Webhooks
BetaReceive 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):
- Add a public
https://address, and tick events if you want. No box ticked: all events, including those added later. - Copy the signing secret, which starts with
whsec_. It is shown only once; after that the screen only displays its last four characters. - 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:
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):
{
"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 totrue, only on the delivery from the "Send a test" button.donnees.client:{ id, prenom }, plustelephoneandemailonly if the customer gave them;nullfor a gift card or a package.- Amounts (
montant,prix) are in cents, as integers, with theirdevise(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.
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
tmore 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();
});import hashlib
import hmac
import time
def verifier_stampywallet(secret: str, corps_brut: bytes, entete: str, tolerance: float = 300) -> bool:
"""corps_brut: the received bytes (Flask: request.get_data()), before any json.loads."""
t, v1 = None, []
for morceau in (entete or "").split(","):
cle, _, valeur = morceau.strip().partition("=")
if cle == "t" and valeur.isdigit():
t = int(valeur)
elif cle == "v1":
v1.append(valeur)
if t is None or not v1:
return False
if abs(int(time.time()) - t) > tolerance:
return False
attendue = hmac.new(secret.encode(), f"{t}.".encode() + corps_brut, hashlib.sha256).hexdigest()
return any(hmac.compare_digest(s, attendue) for s in v1)
# With Flask:
# @app.post("/stampywallet")
# def recevoir():
# if not verifier_stampywallet(os.environ["STAMPYWALLET_WEBHOOK_SECRET"],
# request.get_data(),
# request.headers.get("StampyWallet-Signature", "")):
# abort(400)
# evenement = request.get_json()
# return "", 200<?php
function verifier_stampywallet(string $secret, string $corpsBrut, string $entete, int $tolerance = 300): bool
{
$t = null;
$v1 = [];
foreach (explode(',', $entete) as $morceau) {
$paire = explode('=', trim($morceau), 2);
if (count($paire) !== 2) {
continue;
}
if ($paire[0] === 't' && ctype_digit($paire[1])) {
$t = (int) $paire[1];
} elseif ($paire[0] === 'v1') {
$v1[] = $paire[1];
}
}
if ($t === null || count($v1) === 0) {
return false;
}
if (abs(time() - $t) > $tolerance) {
return false;
}
$attendue = hash_hmac('sha256', $t . '.' . $corpsBrut, $secret);
foreach ($v1 as $s) {
if (hash_equals($attendue, $s)) {
return true;
}
}
return false;
}
// the raw body, before json_decode
$corps = file_get_contents('php://input');
$ok = verifier_stampywallet(getenv('STAMPYWALLET_WEBHOOK_SECRET'), $corps,
$_SERVER['HTTP_STAMPYWALLET_SIGNATURE'] ?? '');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.
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=4346dff69a3e9e25937b22ec031a8eaf8cfce3baca4dfb932865682b8f891f1dResponding, 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 oncree_le. - Deliveries are kept in the log for 30 days.
| Delivery status | Meaning |
|---|---|
en_attente | Not tried yet |
echec | The last attempt failed; another one is scheduled |
livre | A 2xx was received |
abandonne | Eight 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