API v1
Idempotencia
Tu punto de venta va a reintentar. No es una posibilidad: es una tarde con el WiFi del estadio saturado, un timeout, y una caja que no se puede quedar esperando. La única pregunta es si ese reintento duplica los puntos.
Con nosotros no los duplica, y no tienes que hacer nada especial para conseguirlo más
que mandar el mismo id_externo.
La llave es (integración, id_externo)
Dos cosas la componen, y ninguna es el fan:
| Parte | De dónde sale |
|---|---|
| La integración | De tu llave. No la mandas tú, no la puedes elegir |
id_externo |
Tuyo: el folio del ticket, el id de la venta en tu sistema |
Que la llave no incluya al fan es deliberado. El mismo fan compra tres veces en la misma noche y son tres ventas distintas; lo que las distingue es tu folio, no quién pagó.
Qué pasa cuando reenvías
Mandas la misma venta dos veces:
curl -X POST https://app.gzanalytics.com/api/v1/tx \
-H "Authorization: Bearer $GZ_KEY" \
-H "Content-Type: application/json" \
-d '{"id_externo":"TICKET-9001","fan":"GZM26-2T69D","importe":"1,500.50"}'
La primera vez:
{
"ok": true,
"data": {
"duplicado": false,
"tx": 1,
"puntos_otorgados": 1500,
"saldo": 1543,
"fan": {
"id": "GZM26-2T69D",
"correo": "fan@ejemplo.mx"
}
}
}
La segunda, la tercera y la décima:
{
"ok": true,
"data": {
"duplicado": true,
"tx": 1,
"puntos_otorgados": 0,
"saldo": 1543,
"fan": {
"id": "GZM26-2T69D",
"correo": "fan@ejemplo.mx"
}
}
}
Fíjate en tres cosas: duplicado pasó a true,
puntos_otorgados es 0 —no se acreditó nada de nuevo— y
tx te devuelve el folio de la transacción original, no uno nuevo. El
saldo es el saldo real del fan en ese momento, así que puedes imprimirlo en el
ticket sin ramificar.
Un reintento no es un caso de error en tu código. Si tu integración trata
duplicado: true igual que duplicado: false —enseña el saldo y
sigue— está bien escrita. Solo necesitas distinguirlos si quieres registrar algo en tu
propia bitácora.
Por qué 200 y no 409
Un 409 Conflict es la respuesta clásica y nos parece la equivocada. Obliga a
todos los que integran a escribir el mismo bloque: atrapar un error que no es un error,
mirar el código, decidir que en realidad todo salió bien, y buscar el saldo en otra llamada.
Ese bloque es donde se rompen las integraciones, porque solo se ejecuta el día que hay
timeouts — es decir, el día peor.
Un reintento exitoso es un éxito. La respuesta trae el mismo estado que traería si hubieras llamado una sola vez, y por eso va con 200.
Qué usar como id_externo
| ¿Sirve? | Ejemplo | Por qué |
|---|---|---|
| Sí | TICKET-9001 |
El folio del ticket. Ya existe, ya es único en tu sistema, y sobrevive a que tu proceso se reinicie |
| Sí | caja3-2026-08-21-0417 |
Si no tienes folio, compón uno estable con terminal + fecha + consecutivo |
| No | crypto.randomUUID() |
El error clásico. Un UUID nuevo por intento hace que cada reintento sea, para nosotros, una venta distinta. Es exactamente el bug que la idempotencia existe para evitar |
| No | Date.now() |
Cambia entre intentos, igual que el UUID |
| No | El Fan ID | La segunda compra de la misma persona se perdería como duplicada |
Si generas el identificador, genéralo antes del bucle de reintentos y
guárdalo. Un UUID sirve perfectamente como id_externo siempre que sea el
mismo en el intento 1 y en el intento 4. Lo que no funciona es generarlo dentro del
catch.
// Mal: cada vuelta manda una venta nueva.
while (!enviado) {
await postTx({ id_externo: crypto.randomUUID(), fan, importe });
}
// Bien: el id se fija una vez, con la venta.
const venta = { id_externo: ticket.folio, fan, importe };
while (!enviado) {
await postTx(venta);
}
Lo que la idempotencia no hace
Comparamos la llave, no el cuerpo. De ahí salen tres consecuencias que conviene tener claras antes que después:
- Un importe corregido no se actualiza reenviando. Si mandas
TICKET-9001con $1,500 y luego con $1,800, la segunda llamada contestaduplicado: truey la venta se queda en $1,500. Para nosotros ese folio ya se registró. - Una devolución no es la misma venta. No la mandes con el
id_externodel ticket original: sería un duplicado y no pasaría nada. - No es un candado. Dos llamadas simultáneas con el mismo
id_externono se duplican —la unicidad la garantiza la base de datos, no una ventana de tiempo— pero una de las dos recibiráduplicado: true, y no está definido cuál.
Si necesitas corregir una venta ya registrada, escríbenos. Hoy no hay endpoint para eso y no queremos que lo resuelvas mandando un folio inventado.
Tres cosas que no reinician la protección
| Situación | Efecto sobre la idempotencia |
|---|---|
| Rotas tu llave | Ninguno. La integración es la misma; solo cambió el secreto. Tus
id_externo de ayer siguen reconociéndose |
Otro socio usa TICKET-9001 |
Ninguno. La llave incluye la integración, así que no se pisan. No tienes que negociar prefijos con nadie |
| Reintentas al día siguiente | Ninguno. No hay ventana de deduplicación que expire: la unicidad es un índice en la base, no una caché |
Lo que sí abre un espacio nuevo de folios es que te demos una integración distinta
—otra llave dada de alta por separado, no una rotación de la tuya—. Ahí
TICKET-9001 vuelve a estar libre.
id_externo se recorta a 120 caracteres. Si tus
folios son más largos que eso y solo se diferencian al final, dos ventas distintas van a
colisionar y la segunda se perderá como duplicada. Es raro, pero es silencioso: manda algo
corto y único, no una URL ni un JSON serializado.
Estrategia de reintento
La regla corta: reintenta los 429 y los 5xx, con backoff
exponencial y siempre el mismo id_externo. Los demás 4xx no se
reintentan — el cuerpo está mal y va a fallar igual.
const espera = [1000, 2000, 4000, 8000, 16000];
for (let i = 0; ; i++) {
const r = await fetch("https://app.gzanalytics.com/api/v1/tx", {
method: 'POST',
headers: {
authorization: `Bearer ${process.env.GZ_KEY}`,
'content-type': 'application/json',
},
// El mismo objeto en cada vuelta. Ese es todo el truco.
body: JSON.stringify(venta),
});
// Reintentable: ritmo o falla nuestra. Nunca un 4xx que no sea 429.
const reintentable = r.status === 429 || r.status >= 500;
if (!reintentable || i >= espera.length) return r.json();
await new Promise((ok) => setTimeout(ok, espera[i]));
}
Ramifica sobre r.status o sobre error.codigo, nunca sobre el
texto de mensaje. El catálogo dice cuáles se
reintentan, código por código.
Un timeout no te dice si la venta se registró. Esa es la situación que hace
falta manejar bien, y con idempotencia se maneja sin pensarla: vuelve a mandar. Si la
primera llegó, te contesta duplicado: true con el saldo correcto; si no
llegó, la registra. En los dos casos terminas con una sola venta y el saldo bueno.
Los otros endpoints
ping y fan/resolve no mueven estado de negocio —ni puntos, ni
códigos, ni promociones—, así que reintentarlos es gratis y no llevan
id_externo. La idempotencia importa donde se escribe, y hoy eso es
tx.