Ir al contenido

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.

  • 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/.internal y endpoints de metadata de nube.
  • Debe responder 2xx en 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.

POST <tu notify_url> con estos headers:

HeaderContenido
Content-Typeapplication/json
x-alpadevs-event-idUUID único del evento. Estable entre reintentos: es tu clave de deduplicación.
x-alpadevs-timestampUnix timestamp en segundos del momento del envío (cambia en cada reintento).
x-alpadevs-signaturev1=<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",
"redeemed_by_email": "[email protected]",
"uses": 1,
"max_uses": 1,
"holder_ref": "user-42"
}
}
CampoTipoDescripción
eventIdstring (UUID)Igual que x-alpadevs-event-id.
typestringHoy siempre ticket.redeemed. Tipos nuevos serán aditivos: ignora los que no conozcas.
createdAtstring (ISO 8601)Cuándo se generó el evento.
payload.ticket_idstring (UUID)ID interno del boleto en Gate (solo informativo).
payload.external_refstring | nullTu ID del boleto. null si lo cargaste sin externalRef.
payload.qr_contentstringCódigo escaneado (ya normalizado).
payload.experience_idstring (UUID)Experiencia (evento).
payload.redeemed_atstring (ISO 8601)Instante de este uso.
payload.redeemed_by_emailstring | nullCorreo del operador de Gate que validó.
payload.usesintegerUsos consumidos incluyendo este. En boletos de un solo uso, 1.
payload.max_usesintegerCupo del boleto. Si uses == max_uses, el boleto quedó redeemed.
payload.holder_refstring | nullTitular 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.

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:

  1. Lee el body crudo del request (bytes). No lo parsees antes de firmar: un JSON.stringify posterior puede cambiar el orden de llaves o el formato y romper la firma.
  2. Concatena el valor del header x-alpadevs-timestamp, un punto (.) y el body crudo.
  3. Calcula HMAC-SHA256 con tu secreto whsec_… (el string completo, prefijo incluido) y codifícalo en hex minúsculas.
  4. Antepón v1= y compara con x-alpadevs-signature usando una función de tiempo constante.
  5. Rechaza (4xx) si no coincide, o si |ahora − timestamp| supera tu tolerancia (recomendada: 5 minutos) para mitigar replays.
  6. Deduplica por eventId: si ya lo procesaste, responde 2xx sin 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();
},
);

alreadyProcessed, enqueue y markProcessed son tuyos: una tabla de eventId procesados (con retención de unos días) y una cola de trabajo bastan.

  • La entrega es al menos una vez. Si tu endpoint no responde 2xx en 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-timestamp y firma, mismo eventId).
  • 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.

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.

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.

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.