Call2Me
All posts
Engineering

Webhook signature verification: the security step people skip

Your voice agent fires webhooks on call events. If your endpoint accepts any POST that reaches it, anyone who learns the URL can forge events. How HMAC signature verification works, why timestamp checks matter, and how to do it without breaking.

CTCall2Me Team
May 19, 20264 min read
Webhook signature verification flow — HMAC signing, timestamp check, constant-time comparison

Your voice agent generates events constantly: a call started, a call ended, a transcript is ready, data was extracted, a post-call action ran. The platform delivers these to your server as webhooks — HTTP POSTs to an endpoint you control.

Here is the question most teams skip past: when a POST arrives at that endpoint, how do you know it actually came from the platform, and not from someone who guessed or scraped the URL?

If the answer is "it came in on the right URL, so it must be real" — you have a forgery problem waiting to happen.

What this guide covers
  • Why a secret URL is not security.
  • HMAC signatures — what the platform sends and what you check.
  • The timestamp check that stops replay attacks.
  • Constant-time comparison, raw-body gotchas, and a verification checklist.

View the webhooks docs →

A secret URL is not a credential

The instinct is to treat the webhook URL as a password: keep it obscure, and you are safe. You are not.

URLs are not secrets. They end up in places you do not control:

  • Application and access logs.
  • Reverse proxies and load balancer logs.
  • Error trackers that capture the request context on an exception.
  • Screenshots in a support ticket or a Slack thread.

The moment the URL appears in any of those, anyone who reads it can POST to your endpoint. If your handler trusts every POST that arrives, they can forge any event you handle — fake a "call completed," fake an "action succeeded," trigger whatever downstream logic those events drive.

Signature verification fixes this. Even with the URL, an attacker cannot produce a valid request without the signing secret — and the secret never travels over the wire.

How HMAC signatures work

When the platform sends a webhook, it also computes a signature and includes it as a header. The signature is an HMAC — a hash of the request body combined with a shared secret that only you and the platform know.

The flow:

  1. The platform takes the raw request body and a timestamp.
  2. It computes HMAC-SHA256(secret, timestamp + body).
  3. It sends that signature, plus the timestamp, in request headers.

On your side, you do the same computation with your copy of the secret and compare. If the signatures match, the request is authentic — only someone with the secret could have produced it. If they do not match, you reject the request.

The secret itself is never transmitted. It is configured once, out of band, and lives in your environment and the platform's. The signature proves possession of the secret without ever revealing it.

The timestamp check stops replays

Signature verification alone has a gap: replay.

Suppose an attacker captures one valid signed request — through a leaked log, say. The signature on it is real. If your endpoint only checks the signature, the attacker can resend that exact request next week and it still verifies, because nothing about it has changed. They cannot forge new events, but they can replay old ones.

The fix is the timestamp. The platform includes a timestamp in the signed payload, so it cannot be altered without breaking the signature. On your side, after verifying the signature, you also check: is this timestamp within the last few minutes? If it is older, reject it — a legitimate webhook arrives promptly; a request that is ten minutes old is almost certainly a replay.

Signature proves authenticity. Timestamp proves freshness. You need both.

Constant-time comparison

One more subtle point. When you compare your computed signature to the one in the header, do not use a normal ==.

A normal string comparison short-circuits — it returns the instant it finds a byte that does not match. That timing is measurable. An attacker who can send many requests and time the responses can use it to recover the correct signature one byte at a time. This is a timing attack, and it is real.

Use a constant-time comparison function instead — one that always takes the same amount of time regardless of where (or whether) the mismatch is:

  • Python: hmac.compare_digest(a, b)
  • Node.js: crypto.timingSafeEqual(bufferA, bufferB)
  • Go: hmac.Equal(macA, macB)

Every mainstream language ships one. Use it.

Verify the raw body, before parsing

The signature is computed over the exact raw bytes of the request body. If your framework parses the JSON first and you re-serialize it to verify, a difference in key order or whitespace will break the signature even on a legitimate request. Capture the raw body, verify against those bytes, and only then parse the JSON. Accessing the raw body is slightly awkward in many frameworks — and that awkwardness is the single most common source of verification bugs.

A verification checklist

When you implement webhook verification, confirm all five:

  1. Read the raw body before any JSON parsing.
  2. Recompute the HMAC with your copy of the secret over timestamp + raw body.
  3. Compare in constant time — never ==.
  4. Check the timestamp is recent; reject anything older than your window (a few minutes is typical).
  5. Return 401 on any failure, and do not run a single line of downstream logic for a request that did not verify.

If a request fails verification, it does not exist as far as your application is concerned. No logging it as a real event, no side effects — just reject and move on.

Getting started

Call2Me signs every webhook it sends and gives you a signing secret per endpoint. The verification logic above is a dozen lines in any language. Wire it in once, and your event pipeline goes from "trusts anything that reaches the URL" to "trusts only what the platform actually sent." The webhooks documentation has the header names and the exact signing scheme.

Frequently asked

Q.Why not just keep the webhook URL secret?

A secret URL is not a credential. URLs leak — through logs, proxies, browser history, error trackers, and screenshots. Anyone who obtains the URL can POST to it. Signature verification means that even with the URL, an attacker cannot forge a valid event without the signing secret, which never travels over the wire.

Q.What's the timestamp check actually protecting against?

Replay attacks. Without it, an attacker who captures one valid signed request — say, a 'payment succeeded' event — can resend that exact request later and it will still verify, because the signature is still correct. Including a timestamp in the signed payload and rejecting anything older than a few minutes makes a captured request useless after that window.

Q.Why does string comparison need to be constant-time?

A normal equality check returns as soon as it finds a mismatched byte. By measuring how long comparisons take, an attacker can learn the signature one byte at a time — a timing attack. Constant-time comparison functions always take the same time regardless of where the mismatch is, closing that side channel. Most languages ship one: hmac.compare_digest in Python, crypto.timingSafeEqual in Node.

Q.Should I verify before or after parsing the JSON body?

Before. The signature is computed over the raw request body bytes. If you parse the JSON first and then re-serialize it to verify, key ordering or whitespace differences will break the signature. Capture the raw body, verify against it, and only then parse. Many frameworks make the raw body slightly awkward to access — that awkwardness is the most common cause of verification bugs.

ShareX / TwitterLinkedIn

Try Call2Me free

Spin up a voice agent in 5 minutes. No credit card required.

Start free trial