I wrote a while back about Building Voice Agent using ElevenLabs, that can look up orders and open refund cases but never approve one.
That post covered the agent calling out to my Cloudflare Worker via tools. What it didn't cover: ElevenLabs calling me — a post-call webhook, fired after every conversation ends, carrying the transcript, the analysis, the cost breakdown, all of it. Same Worker, one more endpoint.
What is Post-call Webhook?
After a call ends, ElevenLabs can POST the full record to a Webhook endpoint you configure - transcript, per-turn tool calls, sentiment analysis, and a full token-level cost breakdown. Useful for anything you want to do after the call: log it somewhere durable, trigger a CRM update, flag a bad sentiment score for a human to review.
Since I already had a Worker doing double duty as storefront and API tools, adding one more route felt like the obvious move: POST /webhook/:hookId, gated by an HMAC signature ElevenLabs attaches to every request.
Don't Reach for the Official SDK Just for This
ElevenLabs' JS SDK ships a webhooks.constructEvent() helper that does exactly this verification. Before wiring it in, I actually checked what I'd be pulling into a Worker:
@elevenlabs/elevenlabs-js: 24.06 MB unpacked, 22,263 files
dependencies: ws, node-fetch, command-exists
command-exists shells out to check for a binary on PATH — meaningless in a Workers isolate, which has no filesystem or child_process. My wrangler.jsonc doesn't even have nodejs_compat enabled. Maybe constructEvent tree-shakes cleanly away from all of that; fern-generated SDKs vary. I wasn't going to bet a few-MB deployment budget on it to avoid writing 25 lines of standard Web Crypto - especially in a project whose only other dependency is Hono.
Worth checking, if you're deciding the same thing: npm view @elevenlabs/elevenlabs-js dist.unpackedSize before you npm install it into an edge function.
The Actual Signature Scheme
Confirmed straight from ElevenLabs' docs, since I didn't want to guess:
- Header:
elevenlabs-signature, formatted ast=<timestamp>,v0=<hex-signature>— and it can carry more than onev0=value. - Signature = HMAC-SHA256 (hex) of
"<timestamp>.<raw_body>", keyed with your webhook secret. - Reject anything where the timestamp is more than 30 minutes old or in the future — that's ElevenLabs' own replay-protection window.
The code
// src/webhookAuth.ts
import type { Context, Next } from 'hono';
import type { AppEnv } from './index';
const TOLERANCE_SECONDS = 30 * 60; // matches ElevenLabs' own replay window
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++) {
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
}
return bytes;
}
function parseSignatureHeader(header: string): { timestamp: string; signatures: string[] } | null {
const parts = header.split(',');
const timestampPart = parts.find((p) => p.startsWith('t='));
const signatures = parts.filter((p) => p.startsWith('v0=')).map((p) => p.slice(3));
if (!timestampPart || signatures.length === 0) return null;
return { timestamp: timestampPart.slice(2), signatures };
}
async function verifyHmac(secret: string, timestamp: string, rawBody: string, signatureHex: string): Promise<boolean> {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const message = `${timestamp}.${rawBody}`;
return crypto.subtle.verify('HMAC', key, hexToBytes(signatureHex), encoder.encode(message));
}
export function elevenLabsWebhookAuth() {
return async (c: Context<AppEnv>, next: Next) => {
const rawBody = await c.req.text();
const header = c.req.header('elevenlabs-signature');
if (!header) {
return c.json({ error: 'missing_signature' }, 401);
}
const parsed = parseSignatureHeader(header);
if (!parsed) {
return c.json({ error: 'malformed_signature' }, 401);
}
const ageSeconds = Math.abs(Date.now() / 1000 - Number(parsed.timestamp));
if (ageSeconds > TOLERANCE_SECONDS) {
return c.json({ error: 'timestamp_out_of_range' }, 401);
}
for (const sig of parsed.signatures) {
if (await verifyHmac(c.env.WEBHOOK_SECRET, parsed.timestamp, rawBody, sig)) {
return next();
}
}
return c.json({ error: 'invalid_signature' }, 401);
};
}
Wired into the app like any other Hono middleware:
// src/index.ts
import { elevenLabsWebhookAuth } from './webhookAuth';
app.post('/webhook/:hookId', elevenLabsWebhookAuth(), async (c) => {
const body = await c.req.json();
// ... do something with payload.type / payload.data
return c.json({ ok: true });
});
Two things worth calling out in that pairing:
The hookId path segment isn't real security — it's an unguessable URL, defense-in-depth at best. The HMAC check is what actually authenticates the caller.
c.req.json() in the handler works even though the middleware already called c.req.text(). I assumed at first this would break — a Request body can normally only be read once. But Hono's HonoRequest caches the body internally: once .text() has been called, a later .json() call reuses that cached text and parses it, instead of trying to re-read an already-drained stream. No need to manually thread the parsed payload through c.set()/c.get() — worth knowing before you build machinery you don't need.
Why read raw text at all, instead of just calling .json() in the middleware? Because the HMAC has to be computed over the exact bytes ElevenLabs sent. Parsing to an object and re-serializing it for hashing risks differing in key order or whitespace from the original — a correct signature would then fail to verify for reasons that have nothing to do with security.
How to handle Webhook Secret?
WEBHOOK_SECRET isn't thing you hardcode — same pattern as API_TOKEN in the original post. Locally, add it to .dev.vars (and a placeholder pair to .dev.vars.example, so the repo stays self-documenting for anyone else setting it up):
WEBHOOK_SECRET=wsec_your_signing_secret_here
For the deployed Worker, .dev.vars doesn't apply — set the real secret with:
wrangler secret put WEBHOOK_SECRET
which stores it as a Cloudflare Secret rather than a plain environment variable: set once, never shown again, never checked into the repo. The signing secret itself comes from ElevenLabs' side, wherever you configure the post-call webhook URL for your agent.
Secret Rotation: Why Loop Over Every v0= Value
During a secret rotation, ElevenLabs signs each webhook with both the old and new secret for a grace period, producing a header like t=...,v0=<sig-with-old-secret>,v0=<sig-with-new-secret>. Your Worker doesn't know in advance which one matches whatever secret you currently have configured — so the correct check is "does any provided signature match," not "does the first one match." Outside of a rotation, there's just one value and the loop runs once. Cheap insurance for a scenario you'll eventually hit.
Testing it from Windows:
1) Test without a signature
curl.exe -X POST "http://localhost:5173/webhook/1234"
Output:
{"error":"missing_signature"}
2) Test with a correct signature
curl doesn't build the signature for you — you compute it yourself and attach the header. On Windows, .NET already has HMAC-SHA256 built in, so no extra tools.
Write body to a file, and have curl read the exact bytes from there:
$secret = "your-webhook-secret"
$body = '{"type":"post_call_transcription","data":{"foo":"bar"}}'
$timestamp = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()
$message = "$timestamp.$body"
$hmac = New-Object System.Security.Cryptography.HMACSHA256
$hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($secret)
$hashBytes = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($message))
$signatureHex = ($hashBytes | ForEach-Object { $_.ToString("x2") }) -join ""
$header = "t=$timestamp,v0=$signatureHex"
$bodyFile = Join-Path $env:TEMP "webhook-body.json"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($bodyFile, $body, $utf8NoBom)
curl.exe -X POST "http://localhost:5173/webhook/1234" `
-H "Content-Type: application/json" `
-H "elevenlabs-signature: $header" `
--data-binary "@$bodyFile"
Output:
{"ok":true}
3) Test with an invalid signature
Output:
{"error":"invalid_signature"}
Confirmed Working in Production
Add Post-call Webhook to your ElevenLabs Agent. In ElevenLabs dashboard Configure > Settings > Security and choose Select Webhook
And choose Create Webhook, include your Cloudflare Worker's Webhook Endpoint:
Call you ElevenLabs Agent, to test the Post-call Webhook. Once the call is finished, navigate to Cloudflare Dashboard: Compute > Observability and choose your Worker to get its logs:
I trimmed down the payload here to the interesting parts (the full thing includes a complete transcript, per-turn latency metrics, and TLS/geo request metadata I'm not publishing):
{
"type": "post_call_transcription",
"event_timestamp": 1789733272,
"data": {
"agent_id": "agent_8001m1rz48w8ez2r9zqrck4214jf",
"status": "done",
"environment": "production",
"metadata": {
"call_duration_secs": 32,
"charging": {
"llm_price": 0.0305259,
"tts_usage": { "total_characters": 569, "total_audio_output_seconds": 31.2 },
"asr_usage": { "total_audio_input_seconds": 9.42 }
}
},
"analysis": {
"call_successful": "success",
"call_summary_title": "Order Status Check",
"sentiment_analysis": { "overall_label": "positive" }
}
}
}
That per-token cost breakdown under metadata.charging is genuinely handy if you're trying to estimate what a conversational agent actually costs to run per call.
Wrap-up
The HMAC verification itself is unremarkable once you have the right message format — 25 lines, zero dependencies beyond Hono, which was the whole point of not reaching for the SDK.
Code's in the same repo as the original post: github.com/palermo-777/elevenlabs-agent-cf.



Top comments (0)