"We verify webhooks" is the kind of sentence that sounds like a security property and is actually a range.
I integrated three mobile money providers into one Laravel package, and each of them answered the question "how do I know this callback is really from you" differently. The difference is not small. Three separate models with three separate threat profiles, and the code that receives them cannot pretend otherwise.
Wave: a proper signature
Wave does what you would hope. It sends a Wave-Signature header containing a timestamp and one or more signatures, and each signature is an HMAC-SHA256 over the timestamp concatenated directly to the raw request body.
$expected = hash_hmac('sha256', $timestamp.$request->getContent(), $secret);
foreach ($signatures as $candidate) {
if (hash_equals($expected, $candidate)) {
return true;
}
}
Three details in that snippet earn their place.
Use getContent(), not the parsed array. Signing covers the exact bytes that were sent. Decode the JSON and re-encode it and your keys may come back in a different order, whitespace may differ, and the signature will not match. This is the single most common reason a correct implementation fails.
Use hash_equals, not ===. String comparison in PHP short circuits on the first differing byte, so how long it takes leaks how much of the prefix was right. That is enough to forge a signature a byte at a time given enough attempts. hash_equals compares in constant time.
Expect more than one signature. The plural is for key rotation. During a rotation window the provider signs with both the old and the new secret so nothing breaks while you swap. If you only check the first, you get mysterious failures for exactly as long as the rotation lasts.
And the timestamp is not decoration:
if (abs(time() - $timestamp) > $tolerance) {
return false; // a captured request replayed later is still valid otherwise
}
Without a tolerance window, a signature is valid forever. Anyone who captures one request can replay it tomorrow.
Orange Money: a token, not a signature
Orange takes a different approach. When you create a payment it issues a notif_token, and when it calls you back it sends that same token in the body. Verification means comparing what arrived against what you stored for that order.
$expected = $this->tokenFor($payload['order_id']);
return hash_equals($expected, $presented);
This works, and I want to be careful not to sneer at it, because plenty of production systems run on exactly this. But it is worth naming what it is, because it is not a signature.
A signature proves the body was not altered and that the sender holds the secret, without the secret ever being transmitted. A token echoed back in the body is the secret, in the request, every single time. It is a bearer credential in transit. It is only as safe as the transport carrying it, and unlike a signature, anyone who ever sees one request sees the credential itself.
Practically that means: serve the endpoint over HTTPS and only HTTPS, treat the token as a credential and not an identifier, and think twice about where request bodies get logged. A callback body in an aggregated log that half the company can search is a very different exposure with Orange than it is with Wave.
It also means the token has to be persisted at creation time, which is easy to forget because nothing fails until the first callback arrives. There is no way to recompute it.
MTN: nothing to verify
MTN registers a callback host when you provision your API user, and posts to it. There is no signature and no shared token.
The honest response to that is not to invent verification that does not exist. In my package MTN simply does not implement the interface that says a driver can verify webhooks:
if (! $driver instanceof VerifiesWebhooks) {
Log::warning('callback for a provider that cannot verify webhooks', [...]);
return $this->accepted();
}
The callback is accepted, so the provider stops retrying, and nothing is trusted from it. It is treated as a hint that the transaction is worth asking about, and the answer comes from querying the API with the reference you kept.
Which, it turns out, is the right way to treat all three.
The rule that falls out of this
Once you have three providers with three security models, the design that survives all of them is the same one: a callback is never the thing that moves money.
A verified Wave callback is strong evidence. An Orange callback with a matching token is decent evidence. An MTN callback is a rumour. In all three cases the transition that fulfils an order should come from a status query against the provider, and the callback only decides how soon you make it.
That sounds like extra work and it removes an entire category of bug. You stop caring whether callbacks arrive twice, out of order, or at all.
Two more things worth doing regardless of provider:
Answer with the same terse response whatever happened. My controller returns 202 Accepted for a good callback, a bad signature, an unknown provider and a parse failure alike. An endpoint that says "invalid signature" for one input and "unknown order" for another is answering questions for whoever is probing it. The detail belongs in your logs.
Make failures loud on your side, quiet on theirs. A verification failure that only returns a status code and writes nothing is a silent hole. Log it with the source IP, because a run of them is not a bug, it is somebody trying.
Why this is worth an article
Because "we verify webhooks" is on a lot of integration pages, and it covers everything from constant time HMAC with replay protection down to no verification at all. If you are integrating more than one provider, the differences are load bearing, and the code you write for the strictest one is the code that keeps you safe with the loosest.
This came out of catidegla/laravel-mobile-money, which puts MTN MoMo, Wave and Orange Money behind one Laravel API. The webhook handling is the part I would most like other eyes on, particularly from anyone who has run Orange's token model in production.
Top comments (0)