Verifying WhatsApp webhook signatures — HMAC, retries and idempotency
Every inbound WhatsApp reply your customer sends gets pushed to your server as a webhook. That's convenient — no polling, near-instant delivery — but it also means your endpoint is now a public URL that anyone on the internet can POST to. If you don't verify the request actually came from TextMeFlow, an attacker (or just a curious bot) can forge fake "customer replies" straight into your system. Here's how to do it properly, plus the two mistakes that quietly break signature checks in production.
How TextMeFlow signs webhooks
Every delivery includes an X-TextMeFlow-Signature header shaped like sha256=<hex>, which is an HMAC-SHA256 digest of the raw request body, computed with the webhook secret you got when you configured your endpoint in /admin/webhook. The secret is a 64-character string shown once, at creation time — copy it immediately, since it's masked afterwards.
Alongside the signature, two more headers matter:
X-TextMeFlow-Event-Id— a UUID per message. Retries reuse the same id, so it's your idempotency key.X-TextMeFlow-Delivery— a UUID per delivery attempt, useful for grepping logs when you're debugging a specific retry.
Mistake #1: verifying against the parsed body
The single most common bug is computing the HMAC over json_encode($parsedBody) instead of the raw bytes TextMeFlow actually sent. JSON encoders don't guarantee byte-identical output — key ordering, whitespace and float formatting can all differ between what was signed and what you re-serialize. The fix is always the same: read the raw body before any JSON-parsing middleware touches it, and hash that.
In Express this means mounting express.raw({ type: 'application/json' }) on your webhook route specifically — if express.json() already consumed the body upstream, the raw bytes are gone and you'll be verifying against undefined. In Flask, use request.get_data(), not request.json. In PHP, file_get_contents('php://input') gives you the untouched stream.
$raw = file_get_contents('php://input');
$expected = 'sha256=' . hash_hmac('sha256', $raw, $secret);
$received = $_SERVER['HTTP_X_TEXTMEFLOW_SIGNATURE'] ?? '';
if (! hash_equals($expected, $received)) {
http_response_code(401);
exit;
}
$body = json_decode($raw, true);
Mistake #2: comparing signatures with ==
Never compare the computed and received signature with a plain string equality operator. A naive == or .equals() check leaks timing information — how many leading bytes matched before the comparison bailed out — which is (in theory) enough for a patient attacker to reconstruct a valid signature byte by byte. Use a constant-time comparison instead: hash_equals() in PHP, hmac.compare_digest() in Python, crypto.timingSafeEqual() in Node (after checking the lengths match, since timingSafeEqual throws on unequal-length buffers rather than returning false).
import hmac, hashlib, os
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ['TMF_WEBHOOK_SECRET'].encode()
@app.post('/textmeflow-inbound')
def inbound():
raw = request.get_data()
expected = 'sha256=' + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
received = request.headers.get('X-TextMeFlow-Signature', '')
if not hmac.compare_digest(expected, received):
abort(401)
body = request.get_json()
# ...handle body...
return '', 200
Handling retries without double-processing
A delivery counts as successful on any 2xx response returned within 10 seconds. Anything else triggers a retry, up to 5 times with backoff of 1m, 5m, 30m, 2h, 6h — that applies to 408, 429, 5xx responses and network timeouts. After 6 total attempts (the original plus 5 retries) the delivery is marked failed, and you can see the status of every attempt in /admin/webhook. Any other 4xx is treated as a hard failure with no retry — a 401 almost always means your stored secret no longer matches what's configured, so check that first before assuming it's an attack.
Because retries are expected behavior, not an edge case, your endpoint needs to be idempotent: store X-TextMeFlow-Event-Id and skip processing if you've already handled that id. A unique constraint on that column in whatever table logs inbound messages is enough — let the database reject the duplicate insert rather than building custom dedup logic.
One more consequence of the 10-second window: do the actual work (updating a CRM, triggering a booking flow, calling a slow downstream API) after you've responded 200, not before. Acknowledge first, process asynchronously — a slow handler that blocks past 10 seconds looks identical to a timeout and gets needlessly retried.
Migrating from TextMeBot
If you're moving from TextMeBot, the payload shape is identical (type, from, from_name, to, file, message), so no parsing code changes. The one thing to add is the signature check itself — TextMeBot doesn't sign its webhooks, so if your old integration never verified anything, this is the moment to close that gap before going to production. Full field reference and header list are in the webhook docs.
Want to try it live? Configure your endpoint in /admin/webhook after signing up — the free plan includes 50 messages a month, forever, enough to wire up and test a full webhook flow. Get started free.
Zelf WhatsApp-berichten versturen via API?
Gratis voor altijd tot 50 berichten/maand. QR scannen en binnen 5 minuten verstuur je je eerste bericht.
Gratis voor altijd