WalleoPay WalleoPay
EN

Checking a payment without getting burned

A screenshot can be faked, an SMS forwarded, a notification replayed. The only proof that commits anyone is the status re-read from the API after the signature checks out.

L'équipe WalleoPay September 19, 2026 5 min read 1 lectures

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:

  1. 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.
  2. The timestamp is part of the signed message. Signing the body alone will never give the right value.
  3. 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 amount against 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 reference field 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

  1. Read the raw body, before any decoding.
  2. Verify the signature with hash_equals or its constant-time equivalent.
  3. Refuse anything more than five minutes off.
  4. Reply 200 immediately.
  5. Re-read the status through GET /payments/{id}.
  6. Check the status, the amount, the currency and your reference.
  7. Deliver once, in a replayable way.
  8. 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.

Someone else might need this
Sujets webhook signature security api

Vous voulez essayer par vous-même ?

Ouvrez un compte WalleoPay : vos clés de test sont disponibles immédiatement, et le code écrit en test est celui qui encaissera en réel.