Webhooks
BetaRecibe una llamada HTTP firmada en tu servidor, Zapier, Make o n8n en cada visita, recompensa o tarjeta nueva, y verifica su firma HMAC.
Un webhook es una dirección de tu servidor a la que StampyWallet llama en cada evento del mostrador: una visita, una recompensa, una tarjeta nueva, una tarjeta regalo vendida. Es lo que conecta StampyWallet con un TPV, un CRM, una hoja de cálculo o una herramienta sin código, sin integración oficial.
¿En el otro sentido? : para que tu TPV añada un sello en cada venta, usa POST /v1/passages, o recibe las ventas de tu TPV directamente.
Configurar una dirección
En Ajustes › Integraciones › Webhooks (el propietario y los encargados):
- Añade una dirección
https://pública y marca, si quieres, los eventos. Ninguna casilla marcada: todos los eventos, incluidos los que se añadan más adelante. - Copia el secreto de firma, que empieza por
whsec_. Solo se muestra una vez; después la pantalla solo enseña los cuatro últimos caracteres. - Haz clic en «Enviar una prueba»: sale enseguida hacia tu dirección un
passage.creede ejemplo, marcado con"test": true, y la pantalla muestra la respuesta obtenida.
La pantalla muestra los 20 últimos envíos (estado, código, duración, intento) y un botón Reenviar en los que han fallado. Diez direcciones como máximo por comercio; cada una se puede desactivar, reactivar o eliminar.
Atención : se rechazan: una dirección que no sea https://, una dirección que contenga credenciales (https://yo:secreto@…) y cualquier dirección privada o local (localhost, 10.x, 192.168.x…), aunque se obtenga por DNS. Se aceptan todos los puertos. Las redirecciones nunca se siguen: una respuesta 3xx cuenta como fallo.
Lo que recibe tu servidor
Una petición POST en JSON, con estas cabeceras:
POST /votre/adresse 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 caracteres hexadecimales)Y este cuerpo. El sobre es el mismo para todos los eventos; solo cambia donnees (la lista de eventos):
{
"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_seguido de 24 caracteres. El mismo en todos los envíos de un evento (intentos, reenvíos, y de una dirección a otra): es tu clave para eliminar duplicados.cree_le: el momento del evento (no el del envío), en ISO 8601 UTC.test: presente, atrue, solo en el envío del botón «Enviar una prueba».donnees.client:{ id, prenom }, mástelephoneyemailsolo si el cliente los ha dado;nullpara una tarjeta regalo o un bono.- Los importes (
montant,prix) van en céntimos, enteros, con sudevise(ISO 4217). - Pueden aparecer claves nuevas: ignora lo que no conozcas.
Verificar la firma
Verifica cada petición antes de fiarte de su contenido: sin eso, cualquiera podría llamar a tu dirección.
StampyWallet-Signature: t=<marca de tiempo unix en segundos>,v1=<firma>
firma = hex( HMAC-SHA256( clave = secreto, mensaje = "<t>.<cuerpo sin procesar>" ) )- La clave es el secreto completo, prefijo
whsec_incluido. - El mensaje es la marca de tiempo
t, un punto y luego el cuerpo tal como se recibe, en bytes. No vuelvas a leer el JSON antes de verificar: un JSON reescrito ya no tiene los mismos bytes. - Rechaza un
ta más de 5 minutos de tu reloj: así se impide reenviar una petición antigua. - La cabecera puede llevar varios
v1=(el día de un cambio de secreto): basta con que coincida uno. Compara en tiempo constante.
const crypto = require('node:crypto');
/** corpsBrut: el cuerpo recibido, en Buffer (o en texto), ANTES de cualquier 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)));
}
// Con Express: conservar el cuerpo sin procesar en esta ruta.
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'));
// eliminar duplicados con evenement.id y procesar más tarde
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: los bytes recibidos (Flask: request.get_data()), antes de cualquier 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)
# Con 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;
}
// el cuerpo sin procesar, antes de json_decode
$corps = file_get_contents('php://input');
$ok = verifier_stampywallet(getenv('STAMPYWALLET_WEBHOOK_SECRET'), $corps,
$_SERVER['HTTP_STAMPYWALLET_SIGNATURE'] ?? '');Vector de prueba
Para comprobar tu código: con estos valores y una tolerancia infinita (Infinity, float("inf"), PHP_INT_MAX), la función debe devolver verdadero. El cuerpo ocupa una sola línea, sin salto de línea final, y contiene una «é» a propósito: la firma se calcula sobre los bytes UTF-8.
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=4346dff69a3e9e25937b22ec031a8eaf8cfce3baca4dfb932865682b8f891f1dResponder, y los reintentos
- Responde con un código 2xx en menos de 10 segundos. El cuerpo de tu respuesta se ignora. Responde rápido y haz el trabajo largo después.
- Si no, ocho intentos en total: el primero enseguida, luego a 1 min, 5 min, 30 min, 2 h, 5 h, 8 h y 8 h. El último sale unas 23 h 30 después del primero; al octavo fallo, el envío se abandona.
- Tras 50 fallos seguidos en una dirección, se desactiva y el propietario del comercio recibe un correo. Reactívala cuando hayas arreglado tu servidor: su contador vuelve a cero.
- El envío de prueba solo se intenta una vez y no cuenta en los fallos.
- Reenviar vuelve a enviar el mismo evento (mismo
id, mismos bytes), con una firma nueva. - Al menos una vez, sin orden garantizado: un evento puede llegar dos veces, elimina duplicados con
StampyWallet-Id; dos eventos cercanos pueden llegar desordenados, guíate porcree_le. - Los envíos se conservan 30 días en el registro.
| Estado de un envío | Significado |
|---|---|
en_attente | Aún no se ha intentado |
echec | El último intento ha fallado; hay otro previsto |
livre | Se ha recibido un 2xx |
abandonne | Ocho fallos, o dirección desactivada, o sustituido por un reenvío |
Sin código : Zapier, Make y n8n reciben estos webhooks tal cual: pega la dirección de su disparador «Webhook» como dirección. Los pasos a seguir están en Conectar tu TPV.
Actualizado el