Your webhook is a public door
| Threat | Without verification | Defense |
|---|---|---|
| Forged callback | Attacker POSTs fake 'delivered' or 'STOP' | HMAC signature check |
| Replayed callback | Old signed callback re-sent, still trusted | Signed timestamp + freshness window |
| Duplicate processing | Same event acted on twice | Idempotent dedupe on event id |
| Timing attack on signature | Byte-by-byte signature guessing | Constant-time comparison |
Signature verification with HMAC
Providers sign each webhook with a shared secret, typically an HMAC of the request body, sent in a header — the same scheme documented by Stripe, Twilio, and the standard webhook-signing conventions the industry has converged on. You recompute the HMAC with your copy of the secret and compare. Match means authentic; mismatch means drop.
import hmac, hashlib
def verify(body: bytes, signature: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), body, hashlib.sha256).hexdigest()
# constant-time compare — never use == on secrets
return hmac.compare_digest(expected, signature)
- Hash the raw body, exactly as received. Re-serializing JSON first changes the bytes and breaks the signature. Capture the raw payload before parsing.
- Use constant-time comparison (
hmac.compare_digest, not==) so an attacker can't time their way to a valid signature byte by byte. - Keep the secret in the environment, never in code. This follows the same discipline as the API key in every language tutorial.
- Reject on any mismatch with a 401, and don't leak why.
Replay protection
A valid signature proves the payload is authentic, but a captured, genuinely-signed callback can be replayed. If an attacker records a real 'delivered' or 'STOP' callback and re-sends it later, the signature still checks out. Timestamps close this.
- Include a timestamp in the signed dataProviders typically sign a timestamp alongside the body. Verify the signature covers both, so the timestamp can't be altered.
- Reject stale callbacksIf the timestamp is older than a small window (say 5 minutes), drop the request. A legitimate callback arrives promptly; an old one is a replay.
- Dedupe on an event idTrack processed webhook/event ids and ignore repeats. This also handles the legitimate case where a provider redelivers. The idempotency your DLR handler needs anyway does double duty here.
- Combine, don't chooseSignature proves authenticity; timestamp plus dedupe proves freshness. You need both. Either alone leaves a gap.
Idempotency is doing two jobs at once: it keeps duplicate legitimate DLRs from double-counting, and it neutralizes replay attacks that resend a captured, validly-signed callback.
The full secure handler
Assembled, a secure webhook does five things in order: capture the raw body, verify the HMAC signature in constant time, check the timestamp is fresh, dedupe on the event id, then (and only then) act on the payload and return 200. Anything that fails a check gets a 401 or 400 and touches no business logic.
SMSRoute is a no-KYC SMS API with crypto billing (BTC, ETH, USDT, XMR, LTC, and SOL), and webhook signing is exactly the kind of thing that's invisible until it isn't. Wire verification in when you build the DLR reconciliation and two-way inbound routing, not after an incident. It's a few lines, it costs nothing at runtime, and it's the difference between a webhook that drives your business logic safely and a public URL anyone can lie to. Treat every unverified callback as hostile until its signature and timestamp say otherwise. SMSRoute's published route pages list delivery from $0.004/message (premium direct-carrier corridors up to $0.035) with sub-100ms median submission and ~98.6% delivered success (smsroute.cc route pages, 2026).
Related reading
FAQ
How do I verify an SMS webhook is really from my provider?
Why do I need to secure my SMS delivery-receipt webhook?
What is webhook replay protection?
Why use constant-time comparison for webhook signatures?
Send your first SMS in 5 minutes
No KYC. Pay with BTC, ETH, USDT, XMR, LTC, and SOL. Live routes to 149 countries.
Get an API key →