I've seen two production incidents where spoofed webhook POSTs slipped past transcription handlers because nobody verified signatures. One team got 14,000 fake "completed" callbacks in an hour. Another pushed garbage transcripts into a live agent queue before anyone noticed the HMAC check was commented out.
If you're running async speech-to-text for AI agents, webhook signature verification isn't optional. It's the line between a reliable voice pipeline and a public endpoint anyone can spam. In this guide, I'll walk through the HMAC pattern I use on every Privocio deployment, plus replay protection and idempotent handlers that survive real traffic.
In our complete guide to speech-to-text for AI agents, we covered how async handoffs fit the STT-LLM-TTS stack. For the delivery mechanics, see async transcription with webhooks. This article focuses on the security layer most teams skip until something breaks.
Why unverified webhooks fail in production
An unauthenticated webhook URL is just a POST endpoint on the public internet. Attackers don't need your API key. They need your callback URL, which often leaks through client-side configs, error logs, or support tickets.
I've audited handlers that accepted any JSON body with a status: completed field. Within minutes of a URL exposure, bots hammer those endpoints. Your workers dequeue fake jobs, your database fills with junk transcripts, and your downstream LLM burns tokens on garbage text.
The fix is cryptographic proof that the payload came from your transcription provider. Every major async STT service, including Deepgram, AssemblyAI, and Privocio, signs webhook bodies with a shared secret. Your job is to recompute that signature before you touch the payload.
| Attack vector | Without verification | With HMAC verification |
|---|---|---|
| Spoofed completion events | Fake jobs marked done | Rejected at the edge |
| Replayed old callbacks | Duplicate processing | Blocked by timestamp + idempotency |
| Payload tampering | Modified transcript text | Signature mismatch |
| Denial-of-service floods | Worker pool saturated | Cheap 401 before heavy work |
How HMAC signature verification works
HMAC signature verification for transcription webhooks follows the same pattern across most providers. The service takes the raw request body (exact bytes, before JSON parsing), combines it with your webhook secret, and computes an HMAC-SHA256 hash. That hash arrives in a header, usually named something like X-Webhook-Signature or X-Privocio-Signature.
Your handler must:
- Read the raw body as bytes first. Parsing JSON before verification breaks the hash because whitespace changes the input.
- Pull the signature from the header and strip any prefix like
sha256=. - Recompute HMAC-SHA256 with your stored secret.
- Compare using a constant-time function. Never use
==on signature strings; timing leaks matter on public endpoints.
Here's the Python pattern I've shipped on three agent platforms:
import hmac
import hashlib
def verify_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool:
expected = hmac.new(
secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
provided = signature_header.removeprefix("sha256=")
return hmac.compare_digest(expected, provided)
Privocio signs async transcription callbacks the same way. Register your webhook secret in the API docs dashboard, store it in your secrets manager (not in source control), and reject anything that fails verification with HTTP 401 before your handler does real work.
Implementing verification in your handler
The biggest mistake I see is verifying signatures inside the business logic layer. By then you've already parsed JSON, maybe logged the payload, and queued a background task. Verification belongs at the HTTP boundary.
My standard layout:
- Edge middleware: read raw body, check signature, return 401 on failure
- Fast ack: respond 200 within 2 seconds so the provider doesn't retry unnecessarily
- Async worker: fetch the full transcript, update job state, notify the agent
For Node.js handlers, use the raw body hook your framework exposes. In Express, that's express.raw({ type: 'application/json' }) on the webhook route only. Mixing JSON middleware globally will silently break HMAC checks.
When you submit jobs, pass a webhook URL that includes a path per environment (/webhooks/privocio/staging vs /webhooks/privocio/prod). I've debugged too many incidents where staging callbacks overwrote production job state because both environments pointed at the same handler.
If you're comparing providers, OpenAI Whisper API batch flows often use polling instead of webhooks. Webhooks plus HMAC is what I recommend past 50 concurrent jobs. Our pricing page stays flat regardless of callback volume.
Replay protection and idempotency
Signature verification proves the payload came from your provider. It doesn't prove the callback is fresh. An attacker who captures a legitimate signed request can replay it until your secret rotates.
Two defenses I always pair with HMAC:
- Timestamp tolerance: reject callbacks whose
timestampheader is more than 5 minutes old. Store the latest accepted timestamp per job ID to catch replays inside the window. - Idempotency keys: use the provider's job ID as a dedup token. If you've already processed
job_abc123, return 200 and skip reprocessing. I store processed IDs in Redis with a 24-hour TTL.
For HIPAA-regulated pipelines, I log verification failures separately from business errors. Your webhook logs should never store raw audio or full transcripts on failed requests. See our privacy policy for data handling details.
Bottom line: verify the signature at the edge, ack fast, process async, and dedupe by job ID. That stack handled 12,000 callbacks/day on a recent healthcare agent deployment without a single spoofed payload reaching the LLM.
Frequently Asked Questions
Do all speech-to-text APIs sign webhooks?
Most async providers do, but header names and hash algorithms vary. Deepgram uses one header format, AssemblyAI uses another, and Privocio follows the sha256= prefix pattern shown above. Always read the provider's webhook docs instead of assuming interchangeability.
What HTTP status should I return on invalid signatures?
Return 401 Unauthorized immediately. Don't return 200 "to avoid retries" on bad signatures. That teaches attackers your endpoint accepts garbage. Providers retry 5xx responses, not 401s.
Should I verify webhooks in serverless functions?
Yes, but watch cold starts. Read the raw body in the function entrypoint before any JSON parsing. Lambda and Cloud Functions both support raw body access if you configure the trigger correctly. Keep verification under 50ms so you stay inside provider retry windows.
Can I use the same webhook secret across environments?
Don't. Use separate secrets for staging and production. I've seen staging secrets committed to public repos more than once. Rotate immediately if that happens.
Does Privocio support webhook signature verification?
Yes. Privocio signs async transcription callbacks with HMAC-SHA256. Configure your webhook secret in the dashboard, verify on every POST, and see the API documentation for header names and payload fields.
Conclusion: Verify Before You Trust
Webhook signature verification is the cheapest insurance policy in an async transcription pipeline. I've never regretted adding HMAC checks upfront, and I've regretted skipping them exactly twice.
If you're building voice-enabled agents, start with signed webhooks on day one. Verify at the HTTP edge, ack within two seconds, process async, and dedupe by job ID. Test with intentionally tampered payloads before you go live.
Try the flow on our free transcription tier, then scale with predictable pricing. For the full async architecture, read our speech-to-text for AI agents guide and the webhook processing deep dive.
Image Credits:
Cover image: AI-generated illustration — Created with Google Flow Nano Banana.