Skip to content
StampyWalletDocs
API and webhooks / Webhooks / Webhooks

Webhooks

Beta

Receive a signed HTTP call on your server, Zapier, Make or n8n on every visit, reward or new card, and verify its HMAC signature.

A webhook is an address on your server that StampyWallet calls on every counter event: a visit, a reward, a new card, a gift card sold. It is what connects StampyWallet to a POS, a CRM, a spreadsheet or a no-code tool, without an official integration.

The other way around? : to have your POS add a stamp on every sale, use POST /v1/passages, or receive sales from your POS directly.

Set up an address

In Settings › Integrations › Webhooks (the owner and managers):

  1. Add a public https:// address, and tick events if you want. No box ticked: all events, including those added later.
  2. Copy the signing secret, which starts with whsec_. It is shown only once; after that the screen only displays its last four characters.
  3. Click "Send a test": a sample passage.cree, marked "test": true, goes out to your address right away, and the screen shows the response received.

The screen shows the last 20 deliveries (status, code, duration, attempt) and a Resend button on failed ones. Ten addresses at most per business; each can be disabled, re-enabled or deleted.

Warning : refused: an address that is not https://, an address that contains credentials (https://me:secret@…), and any private or local address (localhost, 10.x, 192.168.x…), even when resolved through DNS. All ports are accepted. Redirects are never followed: a 3xx response counts as a failure.

What your server receives

A POST request in JSON, with these headers:

Headers
POST /your/address HTTP/1.1
Content-Type: application/json
User-Agent: StampyWallet-Webhooks/1.0
StampyWallet-Evenement: passage.cree
StampyWallet-Id: evt_WdGHduXPRbExh0vh5eYtCjXo
StampyWallet-Signature: t=1792648365,v1=8f3c…(64 hex characters)

And this body. The envelope is the same for every event; only donnees changes (the event list):

passage.cree
{
  "id": "evt_WdGHduXPRbExh0vh5eYtCjXo",
  "type": "passage.cree",
  "cree_le": "2026-10-22T05:52:45.694Z",
  "commerce": { "id": "9f1cbdfc-df71-4fab-a554-3bdbb7c71ebf", "nom": "Le Café des Crochets" },
  "donnees": {
    "client": { "id": "40de4b86-1d7a-4cc6-add3-b7a83fa43c04", "prenom": "Inès",
                "telephone": "+33611223344", "email": "ines@example.com" },
    "passage": {
      "id": "226fdea1-ba59-49fb-93e6-60a14a765134",
      "quantite": 1,
      "montant": null,
      "devise": "EUR",
      "reference": "caisse-1",
      "source": "scan",
      "piste": "normal",
      "tampons": 1,
      "objectif": 2
    }
  }
}
  • id: evt_ followed by 24 characters. The same for every delivery of an event (attempts, resends, and from one address to another): it is your deduplication key.
  • cree_le: when the event happened (not when it was sent), in ISO 8601 UTC.
  • test: present, set to true, only on the delivery from the "Send a test" button.
  • donnees.client: { id, prenom }, plus telephone and email only if the customer gave them; null for a gift card or a package.
  • Amounts (montant, prix) are in cents, as integers, with their devise (ISO 4217).
  • New keys may appear: ignore what you don't know.

Verify the signature

Verify every request before trusting its content: otherwise, anyone could call your address.

The calculation
StampyWallet-Signature: t=<unix timestamp in seconds>,v1=<signature>
signature = hex( HMAC-SHA256( key = secret, message = "<t>.<raw body>" ) )
  • The key is the whole secret, whsec_ prefix included.
  • The message is the timestamp t, a dot, then the body as received, in bytes. Don't re-serialize the JSON before verifying: rewritten JSON no longer has the same bytes.
  • Reject a t more than 5 minutes away from your clock: this prevents replaying an old request.
  • The header may carry several v1= (on the day a secret changes): one match is enough. Compare in constant time.
const crypto = require('node:crypto');

