Meetily

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"}
FieldRequiredDescription
urlYesMust start with http:// or https://. See "Where a webhook can point" below.
eventsYesA non-empty list of event names. See Event catalogue for the full list.
delivery_modeNoat-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:

StateMeaning
pendingFreshly registered. Delivers nothing.
allowedApproved. This is the only state that delivers.
deniedRejected. 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

RouteNotes
GET /v1/webhooksYour 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}/deliveriesThe delivery log for one webhook.
POST /v1/webhooks/{id}/testSends 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 off 1s, 2s, 4s, 8s, 16s, for as long as Meetily keeps running.
  • at-most-once sends 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 2xx response 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 codeMeaning
delivery_failedThe request couldn't be delivered - a network or transport-level failure.
handler_errorThe destination reported that handling the delivery failed.
third_party_rejectedThe destination rejected the request outright.
deniedThe destination's approval_state is denied, so no attempt was made.
exhaustedEvery 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.

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}/wait

The 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.completed by webhook. An API-initiated POST .../summary/regenerate returns an operation_id you can instead wait on with the summary-operation SSE route above.
  • transcript.updated is a liveness signal, not a content channel - like every event here, it never carries transcript text (see Event catalogue).

Last updated on

On this page