GoZap Analytics

API v1

Referencia

Cada llamada con su curl copiable, la respuesta tal como sale, y la tabla de campos en las dos direcciones. Todos los ejemplos usan datos ficticios.

El sobre

Escribes un parser, no uno por endpoint. Éxito, siempre la misma forma:

{
  "ok": true,
  "data": {}
}

Error:

{
  "ok": false,
  "error": {
    "codigo": "fan_no_encontrado",
    "mensaje": "…",
    "detalle": "…"
  }
}

Ramifica sobre codigo, nunca sobre mensaje. El texto puede mejorar; el código no cambia. detalle es opcional y es para el humano que está depurando. El catálogo completo.

El campo fan: un campo, cinco formas

No tienes por qué saber qué te dio el escáner. Manda lo que traiga.

FormaEjemploNotas
Fan ID tecleadoGZM26-4K7QMMayúsculas y espacios se normalizan
Código de cupónCERV-4K7QMSi no era Fan ID, se prueba como cupón
Token del QRa3f9c2d1b4e5f6a7…El token crudo del escáner
URL completa del QRhttps://wifi.gzanalytics.com/v/a3f9…Tomamos el último segmento
Correofan@ejemplo.mxEl correo con el que se registró en el portal

Se aceptan también los alias fan_id, qr y codigo.

POST /api/v1/ping En producción

¿mi llave sirve?

La primera llamada de cualquier integración. Sin permiso especial.

Sin permiso especial.

Petición

No lleva cuerpo.

curl -X POST https://app.gzanalytics.com/api/v1/ping \
  -H "Authorization: Bearer $GZ_KEY" \
  -H "Content-Type: application/json"
