Skip to content
CaptiFi Connect

Behind the scenes8 min read

Webhook signatures: why HMAC and how to check one

What RFC 2104's HMAC does, why a signed webhook beats a secret in the URL, how to compare in constant time, and the replay window a receiver should enforce.

256: bits of SHA-256 output, the hash inside the HMAC that signs every CaptiFi Connect webhook

256: bits of SHA-256 output, the hash inside the HMAC that signs every CaptiFi Connect webhook. Source: NIST, FIPS 180-4 (August 2015).

A developer asked us this a few weeks ago: "Can I just put a long random token in the webhook URL and check for it?" It's a fair question. Your endpoint is a public HTTPS address, anyone on the internet can POST to it, and a 40-character token in the query string will stop the casual ones. The trouble is what a token proves. It proves the sender knew the URL. It says nothing about whether the body was written by us, whether it was changed on the way, or whether it's the same request you handled ten minutes ago. A signature answers all three, and the maths behind it dates from 1997.

What HMAC is

RFC 2104, "HMAC: Keyed-Hashing for Message Authentication", was published by H. Krawczyk, M. Bellare and R. Canetti in February 1997 as an Informational RFC. The whole construction fits on one line: HMAC is H(K XOR opad, H(K XOR ipad, text)), where H is a hash function, K is the secret key, ipad is "the byte 0x36 repeated B times" and opad is "the byte 0x5C repeated B times". B is the hash's block length in bytes. You pad the key out to a block, XOR it with the inner pad, hash that together with the message, then XOR the key with the outer pad and hash that together with the inner result.

The point of the sandwich is that the key is mixed into the hash twice, which is what stops the length-extension trick that a plain H(key + message) is open to. The RFC says plainly that HMAC's security "depends on cryptographic properties of the hash function H", so the choice of H matters. The hash in the common webhook schemes, ours included, is SHA-256, defined in NIST's FIPS 180-4 (August 2015 edition), with a 512-bit block and a 256-bit digest.

Block size and digest size of the SHA family, bits
  • Block size
  • Digest size
0 bits500 bits1,000 bits1,500 bits2,000 bitsSHA-1, Block size: 512 bitsSHA-1, Digest size: 160 bitsSHA-1SHA-224, Block size: 512 bitsSHA-224, Digest size: 224 bitsSHA-224SHA-256, Block size: 512 bits512 bitsSHA-256, Digest size: 256 bitsSHA-256SHA-384, Block size: 1,024 bitsSHA-384, Digest size: 384 bitsSHA-384SHA-512, Block size: 1,024 bitsSHA-512, Digest size: 512 bitsSHA-512

The block size is the B in RFC 2104: the length the key is padded to before the inner and outer hashes. SHA-512/224 and SHA-512/256 share the 1024-bit block and are left off.

Source: NIST, FIPS 180-4, figure 1 (Secure Hash Algorithm Properties)

See the figures as a table
 Block size (bits)Digest size (bits)
SHA-1512160
SHA-224512224
SHA-256512256
SHA-3841,024384
SHA-5121,024512

The RFC has three things to say about keys, and they all survive into practice. Keys "need to be chosen at random (or using a cryptographically strong pseudo-random generator seeded with a random seed), and periodically refreshed". A key shorter than the hash output "is strongly discouraged as it would decrease the security strength of the function", so for SHA-256 that means at least 32 bytes. And a longer key doesn't buy much: "the extra length would not significantly increase the function strength". The RFC also allows truncating the output and recommends keeping "not less than 80 bits"; webhook schemes don't truncate, so the full 64 hex characters is what you'll see.

Why a signature beats a secret in the URL

A URL token covers the address. Anyone who sees the address can send you anything they like, forever, and addresses get seen: they sit in your web server's access log, your reverse proxy's log, the provider's dashboard, the error page your framework printed the day the endpoint broke, and the message where someone pasted it into a chat to ask for help. Once it's out, every event you receive is suspect, and the only fix is to change the URL everywhere.

An HMAC signature covers the body, with a key that never travels. We hold the secret, you hold the secret, and what crosses the wire is the hash. A request logged in full on your server still can't be used to forge a different one, because the attacker has the signature for that body and timestamp only. Change one byte of the JSON, say a status from "failed" to "delivered" or a phone number in an opt-out event, and the signature no longer matches. That's the property you want for events that move money, update records or take a number off a list.

The Standard Webhooks specification (version 1.0.0, an open design document written to stop every provider inventing its own scheme) describes the common shape. The sender signs the string msg_id.timestamp.payload, that is, the message id, the Unix timestamp and the raw body joined by full stops, with HMAC-SHA256, base64 encodes the result and sends it in a webhook-signature header prefixed with v1. The secret is random, "Between 24 bytes (192 bits) and 64 bytes (512 bits)", base64 encoded and prefixed whsec_ so it's recognisable in a config file. The header is a space-separated list of signatures, which is what lets a provider rotate a secret with no gap: for a while, every event carries a signature under the old key and the new one.

CaptiFi Connect's scheme is a close cousin. The signature header carries two parts, t=1760605206,v1=5f2b... and so on, where t is the Unix second we signed at and v1 is the HMAC SHA-256, in hex, of the string made of t, a full stop and the raw body, keyed with the endpoint's secret. The event id sits inside the body, the signed prefix carries only the timestamp, and a separate delivery header identifies the delivery and stays the same across retries. The webhooks documentation has the header names, the payload for each event and working checks in Node.js, Python and PHP.

Checking one, in order

