> For complete lifecycle-event payload schemas and examples, use the canonical overview: Session /api/sessions; Room lifecycle, members, 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.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, members & room playback | `room.created`, `room.deleted`, `room.member.*`, `room.playback.*`                                                        | [Room events](/api/rooms#lifecycle-member-and-playback-events) |
| Messages                       | `message.queued`, `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 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](#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 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:

```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
<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:

```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 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:

```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 `<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

#### 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,<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.

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