A webhook endpoint is just a public URL. Anyone who finds it can POST JSON to it, and if your code treats that JSON as a real WhatsApp event, you've got a problem. Maybe someone marks a message as delivered, maybe they trigger a workflow in your CRM. Signed webhooks are how you tell the difference between "WazVox sent this" and "someone typed curl."
This post walks through the checks we'd put in a handler, in the order we'd put them. The code is illustrative. Header names, the signing algorithm and the payload shape live in the WazVox docs, so copy those from there and not from this post.
The idea in one paragraph
When the sender signs a webhook, it computes a signature from the request body using a secret that only the two of you know, and sends that signature along with the request. Your server repeats the computation with its copy of the secret. If your result matches the one in the request, the body wasn't changed and it came from someone who holds the secret. GitHub describes the same pattern for its own webhooks: a secret token creates a hash signature that arrives with each payload (GitHub Docs). Stripe and others follow a similar approach (Stripe docs).
The pattern is simple. The bugs are in the details, so here are the ones that bite.
1. Verify against the raw body
This is the most common mistake. Many frameworks parse JSON before your handler sees it. If you then re-serialize the parsed object and compute the signature on that, whitespace and key order can differ from what was originally signed, and a valid request fails verification.
So grab the raw bytes first. In most frameworks that means a raw-body option or middleware on the webhook route only. Parse the JSON after the signature check passes.
# Illustrative only. Header name and algorithm: see https://wazvox.com/docs
raw_body = request.get_data() # bytes, exactly as received
received = request.headers["<SIGNATURE_HEADER_FROM_DOCS>"]
expected = compute_signature(secret, raw_body) # algorithm from the docs
if not constant_time_equal(expected, received):
return Response(status=401)
event = json.loads(raw_body)
2. Compare in constant time
A plain == on two strings can return as soon as it finds a mismatch. In theory that leaks timing information about how much of a guess was right. Most languages ship a constant-time comparison (hmac.compare_digest in Python, crypto.timingSafeEqual in Node). Use it. It costs nothing, and you'll never have to argue about whether it mattered.
3. Keep the secret out of your code
Put the signing secret in an environment variable or a secrets manager, and never in the repo. Also decide how you'll rotate it. If you ever paste it in a ticket or a chat, treat it as leaked and replace it. Check the docs for how secrets are issued and rotated for your WazVox setup.
4. Reject, don't guess
If the signature header is missing, or the signature doesn't match, return an error status and stop. Don't log the full body at info level, because it can contain phone numbers and message text. Log enough to debug: a timestamp, the route, and the fact that verification failed.
Also resist the temptation to add a "skip verification in dev" flag that quietly ships to production. If you need to test locally, sign your test payloads with a test secret instead. That way the production code path is the one you exercise.
5. Acknowledge fast, work later
Once the signature checks out, do the minimum before responding: store the event somewhere durable (a table or a queue), return a success status, and process it in a background worker. If your handler calls three other services before replying, it'll eventually time out, and the sender may then retry the delivery.
Which leads to the next point.
6. Assume you'll see the same event twice
Webhook senders generally retry when they don't get a timely success response, so a handler that isn't built for duplicates will eventually run the same event twice. Check the docs to see what WazVox says about delivery and retries, and what identifier an event carries.
Whatever identifier the docs give you, the handler logic looks like this:
# Illustrative only
event_id = event["<EVENT_ID_FIELD_FROM_DOCS>"]
if already_processed(event_id):
return Response(status=200) # acknowledge, do nothing
store_event(event_id, raw_body)
enqueue_for_processing(event_id)
return Response(status=200)
Make the "have I seen this?" check atomic. A unique constraint on the event ID in your database is the simplest way. Two requests arriving at the same moment will both pass a read-then-write check, but only one will pass the constraint.
This is the receiving-side twin of using idempotency keys when you send requests: one protects you from sending twice, the other from processing twice.
7. Test the failure paths, not only the happy one
Before you ship, write a few small tests:
- A correctly signed body returns success and gets stored.
- The same body sent twice is stored once.
- A body with one character changed is rejected.
- A request with no signature header is rejected.
- A request with a valid signature but malformed JSON is handled without a crash.
They take minutes to write, and they're the ones that catch a refactor that breaks your raw-body handling.
Tradeoffs worth knowing
Signature checks prove the body came from the secret holder and wasn't altered. They don't, by themselves, stop someone replaying a captured valid request later. If replay matters for your use case, ask what the docs offer (for example a timestamp you can check) and combine it with the duplicate check above. Our docs are the place to confirm what's available, so check there before you rely on it.
Also, verification protects your endpoint. It doesn't replace sensible handling of the data inside, such as treating message text as untrusted input before it reaches a template, a query or a rendered page.
A short checklist
- Read the raw body before any JSON parsing.
- Compute the signature with the algorithm and header from the docs.
- Compare in constant time.
- Reject missing or mismatched signatures.
- Store the event, return success quickly, process later.
- Deduplicate by event ID with a unique constraint.
- Test the rejection paths.
If you're wiring WhatsApp into a CRM, booking tool or support product, the webhook handler is the part you'll live with longest, so it's worth getting right early. The full reference for signed webhooks is in the WazVox docs, and the 30-day trial doesn't require a card (Meta's messaging charges are separate), so you can test your handler against real events.