GoZap Analytics

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:

ParteDe 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:

200 OK — 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:

200 OK — reintento
{
  "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?EjemploPor 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
Nocrypto.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
NoDate.now() Cambia entre intentos, igual que el UUID
NoEl 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.

El error clásico, y su arreglo
// 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-9001 con $1,500 y luego con $1,800, la segunda llamada contesta duplicado: true y 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_externo del ticket original: sería un duplicado y no pasaría nada.
  • No es un candado. Dos llamadas simultáneas con el mismo id_externo no 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ónEfecto 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.

Reintento con backoff
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.