Webhooks & SSE
Register webhooks, verify delivery signatures, and use the SSE wait endpoints to react to events without polling.
The Agent API can tell your own code when something happens in two ways: a signed HTTP POST to a webhook you register, or a short-lived Server-Sent-Events connection that resolves once a matching event fires. Both carry the same thin, notification-only payload - see Event catalogue for the full event list and envelope shape.
Register a webhook
POST /v1/webhooks
{"url": "https://example.com/hook", "events": ["job.completed"], "delivery_mode": "at-least-once"}| Field | Required | Description |
|---|---|---|
url | Yes | Must start with http:// or https://. See "Where a webhook can point" below. |
events | Yes | A non-empty list of event names. See Event catalogue for the full list. |
delivery_mode | No | at-least-once (default) or at-most-once. |
Where a webhook can point
The url's host must resolve to a public address, or to a loopback/private host:port you've explicitly added to Local webhook targets (Settings > Integrations). With an empty allowlist, a loopback or private-range host is rejected at registration - 400 - via a best-effort DNS check, rather than being silently accepted and then never delivered.
Link-local and cloud-metadata addresses (including 169.254.169.254), the unspecified address, and multicast are never allowlistable - no setting overrides that.
The allowlist is read once at startup
Adding a host to Local webhook targets has no effect until you restart Meetily. If a locally-targeted webhook seems to ignore its allowlist entry, restart before debugging anything else.
The secret is shown once
A successful registration returns 201 with an hmac_secret:
{
"id": "...",
"url": "https://example.com/hook",
"events": ["job.completed"],
"delivery_mode": "at-least-once",
"created_at": "...",
"hmac_secret": "<random secret, shown once>"
}hmac_secret appears only in this response. Store it immediately - GET /v1/webhooks and every other read of a webhook never include it again, and there's no "reveal secret" endpoint.
Approve the destination before anything delivers
Registering a webhook doesn't mean it starts receiving events. GET /v1/webhooks/{id} exposes an approval_state field, one of:
| State | Meaning |
|---|---|
pending | Freshly registered. Delivers nothing. |
allowed | Approved. This is the only state that delivers. |
denied | Rejected. Delivers nothing. |
A freshly-registered destination starts pending, and this includes loopback and allowlisted local targets - registering http://127.0.0.1:8787/ still lands in pending and delivers nothing until approved. Approve it under Settings > Integrations > Destinations (see Enable & connect) to move it to allowed. Denying or revoking a destination works even while the Automation API is off.
A pending destination fails silently
There's no error and no failed-delivery log entry - a webhook stuck at pending simply never receives anything. If a webhook you registered seems to be doing nothing, check its approval_state before assuming your receiver or signature check is broken.
Managing webhooks
| Route | Notes |
|---|---|
GET /v1/webhooks | Your own webhooks only. |
DELETE /v1/webhooks/{id} | 404 if the id doesn't exist, 403 if it exists but isn't yours. |
GET /v1/webhooks/{id}/deliveries | The delivery log for one webhook. |
POST /v1/webhooks/{id}/test | Sends a synthetic delivery so you can confirm your receiver and signature check work end to end. |
All four ride the Read scope floor and are owner-only. Deleting a webhook you own does not require the delete scope - that scope is for deleting a meeting. These routes are also gated by whether webhook delivery is turned on in Settings (off by default) - see Enable & connect.
The payload stays thin
Every delivery carries the same small, notification-only envelope - no transcript text, no summary content, nothing beyond an event name and a resource id. See Event catalogue for the full envelope shape.
Delivery mechanics
Delivery is bounded best-effort, not guaranteed:
at-least-once(the default) retries up to 6 attempts total, backing off1s, 2s, 4s, 8s, 16s, for as long as Meetily keeps running.at-most-oncesends a single attempt and never retries, no matter the outcome.- Duplicates are possible, and deliveries can be lost outright.
- Nothing is ever replayed after an app restart - whatever hasn't delivered yet when Meetily restarts is gone.
- A
2xxresponse means the request was accepted, not that your automation succeeded - Meetily has no way to know what your receiver did with it.
Because of this, your handler must be idempotent, keyed on event_id - treat a repeat delivery of the same event_id as a no-op, not a second occurrence.
Each delivery attempt that doesn't succeed is logged with a reason code, readable via GET /v1/webhooks/{id}/deliveries:
| Reason code | Meaning |
|---|---|
delivery_failed | The request couldn't be delivered - a network or transport-level failure. |
handler_error | The destination reported that handling the delivery failed. |
third_party_rejected | The destination rejected the request outright. |
denied | The destination's approval_state is denied, so no attempt was made. |
exhausted | Every retry attempt was used without a successful delivery. |
Verify the signature
Every delivery carries two headers:
X-Meetily-Signature: sha256=<hex HMAC-SHA256>
X-Meetily-Timestamp: <unix_seconds>The signature is computed over {timestamp}.{body} - the literal X-Meetily-Timestamp value you received, a literal dot, then the raw request body bytes exactly as received. Don't re-serialize the parsed JSON before checking - that can reorder keys or change whitespace and break the comparison.
import hmac
import hashlib
def verify_signature(secret: str, timestamp: str, body: str, header_sig: str) -> bool:
mac = hmac.new(
secret.encode(),
f"{timestamp}.{body}".encode(),
hashlib.sha256,
).hexdigest()
expected = f"sha256={mac}"
return hmac.compare_digest(expected, header_sig)Standard library only, no SDK required. Always compare with hmac.compare_digest (or your language's constant-time equivalent), never ==, to avoid a timing side channel.
TIMESTAMP="1699999999"
BODY='{"schema_version":1,"event_id":"...","event":"job.completed","occurred_at":"...","resource":{"kind":"job","id":"job-abc123"},"delivery_id":"..."}'
SECRET="your_hmac_secret"
EXPECTED=$(printf '%s.%s' "$TIMESTAMP" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
echo "sha256=$EXPECTED"
# Compare this against the X-Meetily-Signature header value.Use the raw body bytes exactly as received on the wire, not a re-formatted copy.
SSE wait endpoints
These endpoints hold a connection open and resolve on the first matching event, instead of you polling:
GET /v1/jobs/{id}/wait
GET /v1/recording/wait?until=stopped
GET /v1/meetings/{id}/summary/operations/{operation_id}/waitThe summary-operation wait pairs with POST /v1/meetings/{id}/summary/regenerate, which returns an operation_id you then wait on (the same id the event envelope carries in its optional operation_id field). Each is a plain Server-Sent-Events stream - a curl -N GET with a bearer token is enough, no special Accept header needed. Each emits exactly one event, then closes - never loop expecting a second frame from the same request; issue a new request to wait again. On timeout you get a single timeout event instead of an HTTP error. The server caps every wait at roughly 3600 seconds regardless of what you ask for.
curl -N -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8420/v1/jobs/$JOB_ID/wait"curl -N -H "Authorization: Bearer $TOKEN" \
"http://127.0.0.1:8420/v1/recording/wait?until=stopped&timeout=60"Known limits
Known limits
- A loopback receiver keeps everything on this machine. An allowlisted LAN or remote destination is a real network egress point - both the webhook delivery itself and any content your receiver fetches back with its own token leave the machine.
- For a summary the desktop app generates on its own there is no operation to wait on, so watch
summary.completedby webhook. An API-initiatedPOST .../summary/regeneratereturns anoperation_idyou can instead wait on with the summary-operation SSE route above. transcript.updatedis a liveness signal, not a content channel - like every event here, it never carries transcript text (see Event catalogue).
Last updated on
Meetily APIs
The full HTTP API surface: every route, its scope, request and response shapes, the error envelope, and the operation annotations - grounded in the running gateway.
Event catalogue
The scope model, the full list of events, the delivery envelope, and how the frozen workflow trigger ids map to them.
