Meetily
Automation API

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 bounded Server-Sent-Events request that resolves on a route-specific terminal condition. Webhooks carry the thin Gateway event envelope; the summary-operation wait returns operation status instead. See Event catalogue for the 17 subscribable webhook events.

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 under Settings > Integrations > Advanced > Outgoing (webhooks) > Local targets. Registration performs a best-effort DNS precheck, so an unresolved host can be accepted; every actual delivery still resolves the host and requires all resolved addresses to be permitted.

Link-local and cloud-metadata addresses (including 169.254.169.254), the unspecified address, and multicast are never allowlistable - no setting overrides that.

Changes to Local targets apply immediately. Removing a target blocks later retry attempts to it; no Meetily restart is needed.

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 > Advanced > 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
POST /v1/webhooksRegister a webhook. Requires live webhook egress; returns 409 webhooks_disabled when delivery is off.
GET /v1/webhooksYour own webhooks by default. ?all=true is available only to a first-party token with Delete scope.
GET /v1/webhooks/{id}Owner-only.
DELETE /v1/webhooks/{id}Owner-only. 404 if the id doesn't exist, 403 if it exists but isn't yours.
GET /v1/webhooks/{id}/deliveriesOwner-only delivery history for one webhook.
POST /v1/webhooks/{id}/testOwner-only and egress-gated. Returns 202 when queued, 409 webhooks_disabled when delivery is off, or 409 host_not_approved until the destination is allowed and deliverable.

These routes have a Read scope floor. Deleting a webhook you own does not require Delete scope; that scope controls meeting deletion. Reads and deletion remain available when webhook egress is off - only registration and /test require it to be live.

The payload stays thin

Every delivery is content-free: it carries the Gateway event envelope plus webhook-only delivery_id, never transcript text, summary content, meeting content, or a signing secret. See Event catalogue for the exact envelope.

Delivery mechanics

Delivery is bounded best-effort, not guaranteed:

  • at-least-once (the default) makes up to 6 attempts total, backing off 1s, 2s, 4s, 8s, 16s, for as long as Meetily keeps running.
  • at-most-once makes one attempt and never retries.
  • Duplicates are possible, and deliveries can be lost outright.
  • Retry work is not resumed after an app restart. A history row inserted before shutdown can remain pending.
  • 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. delivery_id identifies one delivery lifecycle and stays stable across its retries.

GET /v1/webhooks/{id}/deliveries returns one row per delivery lifecycle, newest first, with these fields:

FieldMeaning
idThe delivery_id included in the webhook body.
eventEvent name.
statuspending, success, failed, blocked, or skipped.
attemptsAttempts made across the lifecycle.
last_attempt_atTime of the most recent attempt, when one occurred.
response_statusLast HTTP response status, when one was received.
created_atWhen the delivery row was inserted.

A pending or denied subscription creates no delivery row. Each history read returns the newest 100 rows. On new inserts, Meetily prunes rows older than seven days and enforces a global 10,000-row cap.

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: bytes, header_sig: str) -> bool:
    mac = hmac.new(
        secret.encode(),
        timestamp.encode() + b"." + body,
        hashlib.sha256,
    ).hexdigest()
    expected = f"sha256={mac}"
    return hmac.compare_digest(expected, header_sig)

Standard library only, no SDK required. Pass the original raw request body bytes, not decoded or re-serialized JSON. Always compare with hmac.compare_digest (or your language's constant-time equivalent), never ==, to avoid a timing side channel.

SSE wait endpoints

SSE waits are route-specific, not a second subscription to the full 17-event catalogue:

RouteTimeout contractTerminal frame
GET /v1/jobs/{id}/wait?timeout=Seconds; default 300, cap 3600.A matching terminal job event or timeout.
GET /v1/recording/wait?until=&timeout=until is required (started or stopped). Default 30 seconds for started, 3600 for stopped; cap 3600.A matching recording event or timeout.
GET /v1/meetings/{id}/summary/operations/{operation_id}/wait?timeout_ms=Milliseconds; default 30000, clamped to 1..60000.An operation-status frame, not a Gateway event envelope.

Inspect the frame's event: name: failure, timeout, or access_ended is not success; check the corresponding resource status before acting.

The summary-operation wait pairs with POST /v1/meetings/{id}/summary/regenerate, which returns the operation_id. Each route streams one result and closes; curl -N with a bearer token is enough. A connection that closes without an application event is inconclusive.

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