Gareth Wilson Gareth Wilson

Guide to Ultravox Webhooks: Features and Best Practices

Published


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

FeatureDetails
ConfigurationAPI: POST https://api.ultravox.ai/api/webhooks with url, events and secrets
Eventscall.started, call.joined, call.ended, call.billed
Signature headerX-Ultravox-Webhook-Signature, bare lowercase hex
Timestamp headerX-Ultravox-Webhook-Timestamp, ISO 8601
Signed contentThe raw body, then the timestamp value, with no separator
SecretsGenerated for you or supplied by you. Several secrets give a comma-separated list of signatures
Event fieldevent
Success responseAny 2xx. Ultravox recommends 204
Retries10, starting after about 30 seconds and doubling each time
DuplicatesPossible

Common events

EventFires whenTypical use
call.startedA call has startedMark the call as in progress
call.joinedA participant has joined the callTrack when the caller connects
call.endedA call has endedFetch the transcript and final call details
call.billedA call has been billedReconcile usage and cost

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

{
  "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:
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.

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:

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 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 can drop repeated deliveries before they reach your service, and its 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 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 for free and handle Ultravox webhooks reliably in minutes.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.