/** corpsBrut: the received body, as a Buffer (or text), BEFORE any JSON.parse. */
function verifierStampyWallet(secret, corpsBrut, entete, tolerance = 300) {
  let t = null;
  const v1 = [];
  for (const morceau of String(entete || '').split(',')) {
    const i = morceau.indexOf('=');
    const cle = morceau.slice(0, i).trim();
    const valeur = morceau.slice(i + 1).trim();
    if (cle === 't' && /^\d+$/.test(valeur)) t = Number(valeur);
    if (cle === 'v1') v1.push(valeur);
  }
  if (t === null || v1.length === 0) return false;
  if (Math.abs(Math.floor(Date.now() / 1000) - t) > tolerance) return false;
  const attendue = crypto.createHmac('sha256', secret)
    .update(`${t}.`).update(corpsBrut).digest('hex');
  return v1.some((s) => s.length === attendue.length
    && crypto.timingSafeEqual(Buffer.from(s), Buffer.from(attendue)));
}

// With Express: keep the raw body on this route.
app.post('/stampywallet', express.raw({ type: 'application/json' }), (req, res) => {
  const ok = verifierStampyWallet(process.env.STAMPYWALLET_WEBHOOK_SECRET,
    req.body, req.get('StampyWallet-Signature'));
  if (!ok) return res.status(400).end();
  const evenement = JSON.parse(req.body.toString('utf8'));
  // deduplicate on evenement.id, then process later
  res.status(200).end();
});

Test vector

To check your code: with these values and an infinite tolerance (Infinity, float("inf"), PHP_INT_MAX), the function must return true. The body is a single line, with no trailing newline, and contains an "é" on purpose: the signature covers the UTF-8 bytes.

Vector
secret : whsec_5WZbPq8rLk2N7xYdTf3Hc9JmVg4RsQe1
t      : 1761134400
corps  : {"id":"evt_4fK9mQ2xT7bL1nR8vZ3cW6yH","type":"passage.cree","cree_le":"2026-10-22T12:00:00.000Z","commerce":{"id":"9b2f6c1e-4d3a-4f7b-8e21-5a6c7d8e9f01","nom":"Le Café du Coin"},"donnees":{"client":{"id":"3c1d2e4f-5a6b-4c7d-8e9f-0a1b2c3d4e5f","prenom":"Camille"},"passage":{"id":"7e8f9a0b-1c2d-4e3f-9a4b-5c6d7e8f9a0b","quantite":1,"montant":null,"devise":"EUR","reference":null,"source":"scan","piste":"normal","tampons":4,"objectif":10}}}
v1     : 4346dff69a3e9e25937b22ec031a8eaf8cfce3baca4dfb932865682b8f891f1d
en-tête: t=1761134400,v1=4346dff69a3e9e25937b22ec031a8eaf8cfce3baca4dfb932865682b8f891f1d

Responding, and retries

  • Respond with a 2xx code in less than 10 seconds. The body of your response is ignored. Respond quickly, and do the long work afterwards.
  • Otherwise, eight attempts in total: the first right away, then after 1 min, 5 min, 30 min, 2 h, 5 h, 8 h and 8 h. The last one goes out about 23 h 30 min after the first; on the eighth failure, the delivery is abandoned.
  • After 50 failures in a row on an address, it is disabled and the business owner receives an email. Re-enable it once your server is fixed: its counter goes back to zero.
  • The test delivery is only tried once and does not count toward failures.
  • Resend sends the same event again (same id, same bytes), with a new signature.
  • At least once, with no guaranteed order: an event may arrive twice, deduplicate on StampyWallet-Id; two close events may arrive out of order, rely on cree_le.
  • Deliveries are kept in the log for 30 days.
Delivery statusMeaning
en_attenteNot tried yet
echecThe last attempt failed; another one is scheduled
livreA 2xx was received
abandonneEight failures, or address disabled, or replaced by a resend

No code : Zapier, Make and n8n receive these webhooks as is: paste the address of their "Webhook" trigger as the address. Step-by-step guides are on Connect your POS.

Updated

Was this page helpful?