const r = await fetch("https://app.gzanalytics.com/api/v1/ping", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.GZ_KEY}`,
    'content-type': 'application/json',
  },
});
const { ok, data, error } = await r.json();
// Ramifica sobre el código, nunca sobre el mensaje.
if (!ok) throw new Error(error.codigo);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GZ_KEY"));

var r = await http.PostAsJsonAsync("https://app.gzanalytics.com/api/v1/ping", new { });
var cuerpo = await r.Content.ReadFromJsonAsync<JsonElement>();

// Ramifica sobre el código, nunca sobre el mensaje.
if (!cuerpo.GetProperty("ok").GetBoolean())
    throw new Exception(cuerpo.GetProperty("error").GetProperty("codigo").GetString());

Respuesta

200 OK
{
  "ok": true,
  "data": {
    "integracion": "Punto de venta",
    "programa": "Fan Pass · Estadio Metropolitano",
    "modo": "vivo",
    "permisos": [
      "fan:leer",
      "tx:escribir"
    ],
    "hora": "2026-08-21T05:15:22.282Z"
  }
}
CampoTipoQué es
integracionstringEl nombre con el que se dio de alta tu llave
programastringEl programa de lealtad al que apunta
modostringvivo o prueba, según el prefijo de la llave
permisosstring[]Lo que esta llave puede hacer
horastringISO 8601 en UTC. Útil para comprobar el reloj de tu terminal

GET también funciona, con menos campos.

POST /api/v1/fan/resolve En producción

El momento de la venta

Quién es la persona que trae el QR, y si tiene una promoción vigente. No mueve estado de negocio —ni puntos, ni códigos, ni promociones— así que reintentar es gratis.

Permiso: fan:leer

Petición

CampoTipoOblig.EjemploQué es
fan string Sí GZM26-2T69D Cualquiera de las cinco formas aceptadasalias fan_id · qr · codigo
curl -X POST https://app.gzanalytics.com/api/v1/fan/resolve \
  -H "Authorization: Bearer $GZ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"fan":"GZM26-2T69D"}'
const r = await fetch("https://app.gzanalytics.com/api/v1/fan/resolve", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.GZ_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({"fan":"GZM26-2T69D"}),
});
const { ok, data, error } = await r.json();
// Ramifica sobre el código, nunca sobre el mensaje.
if (!ok) throw new Error(error.codigo);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GZ_KEY"));

var venta = new
{
    fan = "GZM26-2T69D",
};

var r = await http.PostAsJsonAsync("https://app.gzanalytics.com/api/v1/fan/resolve", venta);
var cuerpo = await r.Content.ReadFromJsonAsync<JsonElement>();

// Ramifica sobre el código, nunca sobre el mensaje.
if (!cuerpo.GetProperty("ok").GetBoolean())
    throw new Exception(cuerpo.GetProperty("error").GetProperty("codigo").GetString());

Respuesta

200 OK
{
  "ok": true,
  "data": {
    "fan": {
      "id": "GZM26-2T69D",
      "nombre": "Fan de Ejemplo",
      "correo": "fan@ejemplo.mx",
      "socio_desde": 2026,
      "temporadas": 1,
      "puntos": 1793
    },
    "promo": {
      "nombre": "2x1 en bebida",
      "descripcion": "Muestra el QR en caja",
      "vigencia_fin": "2026-09-30",
      "codigo": "CO2-HEMTC"
    }
  }
}
CampoTipoQué es
fan.idstringEl Fan ID canónico
fan.nombrestringSolo con permiso fan:leer
fan.correostringSolo con permiso fan:leer
fan.socio_desdenumberAño de alta
fan.temporadasnumberTemporadas activas
fan.puntosnumberSaldo actual del ledger
promoobject | nullnull cuando no hay promoción vigente — que es lo normal y no es un error

Es POST y no GET a propósito: el Fan ID no debe quedar en la barra de direcciones, en el historial ni en los logs de acceso de nadie.

POST /api/v1/tx En producción

Venta cerrada

Registra la venta y acredita puntos. Idempotente por id_externo.

Permiso: tx:escribir

Petición

CampoTipoOblig.EjemploQué es
id_externo string Sí TICKET-9001 Tu identificador de la venta. Es la llave de idempotenciaalias id · tx_id
fan string Sí GZM26-2T69D Cualquiera de las cinco formas aceptadasalias fan_id · qr · codigo
importe string | number Sí "1,500.50" O importe_centavos. Ver los formatos aceptados abajo
importe_centavos number No 150050 Alternativa exacta a importealias centavos · amount_cents
terminal string No Caja 3 De qué punto salióalias caja
sitio string No Estadio Metropolitano De qué sedealias sucursal
ts string | number No 2026-08-21T18:30:00Z Si falta, la ponemos nosotros. Sin zona horaria se lee como UTC
moneda string No MXN MXN por omisión
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","terminal":"Caja 3","sitio":"Estadio Metropolitano"}'
const r = await fetch("https://app.gzanalytics.com/api/v1/tx", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.GZ_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({"id_externo":"TICKET-9001","fan":"GZM26-2T69D","importe":"1,500.50","terminal":"Caja 3","sitio":"Estadio Metropolitano"}),
});
const { ok, data, error } = await r.json();
// Ramifica sobre el código, nunca sobre el mensaje.
if (!ok) throw new Error(error.codigo);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GZ_KEY"));

var venta = new
{
    id_externo = "TICKET-9001",
    fan = "GZM26-2T69D",
    importe = "1,500.50",
    terminal = "Caja 3",
    sitio = "Estadio Metropolitano",
};

var r = await http.PostAsJsonAsync("https://app.gzanalytics.com/api/v1/tx", venta);
var cuerpo = await r.Content.ReadFromJsonAsync<JsonElement>();

// Ramifica sobre el código, nunca sobre el mensaje.
if (!cuerpo.GetProperty("ok").GetBoolean())
    throw new Exception(cuerpo.GetProperty("error").GetProperty("codigo").GetString());

Respuesta

200 OK
{
  "ok": true,
  "data": {
    "duplicado": false,
    "tx": 1,
    "puntos_otorgados": 1500,
    "saldo": 1543,
    "fan": {
      "id": "GZM26-2T69D",
      "correo": "fan@ejemplo.mx"
    }
  }
}
CampoTipoQué es
duplicadobooleantrue si ya habíamos visto ese id_externo. Ver idempotencia
txnumberNuestro folio interno de la transacción
puntos_otorgadosnumber0 cuando es duplicado o cuando la llave es de prueba
saldonumberSaldo del fan después de esta venta
fan.idstringEl Fan ID canónico
fan.correostringSolo con permiso fan:leer

Los puntos son floor(pesos × tasa), con la tasa configurable por programa. El saldo se refleja en el pase de Wallet solo, en silencio, sin gastar el cupo de notificaciones.

POST /api/v1/redeem En producción

Cupón quemado

Cierra el embudo en el momento en que la caja quema el cupón. Uno (codigo) o un lote (codigos[], hasta 500) para terminales que estuvieron sin red.

Permiso: redeem:escribir

Petición

CampoTipoOblig.EjemploQué es
codigo string Sí PROM-4K7QM El cupón. O codigos[] para mandar un lote
codigos string[] No ["PROM-4K7QM", "PROM-9X2LT"] Lote de hasta 500. La respuesta trae resumen y resultados
terminal string No Caja 3 De qué punto salióalias caja
ts string | number No 2026-08-21T18:30:00Z Si falta, la ponemos nosotros. Sin zona horaria se lee como UTC
curl -X POST https://app.gzanalytics.com/api/v1/redeem \
  -H "Authorization: Bearer $GZ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"codigo":"PROM-4K7QM","terminal":"Caja 3"}'
const r = await fetch("https://app.gzanalytics.com/api/v1/redeem", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.GZ_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({"codigo":"PROM-4K7QM","terminal":"Caja 3"}),
});
const { ok, data, error } = await r.json();
// Ramifica sobre el código, nunca sobre el mensaje.
if (!ok) throw new Error(error.codigo);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GZ_KEY"));

var venta = new
{
    codigo = "PROM-4K7QM",
    terminal = "Caja 3",
};

var r = await http.PostAsJsonAsync("https://app.gzanalytics.com/api/v1/redeem", venta);
var cuerpo = await r.Content.ReadFromJsonAsync<JsonElement>();

// Ramifica sobre el código, nunca sobre el mensaje.
if (!cuerpo.GetProperty("ok").GetBoolean())
    throw new Exception(cuerpo.GetProperty("error").GetProperty("codigo").GetString());

Respuesta

200 OK
{
  "ok": true,
  "data": {
    "codigo": "PROM-4K7QM",
    "estado": "canjeado",
    "duplicado": false,
    "promo": "2x1 en bebidas",
    "fan": {
      "correo": "fan@ejemplo.mx"
    },
    "puntos_otorgados": 1
  }
}
CampoTipoQué es
codigostringEl código, normalizado a mayúsculas
estadostringSiempre canjeado cuando la respuesta es 200
duplicadobooleantrue si ese cupón ya estaba quemado
promostringNombre de la promoción a la que pertenece
fan.correostringA quién se le había entregado
puntos_otorgadosnumber0 cuando es duplicado o cuando la llave es de prueba

Aquí la idempotencia va por el código, no por id_externo: un cupón se quema una vez y esa es la llave natural. Reenviar el mismo código contesta 200 con duplicado: true.

Cuándo se puede, y cuándo no

Estado del códigoRespuestaQué significa
asignado200 canjeadoSe le entregó a alguien y esa persona lo usó
canjeado200 duplicado: trueReintento. No es error
liberado409 codigo_liberadoSalió de circulación sin usarse
disponible409 codigo_no_asignadoNunca se le entregó a nadie

Los dos 409 no significan “el cupón ya se usó” —eso es 200 con duplicado: true—. Son códigos que nunca debieron poder quemarse, y por eso los rechazamos en vez de aceptarlos: aceptarlos inflaría la tasa de canje y borraría justo la señal de que algo va mal. Son información tuya: no los reintentes, repórtalos.

GET /api/v1/promos En producción

Qué promociones hay

Las promociones del programa, con su inventario. Existe para que no tengas que adivinar ids ni que te los pasemos por correo.

Permiso: codigos:escribir

Petición

No lleva cuerpo.

curl -X GET https://app.gzanalytics.com/api/v1/promos \
  -H "Authorization: Bearer $GZ_KEY" \
  -H "Content-Type: application/json"
const r = await fetch("https://app.gzanalytics.com/api/v1/promos", {
  method: "GET",
  headers: {
    authorization: `Bearer ${process.env.GZ_KEY}`,
    'content-type': 'application/json',
  },
});
const { ok, data, error } = await r.json();
// Ramifica sobre el código, nunca sobre el mensaje.
if (!ok) throw new Error(error.codigo);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GZ_KEY"));

var r = await http.PostAsJsonAsync("https://app.gzanalytics.com/api/v1/promos", new { });
var cuerpo = await r.Content.ReadFromJsonAsync<JsonElement>();

// Ramifica sobre el código, nunca sobre el mensaje.
if (!cuerpo.GetProperty("ok").GetBoolean())
    throw new Exception(cuerpo.GetProperty("error").GetProperty("codigo").GetString());

Respuesta

200 OK
{
  "ok": true,
  "data": {
    "promos": [
      {
        "id": 1,
        "nombre": "2x1 en bebidas",
        "estado": "activa",
        "vigencia_fin": "2026-09-30",
        "admite_codigos": true,
        "inventario": {
          "disponibles": 1180,
          "asignados": 340,
          "canjeados": 90,
          "liberados": 5
        }
      }
    ]
  }
}
CampoTipoQué es
promos[].idnumberEl id que espera codes
promos[].nombrestringTambién sirve como referencia en codes
promos[].estadostringactiva, pausada o terminada
promos[].admite_codigosbooleanfalse si ya terminó
promos[].inventarioobjectDisponibles, asignados, canjeados y liberados

admite_codigos es false en las terminadas: depositar ahí sería tirar códigos a un pozo.

POST /api/v1/codes En producción

Depositar inventario

Empuja tu lote de códigos a la Bóveda. Sustituye la carga por CSV, que sigue existiendo para quien no integre.

Permiso: codigos:escribir

Petición

CampoTipoOblig.EjemploQué es
promo number | string Sí 1 El id, o el nombre exacto (sin importar mayúsculas)alias promo_id
codigos string[] | string Sí ["ABC-A1", "ABC-A2"] Un arreglo, o el texto pegado tal cual. Tope de 5000 por llamadaalias codes
curl -X POST https://app.gzanalytics.com/api/v1/codes \
  -H "Authorization: Bearer $GZ_KEY" \
  -H "Content-Type: application/json" \
  -d '{"promo":1,"codigos":["ABC-A1","ABC-A2","ABC-A3"]}'
const r = await fetch("https://app.gzanalytics.com/api/v1/codes", {
  method: "POST",
  headers: {
    authorization: `Bearer ${process.env.GZ_KEY}`,
    'content-type': 'application/json',
  },
  body: JSON.stringify({"promo":1,"codigos":["ABC-A1","ABC-A2","ABC-A3"]}),
});
const { ok, data, error } = await r.json();
// Ramifica sobre el código, nunca sobre el mensaje.
if (!ok) throw new Error(error.codigo);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GZ_KEY"));

var venta = new
{
    promo = 1,
    codigos = ["ABC-A1","ABC-A2","ABC-A3"],
};

var r = await http.PostAsJsonAsync("https://app.gzanalytics.com/api/v1/codes", venta);
var cuerpo = await r.Content.ReadFromJsonAsync<JsonElement>();

// Ramifica sobre el código, nunca sobre el mensaje.
if (!cuerpo.GetProperty("ok").GetBoolean())
    throw new Exception(cuerpo.GetProperty("error").GetProperty("codigo").GetString());

Respuesta

200 OK
{
  "ok": true,
  "data": {
    "promo": {
      "id": 1,
      "nombre": "2x1 en bebidas",
      "estado": "activa"
    },
    "recibidos": 3,
    "agregados": 3,
    "duplicados": 0,
    "inventario": {
      "disponibles": 1183,
      "asignados": 340,
      "canjeados": 90,
      "liberados": 5
    }
  }
}
CampoTipoQué es
recibidosnumberCuántos códigos únicos leímos de tu lote
agregadosnumberCuántos entraron al inventario
duplicadosnumberCuántos ya estaban. No es error: reenviar es seguro
inventarioobjectCómo quedó la promoción. Contesta "¿mando más?" sin otra llamada

Idempotente sin id_externo: el índice único (promoción, código) hace que reenviar el mismo lote no duplique nada. Esta ruta no crea promociones — las crea el dueño del programa desde su panel, igual que el alta de fans vive solo en el portal.

GET /api/v1/roster En producción

Espejo del padrón

Lo que cambió desde una fecha: altas, cambios y bajas. Para que tengas tu propia base, y para que una terminal sin red pueda seguir atendiendo con una copia local.

Permiso: roster:leer

Petición

CampoTipoOblig.EjemploQué es
desde string | number No 2026-08-21T00:00:00Z El siguiente de la llamada anterior. Sin esto, la foto completa
limite number No 500 Por defecto 500, tope 2000
curl -X GET https://app.gzanalytics.com/api/v1/roster \
  -H "Authorization: Bearer $GZ_KEY" \
  -H "Content-Type: application/json"
const r = await fetch("https://app.gzanalytics.com/api/v1/roster", {
  method: "GET",
  headers: {
    authorization: `Bearer ${process.env.GZ_KEY}`,
    'content-type': 'application/json',
  },
});
const { ok, data, error } = await r.json();
// Ramifica sobre el código, nunca sobre el mensaje.
if (!ok) throw new Error(error.codigo);
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue(
    "Bearer", Environment.GetEnvironmentVariable("GZ_KEY"));

var r = await http.PostAsJsonAsync("https://app.gzanalytics.com/api/v1/roster", new { });
var cuerpo = await r.Content.ReadFromJsonAsync<JsonElement>();

// Ramifica sobre el código, nunca sobre el mensaje.
if (!cuerpo.GetProperty("ok").GetBoolean())
    throw new Exception(cuerpo.GetProperty("error").GetProperty("codigo").GetString());

Respuesta

200 OK
{
  "ok": true,
  "data": {
    "desde": "2026-08-21T00:00:00Z",
    "fans": [
      {
        "id": "GZM26-2T69D",
        "nombre": "Fan de Ejemplo",
        "correo": "fan@ejemplo.mx",
        "socio_desde": 2026,
        "puntos": 1959,
        "estado": "activo",
        "actualizado": "2026-08-22T07:14:02Z"
      },
      {
        "id": "GZM26-T5KTT",
        "nombre": "Otro Fan",
        "correo": "otro@ejemplo.mx",
        "socio_desde": 2026,
        "puntos": 10,
        "estado": "baja",
        "baja": {
          "en": "2026-08-22T07:16:32Z",
          "motivo": "baja"
        },
        "actualizado": "2026-08-22T07:16:32Z"
      }
    ],
    "hay_mas": false,
    "siguiente": "2026-08-22T07:16:32Z"
  }
}
CampoTipoQué es
fans[].estadostringactivo o baja. No deduzcas la baja de una ausencia: un fan que no aparece puede ser una baja o un error de paginación, y desde tu lado son indistinguibles
fans[].bajaobjectCuándo y por qué, solo cuando estado es baja
fans[].actualizadostringCuándo cambió por última vez algo que tú ves
hay_masbooleanQuedan más páginas: vuelve a llamar con siguiente
siguientestringEl cursor para la próxima llamada

Guarda siguiente y mándalo como desde la próxima vez. No uses tu propio reloj: si adelantara un segundo respecto al nuestro, te saltarías registros sin enterarte.

Formatos que aceptamos sin que normalices nada

Tu punto de venta ya tiene bastante que hacer.

CampoAceptamos
importe1500 · "1,500.50" · "1.500,50" · "$ 1,500" · 1500.5
importe_centavos150050 — alias centavos, amount_cents
tsISO · 1787423400 (epoch s) · 1787423400000 (epoch ms) · 2026-08-21 18:30:00
id_externoalias id, tx_id
terminalalias caja · sitio alias sucursal

Cuando el importe trae coma y punto, manda el último: es el decimal en cualquiera de las dos convenciones. Y una fecha sin zona horaria se lee como UTC — si tu terminal trabaja en hora local, manda el offset o vas a acreditar ventas con seis horas de desfase.

Ritmo

Tope de 600 llamadas por minuto. Es una guarda contra bucles descontrolados, no un medidor de consumo. Si lo pasas, recibes 429 demasiadas_llamadas: reintenta con backoff exponencial y siempre el mismo id_externo.

Especificado, todavía no expuesto

Nada. Todo lo que está en esta página responde hoy. La sección se queda porque cuando haya algo diseñado y aún no expuesto, va a aparecer aquí — y saberlo antes de calendarizar es justo para lo que sirve.