> For complete lifecycle-event payload schemas and examples, use the canonical overview: Session /api/sessions; Room lifecycle, participants, and room playback /api/rooms; Session playback /api/playback; Recording /api/recording; DTMF /api/keypad-input; Messages /api/messages; WebSocket Session /api/web-socket. Use /api/event-delivery for transport behavior. Endpoint pages name relevant events, but these overviews are the canonical references. # Event delivery > Build reliable calling experiences with Vorbal APIs for calls, media, messaging, and webhooks. ## API Docs - Event delivery [Webhook signature public keys](https://docs.vorbal.dev/api/event-delivery/webhooks/get-webhook-public-keys.md) ## OpenAPI Specification The raw OpenAPI 3.1 specification for this API is available at: - [OpenAPI JSON](https://docs.vorbal.dev/openapi.json) - [OpenAPI YAML](https://docs.vorbal.dev/openapi.yaml) > **Note:** This page contains both a page directory (above) and the landing page content (below). The page directory is generated for agent use and does not appear on the landing page. > For complete lifecycle-event payload schemas and examples, use the canonical overview: Session /api/sessions; Room lifecycle, participants, and room playback /api/rooms; Session playback /api/playback; Recording /api/recording; DTMF /api/keypad-input; Messages /api/messages; WebSocket Session /api/web-socket. Use /api/event-delivery for transport behavior. Endpoint pages name relevant events, but these overviews are the canonical references. # Event delivery The platform delivers webhook notifications to your server via HTTP POST. Each webhook reports an event that already happened. An API operation's `202 Accepted` response only confirms that the request was accepted for processing. ## Event catalog Events are grouped by category. Each owning API overview documents every event name, its payload fields, and when it fires: | Category | Events | Reference | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | | Session lifecycle | `session.created`, `session.ringing_started`, `session.early_media_started`, `session.answered`, `session.ended` | [Session events](/api/sessions#lifecycle-events) | | DTMF | `dtmf.received`, `digits.collected` | [DTMF events](/api/keypad-input#lifecycle-events) | | Playback | `playback.started`, `playback.file.ended`, `playback.stopped`, `playback.failed` | [Playback events](/api/playback#lifecycle-events) | | Recording | `recording.became_available`, `recording.ended`, `recording.failed`, `recording.speech.started`, `recording.speech.ended` | [Recording events](/api/recording#lifecycle-events) | | Rooms, participants, and room playback | `room.created`, `room.deleted`, `room.participant.*`, `room.playback.*` | [Room events](/api/rooms#lifecycle-participant-and-playback-events) | | Messages | `message.sent`, `message.delivery_status` | [Message events](/api/messages#lifecycle-events) | | WebSocket Session | `websocket.connected`, `websocket.disconnected`, `websocket.failed`, `websocket.audio_playback_completed` | [WebSocket events](/api/web-socket#lifecycle-events) | Every event has a required top-level `aud` field containing the configured application UUID and an event-time `timestamp` in RFC 3339 / ISO 8601 UTC format, such as `2026-06-28T14:30:00.000Z`. Delivery requests use W3C `traceparent` and `tracestate` headers for distributed tracing. After verifying the signature, take these steps: 1. Require `aud` to equal the application UUID. 2. Process events in the partition order described in [Ordering guarantees](#ordering-guarantees). ## HTTP POST delivery format Each request has a JSON body and headers for identification, deduplication, and security. ### Request headers | Header | Value | Description | | ------------------- | ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `Content-Type` | `application/json` | Always set. The request body is a JSON object. | | `User-Agent` | `Webhook-Delivery/1.0` | Identifies the platform's webhook delivery client. | | `Webhook-Id` | `{unique_id}` | Stable logical-event identifier across redeliveries. Treat it as opaque and use it for deduplication only after verification. | | `Webhook-Timestamp` | `{unix_seconds}` | Timestamp generated for this HTTP delivery attempt. Reject stale values to limit replay. | | `Webhook-Signature` | `v1a,{base64}` | Space-delimited Standard Webhooks Ed25519 signatures over `{webhook_id}.{webhook_timestamp}.{raw_body}`. Rotation may include two `v1a` signatures. | ## Signature verification Vorbal follows the [Standard Webhooks specification](https://github.com/standard-webhooks/standard-webhooks/blob/main/spec/standard-webhooks.md) and signs every delivery with an Ed25519 key. Verification proves that the raw request body and delivery metadata came from Vorbal and were not modified in transit. Signing is always active. Fetch the public verification keys and use them to verify each delivery. For example, a delivery includes these signature headers: ```http Webhook-Id: 01JY0M6M2C1Z8ZQ0CNA1K8A6JM Webhook-Timestamp: 1786795200 Webhook-Signature: v1a,iQ8s...base64...Cg== ``` ### Signing input For each `v1a` entry, Vorbal signs this exact byte sequence: ```text .. ``` The first two separators are literal period bytes (`0x2e`). The raw request body follows the second period without reformatting or a trailing newline. > **Warning** > > **Use the raw bytes.** Verify before JSON parsing. Parsing and re-encoding JSON > can change whitespace, key order, or escaping and invalidate the signature. `Webhook-Id` is stable for the logical event. `Webhook-Timestamp` and `Webhook-Signature` are regenerated on each delivery attempt. ### Signed audience Every webhook body has a required top-level `aud` claim containing the application UUID: ```json { "aud": "app_3f9c0b2a-7d41-4e8b-9f12-2a6c5d0e7b34", "event": "session.ended", "timestamp": "2026-08-15T12:00:00.000Z" } ``` After signature verification, require `aud` to be a string equal to the application UUID. Reject a missing or mismatched audience before performing side effects. ### Fetch public keys Fetch the active Ed25519 public keys from: ```http GET /.well-known/webhook-public-keys HTTP/1.1 Host: api.vorbal.dev ``` The response has this structure: ```json { "keys": [ { "akid": 2, "alg": "ed25519", "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" }, { "akid": 1, "alg": "ed25519", "public_key": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=" } ] } ``` | Field | Description | | ------------ | --------------------------------------------------------------------------------------------------------------- | | `keys` | Complete active verification-key set. Atomically replace the cached set on refresh. | | `akid` | Opaque key identifier for cache replacement and operator diagnostics. It is not carried in `Webhook-Signature`. | | `alg` | Signature algorithm. Currently always `ed25519`. | | `public_key` | Canonical standard-base64 encoding of the raw 32-byte Ed25519 public key. | Refresh the full key set periodically. During rotation, the endpoint publishes the current and previous public keys while the delivery carries one signature from each corresponding private key. Verify the supplied signatures against the trusted active keys and accept when at least one signature/key pair verifies. Never trust a public key supplied by the webhook request itself. ### Verification order 1. Enforce a request-body size limit and retain the raw body bytes. 2. Require non-empty `Webhook-Id`, `Webhook-Timestamp`, and `Webhook-Signature`. Reject a `Webhook-Id` containing `.`. 3. Parse `Webhook-Timestamp` as Unix seconds and reject excessive clock skew, such as more than five minutes. 4. Parse the space-delimited signature entries and strictly decode every supported `v1a` signature from canonical standard base64. Require at least one well-formed `v1a` entry. 5. Construct `..` and require at least one signature to verify against the trusted active Ed25519 keys. 6. Parse and schema-validate the JSON body. Require `aud` to equal the application UUID. 7. Atomically claim `(aud, Webhook-Id)` in durable storage before side effects. Retain completed claims for at least 24 hours. If another handler owns a live processing lease for the same delivery, return `425 Too Early` with `Retry-After: 30`. Mark the claim complete only after side effects commit; release it and return non-`2xx` on failure so delivery can be retried. > **Warning** > > Signature validity is necessary but not sufficient. Always validate freshness, > the signed `aud` claim, the event schema, and deduplication state. The examples below verify header syntax, timestamp freshness, signatures, and the `aud` claim. Apply the request-size, schema-validation, and durable deduplication steps above around the verifier. ### Verification examples #### Node.js ```javascript const crypto = require('crypto'); function decodeCanonicalBase64(value, length) { if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null; const decoded = Buffer.from(value, 'base64'); return decoded.length === length && decoded.toString('base64') === value ? decoded : null; } // publicKeys contains raw 32-byte Ed25519 public keys from the well-known endpoint. function verifyWebhook(rawBody, headers, publicKeys, expectedAudience) { const id = headers['webhook-id']; const timestampText = headers['webhook-timestamp']; const signatureHeader = headers['webhook-signature']; if (!id || id.includes('.') || !/^\d+$/.test(timestampText || '') || !signatureHeader) return false; const timestamp = Number(timestampText); if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false; const signatures = signatureHeader.split(/\s+/).flatMap((entry) => { const comma = entry.indexOf(','); if (comma < 1 || entry.slice(0, comma) !== 'v1a') return []; const signature = decodeCanonicalBase64(entry.slice(comma + 1), 64); return signature ? [signature] : []; }); if (signatures.length === 0) return false; const signingInput = Buffer.concat([ Buffer.from(`${id}.${timestampText}.`, 'utf8'), rawBody, ]); let verified = false; for (const rawKey of publicKeys) { if (rawKey.length !== 32) continue; const spki = Buffer.concat([ Buffer.from('302a300506032b6570032100', 'hex'), rawKey, ]); for (const signature of signatures) { if (crypto.verify(null, signingInput, { key: spki, format: 'der', type: 'spki' }, signature)) { verified = true; break; } } if (verified) break; } if (!verified) return false; try { const event = JSON.parse(rawBody.toString('utf8')); return typeof event.aud === 'string' && event.aud === expectedAudience; } catch { return false; } } ``` #### Python ```python import base64 import binascii import json import time from cryptography.exceptions import InvalidSignature from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey def decode_signature(value: str) -> bytes | None: try: decoded = base64.b64decode(value, validate=True) except (binascii.Error, ValueError): return None if len(decoded) != 64 or base64.b64encode(decoded).decode("ascii") != value: return None return decoded def verify_webhook(raw_body: bytes, headers, public_keys, expected_audience: str) -> bool: webhook_id = headers.get("Webhook-Id", "") timestamp_text = headers.get("Webhook-Timestamp", "") signature_header = headers.get("Webhook-Signature", "") if not webhook_id or "." in webhook_id or not timestamp_text.isascii() or not timestamp_text.isdigit(): return False timestamp = int(timestamp_text) if abs(time.time() - timestamp) > 300: return False signatures = [] for entry in signature_header.split(): version, separator, encoded = entry.partition(",") if separator and version == "v1a": signature = decode_signature(encoded) if signature is not None: signatures.append(signature) if not signatures: return False signing_input = f"{webhook_id}.{timestamp_text}.".encode() + raw_body verified = False for raw_key in public_keys: if len(raw_key) != 32: continue key = Ed25519PublicKey.from_public_bytes(raw_key) for signature in signatures: try: key.verify(signature, signing_input) verified = True break except InvalidSignature: pass if verified: break if not verified: return False try: event = json.loads(raw_body) except (UnicodeDecodeError, json.JSONDecodeError): return False return isinstance(event, dict) and isinstance(event.get("aud"), str) and event["aud"] == expected_audience ``` #### Go ```go import ( "crypto/ed25519" "encoding/base64" "encoding/json" "strconv" "strings" "time" ) func VerifyWebhook( rawBody []byte, webhookID, timestampText, signatureHeader, expectedAudience string, publicKeys []ed25519.PublicKey, ) bool { if webhookID == "" || strings.Contains(webhookID, ".") || timestampText == "" || signatureHeader == "" { return false } timestamp, err := strconv.ParseInt(timestampText, 10, 64) now := time.Now().Unix() if err != nil || now-timestamp > 300 || timestamp-now > 300 { return false } var signatures [][]byte for _, entry := range strings.Fields(signatureHeader) { version, encoded, ok := strings.Cut(entry, ",") if !ok || version != "v1a" { continue } signature, err := base64.StdEncoding.Strict().DecodeString(encoded) if err == nil && len(signature) == ed25519.SignatureSize && base64.StdEncoding.EncodeToString(signature) == encoded { signatures = append(signatures, signature) } } if len(signatures) == 0 { return false } signingInput := append([]byte(webhookID+"."+timestampText+"."), rawBody...) verified := false for _, publicKey := range publicKeys { if len(publicKey) != ed25519.PublicKeySize { continue } for _, signature := range signatures { if ed25519.Verify(publicKey, signingInput, signature) { verified = true break } } if verified { break } } if !verified { return false } var event struct { Audience string `json:"aud"` } return json.Unmarshal(rawBody, &event) == nil && event.Audience != "" && event.Audience == expectedAudience } ``` ### Key rotation During rotation, Vorbal publishes both active public keys and sends two space-delimited signatures: ```http Webhook-Signature: v1a, v1a, ``` A consumer with either trusted key can verify the delivery during the overlap. Refresh the complete public-key set atomically so newly introduced keys become trusted and retired keys do not remain cached indefinitely. ## Webhook delivery and retries The same logical event can reach your endpoint more than once. The first two delivery cycles can retry once after approximately `500ms`; the third cycle makes one final attempt. Each individual attempt is allowed up to `10 seconds` to complete before it is treated as a timeout. | Parameter | Value | | -------------------------------- | -------------------------------------- | | Max delivery cycles per event | `3` | | Max HTTP POST attempts per event | `5` (`2 + 2 + 1`) | | Delivery window | `5 minutes` from the event `timestamp` | | Immediate retry backoff | \~`500ms` | | Timeout per attempt | `10 seconds` | ### Delivery window An event remains eligible for five minutes from its body `timestamp`. After that, it moves to the dead-letter queue without an HTTP request. The window can end before all three cycles or five attempts are used. After a webhook endpoint outage or processing backlog lasting more than five minutes, do not assume that every event will arrive when service recovers. Expired events create permanent delivery gaps; there is no public replay mechanism. ### Response handling | Response | Action | | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `2xx` | The event is acknowledged and removed from the queue. | | `3xx` | **Retryable redirect-policy failure.** Redirects aren't followed, and the signed body and headers aren't forwarded. Configure the final HTTPS endpoint directly. | | [`425 Too Early`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/425) | **Active delivery lease.** Defers the event at a persistent not-before deadline without consuming the delivery budget or counting as an endpoint failure. A valid `Retry-After` sets a delay of up to 30 seconds; a missing or invalid value defaults to 30 seconds. Use it while another handler owns and renews the same delivery claim. | | `408` or `429` | **Retryable response.** Uses the same budget as `5xx`. A valid `Retry-After` can extend the inline delay to at most 30 seconds. | | Other `4xx` | **Permanent consumer failure.** The event is attempted once, dead-lettered with `reason: consumer_rejected` and the numeric status, then acknowledged. | | `5xx` | **Retryable failure.** The platform retries within the current cycle when possible, then continues under the three-cycle budget. | | Network / timeout / connection error | **Retryable failure.** Includes DNS failures, refused connections, and timeouts. | ## Ordering guarantees Events in the same resource scope are processed sequentially. A retryable failure or `425 Too Early` holds later events in that scope until the event succeeds, is dead-lettered, or expires. The platform selects the ordering scope as follows: * All `room.*` events with the same `room_id`, including participant and playback events, share one Room scope. * For other events, the first non-empty identifier in this order defines the scope: `message_uuid`, `session_uuid`, `room_id`, `recording_uuid`, then `operation_uuid`. For example, `message.*` events are ordered by `message_uuid`. Session events are ordered by `session_uuid`, so when both arrive, [`session.answered`](/api/sessions#sessionanswered) arrives before [`session.ended`](/api/sessions#sessionended). Different resource scopes can be delivered concurrently and interleaved. Do not infer an order between a Room scope and a Session scope, even when the Session is a participant in that Room. The endpoint circuit breaker can defer every scope that uses the same webhook URL.