Webhooks
Si le das al equipo de alpadevs una URL de notificación (notify_url), te enviamos un
POST por cada uso de un boleto tuyo que se redime en puerta. Con la URL, el admin te
comparte una sola vez tu secreto de firma whsec_… (48 hex): guárdalo en tu gestor
de secretos; con él verificas que cada webhook realmente viene de Gate.
Requisitos de la URL
Sección titulada «Requisitos de la URL»- Debe ser
https://y apuntar a un host público. Rechazamos (y no entregamos a)localhost, IPs privadas o de loopback, rangos link-local, hosts.local/.internaly endpoints de metadata de nube. - Debe responder
2xxen menos de 10 segundos. Cualquier otra respuesta, o un timeout, cuenta como entrega fallida. - Puede llevar query string (por ejemplo, un token propio). Nunca la mostramos en logs.
Evento ticket.redeemed
Sección titulada «Evento ticket.redeemed»POST <tu notify_url> con estos headers:
| Header | Contenido |
|---|---|
Content-Type | application/json |
x-alpadevs-event-id | UUID único del evento. Estable entre reintentos: es tu clave de deduplicación. |
x-alpadevs-timestamp | Unix timestamp en segundos del momento del envío (cambia en cada reintento). |
x-alpadevs-signature | v1=<hex>: HMAC-SHA256 en hex minúsculas (64 chars), ver verificación. |
Body (JSON compacto, sin espacios ni saltos de línea; aquí formateado para leerlo):
{ "eventId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "type": "ticket.redeemed", "createdAt": "2026-08-29T10:00:01.123Z", "payload": { "ticket_id": "33333333-3333-4333-8333-333333333333", "external_ref": "TG-1001", "qr_content": "ABC.DEF", "experience_id": "22222222-2222-4222-8222-222222222222", "redeemed_at": "2026-08-29T10:00:01.123456+00:00", "uses": 1, "max_uses": 1, "holder_ref": "user-42" }}| Campo | Tipo | Descripción |
|---|---|---|
eventId | string (UUID) | Igual que x-alpadevs-event-id. |
type | string | Hoy siempre ticket.redeemed. Tipos nuevos serán aditivos: ignora los que no conozcas. |
createdAt | string (ISO 8601) | Cuándo se generó el evento. |
payload.ticket_id | string (UUID) | ID interno del boleto en Gate (solo informativo). |
payload.external_ref | string | null | Tu ID del boleto. null si lo cargaste sin externalRef. |
payload.qr_content | string | Código escaneado (ya normalizado). |
payload.experience_id | string (UUID) | Experiencia (evento). |
payload.redeemed_at | string (ISO 8601) | Instante de este uso. |
payload.redeemed_by_email | string | null | Correo del operador de Gate que validó. |
payload.uses | integer | Usos consumidos incluyendo este. En boletos de un solo uso, 1. |
payload.max_uses | integer | Cupo del boleto. Si uses == max_uses, el boleto quedó redeemed. |
payload.holder_ref | string | null | Titular actual según tu último import. |
Responde 2xx lo antes posible: valida la firma, deduplica, encola y devuelve
200. No hagas trabajo pesado en línea.
Verificación de la firma
Sección titulada «Verificación de la firma»La firma cubre los bytes exactos del body más el timestamp:
firmado = x-alpadevs-timestamp + "." + rawBody (rawBody = cuerpo crudo, SIN re-serializar)esperada = "v1=" + hex_minúsculas( HMAC_SHA256( whsec, firmado ) )válida = comparación_en_tiempo_constante( esperada, x-alpadevs-signature )Paso a paso:
- Lee el body crudo del request (bytes). No lo parsees antes de firmar: un
JSON.stringifyposterior puede cambiar el orden de llaves o el formato y romper la firma. - Concatena el valor del header
x-alpadevs-timestamp, un punto (.) y el body crudo. - Calcula HMAC-SHA256 con tu secreto
whsec_…(el string completo, prefijo incluido) y codifícalo en hex minúsculas. - Antepón
v1=y compara conx-alpadevs-signatureusando una función de tiempo constante. - Rechaza (4xx) si no coincide, o si
|ahora − timestamp|supera tu tolerancia (recomendada: 5 minutos) para mitigar replays. - Deduplica por
eventId: si ya lo procesaste, responde2xxsin hacer nada.
// Express. Requiere el body CRUDO: no uses express.json() en esta ruta.import { createHmac, timingSafeEqual } from "node:crypto";import express from "express";
const SECRET = process.env.GATE_WEBHOOK_SECRET; // whsec_…const TOLERANCE_S = 5 * 60;
const app = express();
app.post( "/webhooks/gate", express.raw({ type: "application/json" }), (req, res) => { const eventId = req.get("x-alpadevs-event-id"); const timestamp = req.get("x-alpadevs-timestamp"); const signature = req.get("x-alpadevs-signature"); const rawBody = req.body; // Buffer con los bytes exactos
if (!eventId || !timestamp || !signature || !Buffer.isBuffer(rawBody)) { return res.status(400).end(); }
// 1) Tolerancia de tiempo (anti-replay) const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)); if (!Number.isFinite(age) || age > TOLERANCE_S) { return res.status(400).end(); }
// 2) HMAC-SHA256 sobre "<timestamp>.<rawBody>" — el update encadenado // equivale a concatenar sin re-codificar el Buffer const expected = "v1=" + createHmac("sha256", SECRET) .update(`${timestamp}.`) .update(rawBody) .digest("hex");
// 3) Comparación en tiempo constante const a = Buffer.from(expected); const b = Buffer.from(signature); if (a.length !== b.length || !timingSafeEqual(a, b)) { return res.status(401).end(); }
// 4) Dedup por eventId, luego encolar y responder rápido if (alreadyProcessed(eventId)) return res.status(200).end(); const event = JSON.parse(rawBody.toString("utf8")); enqueue(event); markProcessed(eventId); return res.status(200).end(); },);# Flask. request.get_data() devuelve los bytes exactos del body.import hashlibimport hmacimport osimport time
from flask import Flask, abort, request
SECRET = os.environ["GATE_WEBHOOK_SECRET"].encode() # whsec_…TOLERANCE_S = 5 * 60
app = Flask(__name__)
@app.post("/webhooks/gate")def gate_webhook(): event_id = request.headers.get("x-alpadevs-event-id") timestamp = request.headers.get("x-alpadevs-timestamp") signature = request.headers.get("x-alpadevs-signature") raw_body = request.get_data() # bytes, sin parsear
if not (event_id and timestamp and signature): abort(400)
# 1) Tolerancia de tiempo (anti-replay) try: age = abs(int(time.time()) - int(timestamp)) except ValueError: abort(400) if age > TOLERANCE_S: abort(400)
# 2) HMAC-SHA256 sobre "<timestamp>.<rawBody>" signed = timestamp.encode() + b"." + raw_body expected = "v1=" + hmac.new(SECRET, signed, hashlib.sha256).hexdigest()
# 3) Comparación en tiempo constante if not hmac.compare_digest(expected, signature): abort(401)
# 4) Dedup por eventId, luego encolar y responder rápido if already_processed(event_id): return "", 200 event = request.get_json(force=True) enqueue(event) mark_processed(event_id) return "", 200<?php// php://input entrega los bytes exactos del body.$secret = getenv('GATE_WEBHOOK_SECRET'); // whsec_…$toleranceS = 5 * 60;
$eventId = $_SERVER['HTTP_X_ALPADEVS_EVENT_ID'] ?? '';$timestamp = $_SERVER['HTTP_X_ALPADEVS_TIMESTAMP'] ?? '';$signature = $_SERVER['HTTP_X_ALPADEVS_SIGNATURE'] ?? '';$rawBody = file_get_contents('php://input');
if ($eventId === '' || $timestamp === '' || $signature === '' || !ctype_digit($timestamp)) { http_response_code(400); exit;}
// 1) Tolerancia de tiempo (anti-replay)if (abs(time() - (int) $timestamp) > $toleranceS) { http_response_code(400); exit;}
// 2) HMAC-SHA256 sobre "<timestamp>.<rawBody>"$expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
// 3) Comparación en tiempo constanteif (!hash_equals($expected, $signature)) { http_response_code(401); exit;}
// 4) Dedup por eventId, luego encolar y responder rápidoif (alreadyProcessed($eventId)) { http_response_code(200); exit;}$event = json_decode($rawBody, true, 512, JSON_THROW_ON_ERROR);enqueue($event);markProcessed($eventId);http_response_code(200);alreadyProcessed, enqueue y markProcessed son tuyos: una tabla de eventId
procesados (con retención de unos días) y una cola de trabajo bastan.
Reintentos, backoff y dead-letter
Sección titulada «Reintentos, backoff y dead-letter»- La entrega es al menos una vez. Si tu endpoint no responde
2xxen 10 s, reintentamos con backoff exponencial de 2ⁿ minutos (n = intentos fallidos), con tope de 60 minutos entre intentos. - Tras 10 intentos fallidos el evento pasa a dead-letter: deja de reintentarse
solo y queda visible para el equipo de alpadevs, que puede relanzarlo manualmente
(con un nuevo
x-alpadevs-timestampy firma, mismoeventId). - Una URL que no cumpla los requisitos (no HTTPS, host interno) se marca fallida en cada intento hasta agotar los reintentos; corrige la configuración con alpadevs.
- Los eventos se generan en la misma transacción que la redención: si la puerta aceptó el boleto, el evento existe. Lo que puede fallar es la entrega, nunca la generación.
Deduplicación por eventId
Sección titulada «Deduplicación por eventId»Puedes recibir el mismo evento más de una vez (respondiste 2xx pero la
confirmación se perdió, o el admin relanzó un evento). eventId es estable entre
reintentos: guarda los procesados y descarta silenciosamente (con 2xx) los repetidos.
Dos usos distintos de un mismo boleto multi-uso son dos eventos con eventId
distintos.
Reconciliar además del webhook
Sección titulada «Reconciliar además del webhook»El webhook no sustituye al pull de GET /v1/partner/redemptions:
úsalo (por ejemplo, cada hora, y al cierre del evento) para cubrir cualquier evento
perdido.
Eventos en sentido contrario
Sección titulada «Eventos en sentido contrario»No hay un endpoint webhook entrante en Gate. Los eventos de tu lado
(ticket.created, ticket.transferred, ticket.voided) se reflejan llamando a
POST /tickets/bulk y POST /tickets/void
en cuanto ocurren en tu sistema; ambos son idempotentes por externalRef. Para
transferencias ver Transferencias.