Agent skill
Ultravox Webhooks Skill
Receive and verify Ultravox webhooks. Use when setting up Ultravox webhook handlers, debugging X-Ultravox-Webhook-Signature / X-Ultravox-Webhook-Timestamp verification, or handling realtime voice-AI call lifecycle events like call.started, call.joined, call.ended, and call.billed.
Install this skill
npx skills add hookdeck/webhook-skills --skill ultravox-webhooks
Ultravox (ultravox.ai) is a realtime voice-AI platform. Account webhooks notify your server about the call lifecycle. Each delivery is a POST signed with HMAC-SHA256 using the webhook's secret.
When to Use This Skill
- How do I receive Ultravox webhooks?
- How do I verify Ultravox webhook signatures?
- Why is my Ultravox webhook signature verification failing?
- How do I handle
call.started,call.joined,call.endedorcall.billedevents? - Why do I get a 401 for a webhook with several secrets, or none?
Secret Setup (important)
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.
Verification (core)
Headers: X-Ultravox-Webhook-Signature (bare lowercase hex digest, no prefix) and X-Ultravox-Webhook-Timestamp (ISO 8601). The signature is hex HMAC-SHA256, keyed with the secret (UTF-8 bytes, used as-is), over the raw body bytes followed directly by the timestamp header value, with no separator.
const HEX_DIGEST = /^[0-9a-fA-F]{64}$/;
// rawBody: Buffer from express.raw({ type: '*/*' }) - NOT parsed JSON
if (!sig || !ts || !HEX_DIGEST.test(sig)) return res.status(401).end();
const signed = Buffer.concat([rawBody, Buffer.from(ts, 'latin1')]); // body, then timestamp
const expected = crypto.createHmac('sha256', secret).update(signed).digest();
const received = Buffer.from(sig.toLowerCase(), 'hex'); // safe: format validated above
if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {
return res.status(401).end();
}
- Compare the whole header as one digest. Do not split on commas or strip a prefix.
- Missing/empty signature or timestamp, malformed value, or mismatch: respond
401. - Verify first, then
JSON.parse. Never re-serialize the body. - Timestamp window is off by default. Hookdeck retries and replays resend the original timestamp, so a fixed window would reject legitimate retries. To opt in, set
ULTRAVOX_(unset orWEBHOOK_ TOLERANCE_ SECONDS 0= off); Ultravox's own example rejects anything older than 1 minute. - Respond
204(Ultravox's recommendation). Errors and unavailability are retried 10 times, starting around 30 s and doubling each time; duplicates can occur, so handle events idempotently.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Common Event Types
Dispatch on the body's event field.
| Event | Fires When |
|---|---|
call.started | A call has started |
call.joined | A participant has joined the call |
call.ended | A call has ended |
call.billed | A call has been billed |
For payload details, see Ultravox webhook events.
Environment Variables
ULTRAVOX_WEBHOOK_SECRET=your_ultravox_webhook_secret_here # the webhook's single secret
ULTRAVOX_WEBHOOK_TOLERANCE_SECONDS=0 # optional; 0/unset = no timestamp check
Local Development
For local webhook testing, run the Hookdeck CLI via npx (no install, no account required):
npx hookdeck-cli listen 3000 ultravox --path /webhooks/ultravox
Reference Materials
- references/overview.md - Webhook basics, events, delivery and retries
- references/setup.md - Create a webhook and set its secret
- references/verification.md - Signature verification details and gotchas
- examples/ - Runnable Express, Next.js and FastAPI handlers with tests