# Guide to Ultravox Webhooks: Features and Best Practices

Ultravox webhooks tell your server when a voice AI call starts, when a participant joins, when the call ends and when it's billed. If you run voice agents on Ultravox, webhooks are how you know when to fetch a transcript, update a CRM record, trigger a follow-up or reconcile usage, without polling the calls API.

This guide covers the events Ultravox sends, how deliveries are signed, why the number of secrets on a webhook matters, and how to build a receiver for call events.

## What are Ultravox webhooks?

An Ultravox webhook is an account-level subscription that you create with the API. It names a URL, the events you want and the secrets used to sign deliveries. Ultravox then sends a JSON `POST` for each event, with the event name in `event` and the call in `call`.

Each delivery carries `X-Ultravox-Webhook-Timestamp`, an ISO 8601 timestamp, and `X-Ultravox-Webhook-Signature`, a lowercase hex HMAC-SHA256 over the raw body immediately followed by the timestamp. Note the order: body first, timestamp last, with nothing between them.

## Ultravox webhook features

| Feature | Details |
| --- | --- |
| Configuration | API: `POST https://api.ultravox.ai/api/webhooks` with `url`, `events` and `secrets` |
| Events | `call.started`, `call.joined`, `call.ended`, `call.billed` |
| Signature header | `X-Ultravox-Webhook-Signature`, bare lowercase hex |
| Timestamp header | `X-Ultravox-Webhook-Timestamp`, ISO 8601 |
| Signed content | The raw body, then the timestamp value, with no separator |
| Secrets | Generated for you or supplied by you. Several secrets give a comma-separated list of signatures |
| Event field | `event` |
| Success response | Any `2xx`. Ultravox recommends `204` |
| Retries | 10, starting after about 30 seconds and doubling each time |
| Duplicates | Possible |

## Common events

| Event | Fires when | Typical use |
| --- | --- | --- |
| `call.started` | A call has started | Mark the call as in progress |
| `call.joined` | A participant has joined the call | Track when the caller connects |
| `call.ended` | A call has ended | Fetch the transcript and final call details |
| `call.billed` | A call has been billed | Reconcile usage and cost |

Each body has the event name and the full call object. Here is a trimmed `call.ended` delivery:

```json
{
  "event": "call.ended",
  "call": {
    "callId": "3c90c3cc-0d44-4b50-8888-8dd25736052a"
  }
}

```

The call object has the same fields as the calls API returns, so you can usually act on it without another request. Fetch the transcript separately when you need it.

## Setting up Ultravox webhooks

1. Generate a long random secret, or let Ultravox generate one, and store it server-side, for example as `ULTRAVOX_WEBHOOK_SECRET`.
2. Create the webhook with your Ultravox API key:

```bash
curl -X POST "https://api.ultravox.ai/api/webhooks" \
  -H "X-API-Key: $ULTRAVOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://your-app.example.com/webhooks/ultravox",
    "events": ["call.started", "call.ended", "call.billed"],
    "secrets": ["your-webhook-secret"]
  }'

```

1. Start a test call and confirm your endpoint receives and verifies `call.started` and `call.ended`.

If you receive Ultravox webhooks through Hookdeck, use the Hookdeck source URL as the webhook `url` and create a source with the Ultravox type. The source asks you to:

> Give the Ultravox webhook exactly one secret and paste it here. A webhook without a secret is unsigned, and one with several secrets sends a list of signatures, so both fail verification.

The Ultravox source accepts `POST` only, which is how Ultravox delivers webhooks.

## Securing Ultravox webhooks

Read `X-Ultravox-Webhook-Timestamp` and `X-Ultravox-Webhook-Signature`. Concatenate the raw body bytes and the timestamp value, compute a hex HMAC-SHA256 with your secret, and compare it with the header in constant time. With one secret, the header holds one digest, so compare the whole value.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyUltravoxSignature(rawBody, signatureHeader, timestampHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !timestampHeader || !secret) return false;
  // One secret means one digest. A comma-separated list means the webhook has several secrets.
  if (!/^[0-9a-fA-F]{64}$/.test(signatureHeader)) return false;

  // Optional replay window. Off by default (see below).
  if (toleranceSeconds > 0) {
    // Treat a timestamp without an offset as UTC, as the Python version does.
    const hasOffset = /(Z|[+-]\d\d:?\d\d)$/i.test(timestampHeader);
    const sentAt = Date.parse(hasOffset ? timestampHeader : `${timestampHeader}Z`);
    if (Number.isNaN(sentAt)) return false;
    if (Math.abs(Date.now() - sentAt) / 1000 > toleranceSeconds) return false;
  }

  // The raw body FIRST, then the timestamp, with no separator.
  const message = Buffer.concat([rawBody, Buffer.from(timestampHeader, 'latin1')]);
  const expected = crypto.createHmac('sha256', secret).update(message).digest();
  const received = Buffer.from(signatureHeader, 'hex');
  return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}

const app = express();