The check is six steps, and the order matters more than the language.

  1. Read the body as raw bytes, before your framework parses it. The signature is over the bytes we sent, and a re-serialised JSON object will differ in whitespace or key order and fail every time.
  2. Split the signature header into its parts and pull out t and v1. If either is missing, stop.
  3. Compare t with your clock. If it's more than your tolerance away in either direction, stop. CaptiFi Connect's own examples use five minutes.
  4. Compute HMAC-SHA256 over t, a full stop and the raw body, using the secret, and encode it in hex.
  5. Compare your result with v1 using a constant-time comparison, after checking the two are the same length.
  6. Only now parse the JSON. Record the delivery id (or the event id) and skip anything you've already seen, then return a 2xx and do the real work afterwards.

Steps 3 and 5 are the easiest to leave out, and each guards against a different attack.

Why the comparison has to be constant time

A normal string comparison stops at the first byte that differs. That's what makes it fast and what makes it leaky. If an attacker can send a guessed signature and measure how long you take to reject it, a guess whose first byte is right takes a fraction longer than one that's wrong, and the fraction is enough to find the signature one byte at a time. The Standard Webhooks spec puts it bluntly: "use a constant time comparison function to compare the calculated with the expected signature. Failing to do so can expose consumers to timing-attacks and turn them into signing oracles."

Every mainstream runtime has the function. Node's documentation for crypto.timingSafeEqual says it "compares the underlying bytes" of the two inputs "using a constant-time algorithm" and that it "does not leak timing information that would allow an attacker to guess one of the values", which is why it names "comparing HMAC digests" as a suitable use. It also says both inputs "must have the same byte length" and that "An error is thrown if a and b have different byte lengths", so check the length first and fail quietly if it's off. Python has hmac.compare_digest and PHP has hash_equals, both built for the same job. The Node docs add a caution that applies everywhere: using the function "does not guarantee that the surrounding code is timing-safe". Parse the header, decode the hex and compare in the same path whether the header looks plausible or not.

Replays, windows and clocks

The signature proves we wrote the body. It doesn't, on its own, prove we wrote it just now. Anyone who captured a valid request (a compromised log, a misconfigured proxy) can send it again, and a message.delivered that arrives twice is harmless but an opt_out.created or a message.received that arrives twice may not be. Two defences work together, and the spec names both: verify the timestamp "is within some allowable tolerance of the current timestamp to prevent replay attacks", and use the id "as an idempotency key to prevent accidentally processing the same webhook more than once (e.g. save the IDs in redis for 5 minutes)".

The window is a trade-off between replay and clock drift. Five minutes, which is what our examples use, is wide enough that a server whose clock has wandered a little still accepts events, and narrow enough that a captured request goes stale before anyone can do much with it. If you reject everything you receive, check your clock before you check your code; a box that's six minutes out looks exactly like a signing bug. Because the timestamp is inside the signed string, an attacker can't freshen a captured request by editing t; the signature would stop matching.

Retries are the honest reason duplicates exist. A webhook that times out, or gets a 500, or hits a closed connection gets sent again, and the spec recommends "a retry schedule spanning multiple days, with an exponential backoff" plus "some level of random jitter". Its example schedule runs from an immediate attempt to a tenth one about 75 hours in.

Example retry schedule from the Standard Webhooks spec, hours after the first attempt
0 h20 h40 h60 h80 hAttempt 1, Hours since first attempt: 0 hAttempt 1Attempt 2, Hours since first attempt: 0 hAttempt 2Attempt 3, Hours since first attempt: 0.08 hAttempt 3Attempt 4, Hours since first attempt: 0.58 hAttempt 4Attempt 5, Hours since first attempt: 2.58 hAttempt 5Attempt 6, Hours since first attempt: 7.58 hAttempt 6Attempt 7, Hours since first attempt: 17.58 hAttempt 7Attempt 8, Hours since first attempt: 31.58 hAttempt 8Attempt 9, Hours since first attempt: 51.58 hAttempt 9Attempt 10, Hours since first attempt: 75.58 h75.58 hAttempt10

Converted from the spec's "time since start" column (immediately, 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, 14 hours, 20 hours, 24 hours between attempts). Attempt 2 is 5 seconds in.

Source: Standard Webhooks specification 1.0.0, example retry schedule

See the figures as a table
 Hours since first attempt (h)
Attempt 10
Attempt 20
Attempt 30.08
Attempt 40.58
Attempt 52.58
Attempt 67.58
Attempt 717.58
Attempt 831.58
Attempt 951.58
Attempt 1075.58

CaptiFi Connect counts a delivery done when you answer with any 2xx within 8 seconds, and otherwise tries again after 1, 5, 30, 120, 360 and 720 minutes before giving up on that event. An endpoint that fails 100 times in a row is switched off until you turn it back on. Both mean the same thing for your code: a retry can land after you handled the first attempt but answered a fraction late, so the dedupe in step 6 isn't optional, and a late message.sent must never overwrite a delivered.

Keeping the key a secret

The RFC's closing advice on keys is the part that outlives any particular scheme. "A correct implementation of the above construction, the choice of random (or cryptographically pseudorandom) keys, a secure key exchange mechanism, frequent key refreshments, and good secrecy protection of keys are all essential ingredients for the security of the integrity verification mechanism provided by HMAC." In webhook terms: generate the secret on the provider's side, show it once, store it with your other secrets and keep it out of the repository, and rotate it on a schedule or the moment you think it's been seen.

CaptiFi Connect shows an endpoint's signing secret once, when the endpoint is created, and lets you rotate it later. Test keys fire real, signed webhooks for texts that never leave, so you can build and check the verification against live signatures without spending a credit.

Sources

Read for this post on 17 October 2026.

Written by the CaptiFi Connect team. Published 17 October 2026. More in Behind the scenes

Send your first text this week

Set up your account and contacts now; we check every new account before it can send.