Event delivery

Understand webhook metadata, signature verification, retries, failures, and event ordering.
View as Markdown

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:

CategoryEventsReference
Session lifecyclesession.created, session.ringing_started, session.early_media_started, session.answered, session.endedSession events
DTMFdtmf.received, digits.collectedDTMF events
Playbackplayback.started, playback.stopped, playback.failedPlayback events
Recordingrecording.became_available, recording.ended, recording.failed, recording.speech.started, recording.speech.endedRecording events
Rooms, members & room playbackroom.created, room.deleted, room.member.*, room.playback.*Room events
Messagesmessage.queued, message.sent, message.delivery_statusMessage events
WebSocket Sessionwebsocket.connected, websocket.disconnected, websocket.failed, websocket.audio_playback_completedWebSocket events

Every event has a required top-level aud field containing the intended app_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 your configured application UUID.
  2. Process events in the partition order described in Ordering guarantees.

HTTP POST delivery format

Each request has a JSON body and headers for identification, deduplication, and security.

Request headers

HeaderValueDescription
Content-Typeapplication/jsonAlways set. The request body is a JSON object.
User-AgentWebhook-Delivery/1.0Identifies 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-Signaturev1a,{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 and signs every delivery with the cluster’s Ed25519 key. Verification proves that the raw request body and delivery metadata came from Vorbal and were not modified in transit.

Signing is cluster-wide and always active. You fetch public verification keys; you never receive or store a shared signing secret.

For example, a delivery includes these signature headers:

1Webhook-Id: 01JY0M6M2C1Z8ZQ0CNA1K8A6JM
2Webhook-Timestamp: 1786795200
3Webhook-Signature: v1a,iQ8s...base64...Cg==

Signing input

For each v1a entry, Vorbal signs this exact byte sequence:

<webhook-id>.<webhook-timestamp>.<raw-body>

The first two separators are literal period bytes (0x2e). The raw request body follows the second period without reformatting or a trailing newline.

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

Because the verification key is shared by the cluster, every webhook body has a required top-level aud claim containing the intended application UUID:

1{
2 "aud": "app_3f9c0b2a-7d41-4e8b-9f12-2a6c5d0e7b34",
3 "event": "session.ended",
4 "timestamp": "2026-08-15T12:00:00.000Z"
5}

After signature verification, require aud to be a string equal to your configured application UUID. Reject a missing or mismatched audience before performing side effects. This prevents a valid delivery captured for one application from being replayed to another application that trusts the same cluster key.

Fetch public keys

The active Ed25519 public keys are published at an unauthenticated endpoint:

1GET /.well-known/webhook-public-keys HTTP/1.1
2Host: api.telekesher.dev

The response has this structure:

1{
2 "keys": [
3 {
4 "akid": 2,
5 "alg": "ed25519",
6 "public_key": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
7 },
8 {
9 "akid": 1,
10 "alg": "ed25519",
11 "public_key": "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE="
12 }
13 ]
14}
FieldDescription
keysComplete active verification-key set. Atomically replace the cached set on refresh.
akidOpaque key identifier for cache replacement and operator diagnostics. It is not carried in Webhook-Signature.
algSignature algorithm. Currently always ed25519.
public_keyCanonical 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 <webhook-id>.<webhook-timestamp>.<raw-body> 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 your configured 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.

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

1const crypto = require('crypto');
2
3function decodeCanonicalBase64(value, length) {
4 if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) return null;
5 const decoded = Buffer.from(value, 'base64');
6 return decoded.length === length && decoded.toString('base64') === value ? decoded : null;
7}
8
9// publicKeys contains raw 32-byte Ed25519 public keys from the well-known endpoint.
10function verifyWebhook(rawBody, headers, publicKeys, expectedAudience) {
11 const id = headers['webhook-id'];
12 const timestampText = headers['webhook-timestamp'];
13 const signatureHeader = headers['webhook-signature'];
14 if (!id || id.includes('.') || !/^\d+$/.test(timestampText || '') || !signatureHeader) return false;
15
16 const timestamp = Number(timestampText);
17 if (!Number.isSafeInteger(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) return false;
18
19 const signatures = signatureHeader.split(/\s+/).flatMap((entry) => {
20 const comma = entry.indexOf(',');
21 if (comma < 1 || entry.slice(0, comma) !== 'v1a') return [];
22 const signature = decodeCanonicalBase64(entry.slice(comma + 1), 64);
23 return signature ? [signature] : [];
24 });
25 if (signatures.length === 0) return false;
26
27 const signingInput = Buffer.concat([
28 Buffer.from(`${id}.${timestampText}.`, 'utf8'),
29 rawBody,
30 ]);
31
32 let verified = false;
33 for (const rawKey of publicKeys) {
34 if (rawKey.length !== 32) continue;
35 const spki = Buffer.concat([
36 Buffer.from('302a300506032b6570032100', 'hex'),
37 rawKey,
38 ]);
39 for (const signature of signatures) {
40 if (crypto.verify(null, signingInput, { key: spki, format: 'der', type: 'spki' }, signature)) {
41 verified = true;
42 break;
43 }
44 }
45 if (verified) break;
46 }
47 if (!verified) return false;
48
49 try {
50 const event = JSON.parse(rawBody.toString('utf8'));
51 return typeof event.aud === 'string' && event.aud === expectedAudience;
52 } catch {
53 return false;
54 }
55}

Key rotation

During rotation, Vorbal publishes both active public keys and sends two space-delimited signatures:

1Webhook-Signature: v1a,<current-signature> v1a,<previous-signature>

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.

ParameterValue
Max delivery cycles per event3
Max HTTP POST attempts per event5 (2 + 2 + 1)
Delivery window5 minutes from the event timestamp
Immediate retry backoff~500ms
Timeout per attempt10 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

ResponseAction
2xxThe event is acknowledged and removed from the queue.
3xxRetryable redirect-policy failure. Redirects aren’t followed, and the signed body and headers aren’t forwarded. Configure the final HTTPS endpoint directly.
425 Too EarlyActive 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 429Retryable response. Uses the same budget as 5xx. A valid Retry-After can extend the inline delay to at most 30 seconds.
Other 4xxPermanent consumer failure. The event is attempted once, dead-lettered with reason: consumer_rejected and the numeric status, then acknowledged.
5xxRetryable failure. The platform retries within the current cycle when possible, then continues under the three-cycle budget.
Network / timeout / connection errorRetryable 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 member 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 arrives before session.ended.

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 member of that Room. The endpoint circuit breaker can defer every scope that uses the same webhook URL.