app.post('/webhooks/ultravox', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.ULTRAVOX_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  const valid = verifyUltravoxSignature(
    req.body,
    req.get('x-ultravox-webhook-signature'),
    req.get('x-ultravox-webhook-timestamp'),
    secret
  );
  if (!valid) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.call.callId plus event.event, then process asynchronously.
  res.sendStatus(204);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
from datetime import datetime, timezone

def verify_ultravox_signature(raw_body: bytes, signature_header, timestamp_header, secret: str,
                              tolerance_seconds: int = 0) -> bool:
    if not signature_header or not timestamp_header or not secret:
        return False
    # One secret means one digest. A comma-separated list means the webhook has several secrets.
    if not re.fullmatch(r"[0-9a-fA-F]{64}", signature_header):
        return False
    # Optional replay window. Off by default (see below).
    if tolerance_seconds > 0:
        try:
            sent_at = datetime.fromisoformat(timestamp_header)
        except ValueError:
            return False
        if sent_at.tzinfo is None:
            sent_at = sent_at.replace(tzinfo=timezone.utc)
        if abs((datetime.now(timezone.utc) - sent_at).total_seconds()) > tolerance_seconds:
            return False
    # The raw body FIRST, then the timestamp, with no separator.
    message = raw_body + timestamp_header.encode("latin-1")
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()
    return hmac.compare_digest(bytes.fromhex(signature_header), expected)

# In FastAPI: raw_body = await request.body()
# verify_ultravox_signature(raw_body, request.headers.get("x-ultravox-webhook-signature"),
#                           request.headers.get("x-ultravox-webhook-timestamp"),
#                           os.environ["ULTRAVOX_WEBHOOK_SECRET"])

```

Ultravox's own sample splits the header on commas and accepts any matching digest, which supports rotating between several secrets. Hookdeck's Ultravox source, and the code above, compare the whole header as one digest, which is why the webhook should hold exactly one secret.

Ultravox suggests checking that the timestamp is recent, for example within the last minute. The samples above make that check optional and leave it off by default. Hookdeck applies no timestamp tolerance, and its retries and replays resend Ultravox's original headers, so a one-minute window on your endpoint would reject almost every retry. If your endpoint receives directly from Ultravox, pass `toleranceSeconds` to follow Ultravox's advice.

To check your verifier, use the secret `ultravox-guide-secret`, an `X-Ultravox-Webhook-Timestamp` of `2025-10-26T12:00:00.000000` and this exact body:

```
{"event":"call.ended","call":{"callId":"3c90c3cc-0d44-4b50-8888-8dd25736052a"}}

```

The header should be:

```
X-Ultravox-Webhook-Signature: 966e8bca5e53a5f63ca70e799b576beb12466ce576795fddd3247373e7cdae1c

```

> **Make Ultravox webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Ultravox-Webhook-Signature` header on every delivery, returns a `2xx` straight away, and queues each call event until your service has processed it.

## Ultravox webhook limitations and pain points

### Several secrets, several signatures

**The Problem:** Verification starts failing after you add a second secret to rotate.

**Why It Happens:** Ultravox signs with every secret on the webhook and sends the digests as a comma-separated list. That's how Ultravox supports rotation without downtime, but a verifier that compares the whole header, such as Hookdeck's Ultravox source, sees a value that matches none of them.

**Workarounds:**

* Keep exactly one secret on the webhook.
* To rotate, replace the webhook's `secrets` with the new secret alone, and update your verifier at the same time.
* If you verify on your own server and need overlap, split the header on commas and accept any match, as Ultravox's sample does.

**How Hookdeck Can Help:** Deliveries with a signature list stay in Hookdeck's request log, marked as failed verification, so you can see which calls arrived during a rotation and re-read them from the Ultravox calls API.

### Unsigned webhooks

**The Problem:** Requests arrive without a signature header.

**Why It Happens:** A webhook with no secrets isn't signed.

**Workarounds:**

* Check the webhook's `secrets` with the API and set one if it's empty.
* Reject unsigned requests rather than skipping verification.

**How Hookdeck Can Help:** Hookdeck's Ultravox source marks unsigned requests as failed verification, so a missing secret shows up in the request log the first time a call event arrives.

### A one-minute freshness check

**The Problem:** Following Ultravox's advice to reject old timestamps makes retries fail.

**Why It Happens:** Ultravox's first retry comes about 30 seconds after a failure, and a proxy's retries or a manual replay keep the original timestamp, so a one-minute window rejects most of them.

**Workarounds:**

* Apply the window only where requests come straight from Ultravox.
* Rely on idempotent handling for duplicates instead.

**How Hookdeck Can Help:** Hookdeck checks the signature when a request arrives and applies no timestamp tolerance, so a late retry isn't marked as failed. If your service keeps its own window behind Hookdeck, make it wide enough for Hookdeck's retries, or leave it off.

### Duplicates and retries

**The Problem:** A call is processed twice, or a `call.billed` event is recorded twice.

**Why It Happens:** Ultravox retries errors and unavailability 10 times and says duplicates can occur.

**Workarounds:**

* Key processing on the call ID and event name, and make writes idempotent.
* Return `204` as soon as the request is verified and stored.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated deliveries before they reach your service, and its [retries](/docs/retries) to your service run on your schedule.

## Best practices

### Verify against the raw body

Ultravox signs the exact bytes it sends. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`.

### Subscribe only to what you use

Most integrations need `call.ended`, and billing systems need `call.billed`. Leave out `call.started` and `call.joined` if you don't use them.

### Keep the secret out of code

Pass the secret in the `secrets` array from your secret store when you create the webhook, and keep the same value in your receiver's environment.

## Conclusion

Ultravox signs the raw body followed by the timestamp, and signs once per secret. Keep one secret on each webhook, compare the digest in constant time, and only enforce a short freshness window where requests come straight from Ultravox.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Ultravox signature, and holds each call event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Ultravox webhooks reliably in minutes.