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.
| Forma | Ejemplo | Notas |
|---|---|---|
| Fan ID tecleado | GZM26-4K7QM | Mayúsculas y espacios se normalizan |
| Código de cupón | CERV-4K7QM | Si no era Fan ID, se prueba como cupón |
| Token del QR | a3f9c2d1b4e5f6a7… | El token crudo del escáner |
| URL completa del QR | https://wifi.gzanalytics.com/v/a3f9… | Tomamos el último segmento |
| Correo | fan@ejemplo.mx | El 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
{
"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"
}
}
| Campo | Tipo | Qué es |
|---|---|---|
integracion | string | El nombre con el que se dio de alta tu llave |
programa | string | El programa de lealtad al que apunta |
modo | string | vivo o prueba, según el prefijo de la llave |
permisos | string[] | Lo que esta llave puede hacer |
hora | string | ISO 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
| Campo | Tipo | Oblig. | Ejemplo | Qué 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
{
"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"
}
}
}
| Campo | Tipo | Qué es |
|---|---|---|
fan.id | string | El Fan ID canónico |
fan.nombre | string | Solo con permiso fan:leer |
fan.correo | string | Solo con permiso fan:leer |
fan.socio_desde | number | Año de alta |
fan.temporadas | number | Temporadas activas |
fan.puntos | number | Saldo actual del ledger |
promo | object | null | null 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
| Campo | Tipo | Oblig. | Ejemplo | Qué 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
{
"ok": true,
"data": {
"duplicado": false,
"tx": 1,
"puntos_otorgados": 1500,
"saldo": 1543,
"fan": {
"id": "GZM26-2T69D",
"correo": "fan@ejemplo.mx"
}
}
}
| Campo | Tipo | Qué es |
|---|---|---|
duplicado | boolean | true si ya habíamos visto ese id_externo. Ver idempotencia |
tx | number | Nuestro folio interno de la transacción |
puntos_otorgados | number | 0 cuando es duplicado o cuando la llave es de prueba |
saldo | number | Saldo del fan después de esta venta |
fan.id | string | El Fan ID canónico |
fan.correo | string | Solo 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
| Campo | Tipo | Oblig. | Ejemplo | Qué 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
{
"ok": true,
"data": {
"codigo": "PROM-4K7QM",
"estado": "canjeado",
"duplicado": false,
"promo": "2x1 en bebidas",
"fan": {
"correo": "fan@ejemplo.mx"
},
"puntos_otorgados": 1
}
}
| Campo | Tipo | Qué es |
|---|---|---|
codigo | string | El código, normalizado a mayúsculas |
estado | string | Siempre canjeado cuando la respuesta es 200 |
duplicado | boolean | true si ese cupón ya estaba quemado |
promo | string | Nombre de la promoción a la que pertenece |
fan.correo | string | A quién se le había entregado |
puntos_otorgados | number | 0 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ódigo | Respuesta | Qué significa |
|---|---|---|
asignado | 200 canjeado | Se le entregó a alguien y esa persona lo usó |
canjeado | 200 duplicado: true | Reintento. No es error |
liberado | 409 codigo_liberado | Salió de circulación sin usarse |
disponible | 409 codigo_no_asignado | Nunca 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
{
"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
}
}
]
}
}
| Campo | Tipo | Qué es |
|---|---|---|
promos[].id | number | El id que espera codes |
promos[].nombre | string | También sirve como referencia en codes |
promos[].estado | string | activa, pausada o terminada |
promos[].admite_codigos | boolean | false si ya terminó |
promos[].inventario | object | Disponibles, 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
| Campo | Tipo | Oblig. | Ejemplo | Qué 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
{
"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
}
}
}
| Campo | Tipo | Qué es |
|---|---|---|
recibidos | number | Cuántos códigos únicos leímos de tu lote |
agregados | number | Cuántos entraron al inventario |
duplicados | number | Cuántos ya estaban. No es error: reenviar es seguro |
inventario | object | Có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
| Campo | Tipo | Oblig. | Ejemplo | Qué 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
{
"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"
}
}
| Campo | Tipo | Qué es |
|---|---|---|
fans[].estado | string | activo 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[].baja | object | Cuándo y por qué, solo cuando estado es baja |
fans[].actualizado | string | Cuándo cambió por última vez algo que tú ves |
hay_mas | boolean | Quedan más páginas: vuelve a llamar con siguiente |
siguiente | string | El 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.
| Campo | Aceptamos |
|---|---|
importe | 1500 · "1,500.50" · "1.500,50" · "$ 1,500" · 1500.5 |
importe_centavos | 150050 — alias centavos, amount_cents |
ts | ISO · 1787423400 (epoch s) · 1787423400000 (epoch ms) · 2026-08-21 18:30:00 |
id_externo | alias id, tx_id |
terminal | alias 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.