The question comes up in every integration: "my customer says they paid, how do I check?" The short answer: you ask the API, and nothing else. Everything else - screenshot, forwarded SMS, browser redirect, incoming notification - is a hint at best and a trap at worst.
Three proofs that are not proofs
The screenshot. It takes thirty seconds to make with any image editor, and the templates circulate. Amount, name, time: all of it is editable. A screenshot proves nothing.
The operator SMS, forwarded. Same problem, with the added authority of something official-looking. The text of an SMS can be retyped.
The browser coming back. When a customer lands on your confirmation page, it means they clicked, not that they paid. The return address is controlled by whoever visits it: never hang a business decision on it. It exists to display "thanks, we are checking", nothing more.
A notification is a signal, not a proof
You configure an address, we call you when a payment changes state. That is convenient, but an incoming call is still data arriving from outside. Anyone who knows your address can hit it with a well-formed JSON body.
Hence two mandatory moves, in this order: verify the signature, then re-read the status from the API.
Verifying the signature
Every delivery carries the X-WalleoPay-Signature header, shaped like this:
t=1758268800,v1=6b8f1e...
t is the Unix timestamp of the signature, v1 an HMAC SHA-256 computed over the string timestamp.body - the timestamp, a dot, then the raw request body. The key is your notification secret, the one starting with whsec_.
Three details sink half of all first attempts:
- The body must be read exactly as it arrived, byte for byte. If your framework already decoded the JSON and you re-encode it to compute the digest, key order, whitespace or the escaping of accented characters is enough to change the result.
- The timestamp is part of the signed message. Signing the body alone will never give the right value.
- Check the clock skew. A valid signature stays valid forever if you never look at
t. Past five minutes, refuse: that is the tolerance window the platform applies, and it is what stops a captured call from being replayed hours later.
In PHP
function verifySignature(string $header, string $rawBody, string $secret): bool
{
// "t=1758268800,v1=6b8f1e..."
parse_str(str_replace(',', '&', $header), $parts);
$timestamp = (int) ($parts['t'] ?? 0);
$received = (string) ($parts['v1'] ?? '');
if ($timestamp <= 0 || $received === '') {
return false;
}
// Replay: past the tolerance we refuse, even if the digest is right.
if (abs(time() - $timestamp) > 300) {
return false;
}
$expected = hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
// Constant-time comparison: never ===.
return hash_equals($expected, $received);
}
$body = file_get_contents('php://input'); // raw, definitely not json_encode()
$ok = verifySignature($_SERVER['HTTP_X_WALLEOPAY_SIGNATURE'] ?? '', $body, getenv('WALLEOPAY_WEBHOOK_SECRET'));
http_response_code($ok ? 200 : 400);
In JavaScript
const crypto = require('crypto');
function verifySignature(header, rawBody, secret) {
const parts = Object.fromEntries(
String(header || '').split(',').map((p) => p.split('='))
);
const timestamp = Number(parts.t);
const received = parts.v1;
if (!timestamp || !received) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - timestamp) > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(received, 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express: you need the raw body, so express.raw() and not express.json().
app.post('/webhooks/walleopay', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.get('X-WalleoPay-Signature'), req.body.toString('utf8'), process.env.WALLEOPAY_WEBHOOK_SECRET)) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // acknowledge immediately
handleInBackground(event);
});
Then check again with the API
The signature proves the call really came from us. It does not prove the state it describes is still current: notifications can arrive out of order, be replayed after a network incident, or describe a state that has already moved on.
Hence the rule: the notification body tells you which payment to look at, not what to do about it.
$event = json_decode($body, true);
$id = $event['data']['id'] ?? null; // "pay_01j..."
$response = $client->get("https://walleopay.com/api/v1/payments/{$id}", [
'headers' => ['Authorization' => 'Bearer '.getenv('WALLEOPAY_SECRET_KEY')],
]);
$payment = json_decode((string) $response->getBody(), true);
if (($payment['status'] ?? null) === 'succeeded') {
fulfilOrder($payment['reference'], $payment['amount']);
}
Three checks not to skip at that moment:
- The amount. Compare
amountagainst what this order should cost. A successful payment of 500 francs against a 50,000-franc order is still a successful payment. - The currency. Refuse anything that is not the order's currency.
- Your reference. The
referencefield carries the identifier you sent at creation. That is what ties the payment to the right order, not a match on amount and timestamp.
Deliver exactly once
The same event can reach you several times - that is the price of reliable delivery. So make your handler replayable: before delivering, check this order has not already been delivered. A paid_payment_id column in your database, written once, is enough to make the operation safe no matter how many calls come in.
Answer quickly, too. Acknowledge first, process after: a server that takes thirty seconds to reply will be retried, which multiplies the duplicates.
The checklist
- Read the raw body, before any decoding.
- Verify the signature with
hash_equalsor its constant-time equivalent. - Refuse anything more than five minutes off.
- Reply
200immediately. - Re-read the status through
GET /payments/{id}. - Check the status, the amount, the currency and your reference.
- Deliver once, in a replayable way.
- Never deliver on a screenshot, an SMS or a return URL.
These eight points do not take an hour to write. They rule out the most painful category of incident there is: the one you discover while counting the money.