Meeting webhooks
Meeting webhooks for bookings, calls and AI summaries
Meeto webhooks send a signed HTTPS POST to your server when a booking is created, rescheduled or cancelled, when the booked call ends and when its AI summary is ready. Personal webhooks come with Pro or a paid Team seat, up to 10 endpoints; a Team workspace adds up to 5 shared endpoints. Failed deliveries are retried for about 33 hours, 45 for workspaces, with a delivery log and manual redelivery.
Which events can I receive?
Five events cover a booking from start to finish. Pick any of them per endpoint; each delivery names its event in the Meeto-Event header and the event field.
| Event | Sent when |
|---|---|
| booking.created | A guest books a time on your booking page |
| booking.rescheduled | The booking moves; carries previousBookingUid and previousStartsAt |
| booking.cancelled | The host or guest cancels; carries cancelledBy (host or guest) |
| meeting.ended | A call session in the booked room ends; one booking can have several sessions |
| recap.ready | The AI summary of the booked call is ready, with links to finished recordings |
Subscribe at least one endpoint to booking.created. Later events of a booking are sent only if its creation went out to some endpoint. They go to the endpoints that received booking.created; when none of your endpoints holds that delivery, for example after the log was cleaned up, they go to every endpoint subscribed to the event. A booking made before you added any endpoint stays silent, also when it is cancelled, ends or gets a summary. Summaries need a host on Pro or Team; see AI meeting summaries.
What does a webhook payload look like?
Every event has the same shape, so one parser handles all five. Times are UTC in RFC 3339; timezone is the host's working time zone for display.
POST /hooks/meeto HTTP/1.1
Content-Type: application/json
User-Agent: Meeto-Webhooks/1.0
Meeto-Event: booking.created
Meeto-Webhook-Id: <endpoint id>
Meeto-Delivery-Id: 1421
Meeto-Signature: t=1805702400,v1=5f0c…e9a1{
"event": "booking.created",
"createdAt": "2027-03-20T09:14:03Z",
"occurredAt": "2027-03-20T09:14:03Z",
"bookingUid": "bk_4f1c9a2b7e3d8054a1cb62d7f9",
"ref": "deal-4821",
"tracking": { "ref": "deal-4821", "utm_source": "newsletter" },
"workspace": null,
"host": { "userId": "<user id>", "handle": "anna", "name": "Anna Example", "email": "anna@example.com" },
"guest": { "name": "Sam Guest", "email": "sam@example.com", "company": "Acme" },
"eventType": { "id": "<type id>", "title": "Intro call", "slug": "intro", "durationMin": 30 },
"startsAt": "2027-03-22T10:00:00Z",
"endsAt": "2027-03-22T10:30:00Z",
"timezone": "Europe/Berlin",
"meetingUrl": "https://meeto.me/<room>?k=<room access key>",
"status": "confirmed",
"answers": [{ "label": "Company", "answer": "Acme" }]
}// meeting.ended adds:
"meeting": {
"sessionId": "<call session id>",
"startedAt": "2027-03-22T10:01:12Z",
"endedAt": "2027-03-22T10:34:40Z",
"durationSec": 2008,
"peakParticipants": 2
}
// recap.ready adds:
"recap": {
"url": "https://meeto.me/m/<page token>",
"title": "Intro call",
"recordings": [{ "url": "https://meeto.me/api/m/<page token>/file/<id>/video", "durationSec": 1990 }]
}| Field | Meaning |
|---|---|
| bookingUid | bk_ plus 26 hex characters; a reschedule issues a new one and sends the old one as previousBookingUid |
| ref / tracking | Your own markers from the booking link, for example ?ref=deal-4821&utm_source=newsletter; up to 10 pairs, always present ({} when empty) |
| workspace | null for a personal endpoint; { id, slug } for a workspace endpoint |
| answers / guest.company | The booking form answers; a company question is also copied into guest.company |
| cancelledBy / reason / message | Who cancelled, rebook_requested when the host asked the guest to pick another time, and the note written with the change |
| recap.url / recordings | The summary page and finished video files; they open according to the owner's sharing settings |
| truncated | true when the body was cut to fit 64 KiB: answers shortened to 500 characters and guests to the first 20, and if still too large the answers are removed; ids and times are never cut |
How do I verify the signature?
Compute HMAC-SHA256 with your endpoint secret (whsec_…) over "<t>.<raw body>" and compare it with v1 from Meeto-Signature in constant time. Use the raw request bytes, not re-serialized JSON, and reject a t more than five minutes away from your clock to block replays.
import crypto from "node:crypto";
import express from "express";
const secret = process.env.MEETO_WEBHOOK_SECRET; // whsec_…
function verifyMeetoSignature(rawBody, header, toleranceSec = 300) {
const parts = Object.fromEntries(
(header ?? "").split(",").map((part) => part.split("=", 2)),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.`)
.update(rawBody)
.digest();
const received = Buffer.from(parts.v1, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
const app = express();
// express.raw keeps the exact bytes Meeto signed; do not parse JSON first.
app.post("/hooks/meeto", express.raw({ type: "application/json" }), (req, res) => {
if (!verifyMeetoSignature(req.body, req.get("Meeto-Signature"))) {
return res.sendStatus(401);
}
const deliveryId = req.get("Meeto-Delivery-Id"); // the same on every retry
const event = JSON.parse(req.body.toString("utf8"));
console.log(deliveryId, event.event, event.bookingUid);
res.sendStatus(200); // answer fast, do slow work in your own queue
});
app.listen(3000);import hashlib, hmac, time
def verify_meeto_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
if not t.isdigit() or abs(time.time() - int(t)) > tolerance:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected.encode(), v1.encode())Before real traffic, press Send test on the endpoint. It sends a signed {"event":"test","createdAt":"…"} with Meeto-Event: test and a Meeto-Delivery-Id starting with test-, and shows your server's answer at once. The button allows five tests, then one a minute.
How do retries and ordering work?
Answer with any 2xx to accept a delivery. Meeto waits 10 seconds to connect and 15 seconds for your answer, does not follow redirects (a 3xx counts as a failure) and retries everything else on a fixed schedule.
| Endpoint | Schedule |
|---|---|
| Personal | 1 min, 5 min, 30 min, 2 h, 6 h, 24 h: 7 attempts over about 33 hours |
| Workspace | 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, 24 h: 8 attempts over about 45 hours |
| 422 or 410 answer | Stops at once: the delivery is parked as failed and can only be redelivered by hand |
Retries send the exact same body and the same Meeto-Delivery-Id. Deliveries to one endpoint leave in order, but a delivery that has failed three times stops holding back the ones behind it, so order your own records by occurredAt. Reply 2xx to event names you do not know; new ones may be added.
Delivery log and redelivery
See every attempt
The log on the webhooks screen keeps each delivery for about five weeks: event, status (pending, delivered, failed), attempts, HTTP code and the first 512 bytes of your answer.
Redeliver by hand
A failed delivery has a retry button. It queues the same body once more as a new delivery with a new id; the same row cannot be redelivered twice.
Pause without losing events
Switching an endpoint off pauses it. Events keep queuing and go out when you switch it back on; deliveries still waiting after 30 days are marked failed.
Endpoint URLs must be public https addresses without credentials in them; each endpoint listens to at least one event. A personal secret is shown once, when you create the endpoint. New secret replaces it immediately, and the old one stops verifying.
Personal and workspace webhooks compared
A Team workspace can send events to its own endpoints, managed by the owner and admins on the team webhooks screen. Every paid seat also includes personal webhooks, because a seat gives full Pro.
| Setting | Personal / Workspace |
|---|---|
| Plan | Pro ($6.99/month) or a paid Team seat / Team workspace ($11.99 per seat per month), after its first payment |
| Endpoints | Up to 10 per account / up to 5 per workspace |
| Which bookings | All of your own bookings / bookings of workspace meeting types and team round-robin links, for every member; members' personal types never reach it |
| Secret | Shown once / can be revealed again by owners and admins |
| Rotation | Old secret stops at once / the new secret signs at once; the previous one stays visible for 7 days |
| Test event | test, not logged / test.ping, written to the log |
| Failing endpoint | Keeps retrying per delivery / switched off after 3 days of failures, owner and admins get an email |
After rotating a workspace secret, put the new one into your receiver promptly: deliveries signed with it fail verification on a receiver that knows only the old secret, and they come back on the retry schedule above. A workspace that stops paying pauses its endpoints; events wait in the queue, up to 30 days, and go out after payment. For the call itself, see how Meeto handles security.
Frequently asked questions
Do webhooks fire for meetings created through the REST API?
No. Every event belongs to a booking made on a Meeto booking page, personal or team round-robin. For meetings your backend creates through /api/v1, read the meeting, its notes and its recordings from the API instead.
Can I receive webhooks on the Free plan?
No. Creating, editing, testing and redelivering answer 403 pro_required, and new events of a Free account are not queued. One exception: the cancellation of a booking an endpoint already received still goes out, so you are not left with a meeting that is off. Endpoints made before a downgrade stay listed with their log and can be deleted.
Why did the same event arrive twice?
Delivery is at least once. Automatic retries repeat the Meeto-Delivery-Id, so store it and skip ids you have seen. A manual redelivery gets a new id, so also make your handler safe to run twice for the same bookingUid and event.
Can I point a webhook at localhost while I develop?
No. Production deliveries go only to public https addresses; localhost, private and link-local addresses are refused when you save the endpoint and again when Meeto connects. Expose your local receiver through an https tunnel for testing.
Put it to work
Open your Meeto account to set it up, or explore the working product first.