Saltar al contenido
API y webhooks

Integra AVEX en tus flujos

Documentación mínima para integradores. Bearer tokens con alcance por cliente, webhooks firmados HMAC-SHA256 y tiers de rate-limit publicados — los mismos números que corren en producción.

01 — Autenticación

Bearer token por cliente

Cada API key de AVEX está limitada al cliente que la solicita. Envíala en el header Authorization como Bearer token y AVEX restringe automáticamente cada operación a los recursos de ese cliente. El mismo header viaja en todas las rutas v1; el ejemplo lista las instituciones visibles para tu clave — los UUID que después usas en los upserts.

cURLauth-header.sh
curl https://www.avex.com.co/api/v1/institutions \
  -H "Authorization: Bearer avex_k1_<tu_clave>"
GET /api/v1/institutions con el header Authorization. Reemplaza avex_k1_<tu_clave> con la API key real que generas en tu panel; los ejemplos completos por vertical están en la sección de ingesta.

Suscripción del cliente y enforcement por transporte

Cuando la suscripción del cliente está bloqueada (trial vencido, suspendida o cancelada), cada transporte se comporta distinto — la regla es la operación hacia el titular sigue; la gestión se bloquea:

Enforcement de suscripción por transporte — comportamiento con suscripción bloqueada
TransporteAutenticaciónCon suscripción bloqueada
Webhooks de pago entrantes/api/payments/* · /api/webhooks/*HMAC por clienteSiguen procesando — reflejan pagos de titulares existentes en pases ya emitidos. Solo un cliente con estado suspended a nivel de cuenta se rechaza.
API v1 de gestión e ingesta/api/v1/*API keyResponde 402 SUBSCRIPTION_INACTIVE (el estado concreto viaja en error.details.subscriptionStatus) — los upserts pueden emitir pases NUEVOS, distribución que una suscripción vencida no obtiene.
Verificación de puerta/api/v1/verify/** · /api/v1/entertainment/validateAPI keyExenta — un lector de puerta no se detiene a mitad de evento por un trial vencido.
02 — Endpoints de ingesta

La ingesta v1 en los 5 verticales

AVEX es un canal, no un CRM: tu sistema empuja los datos ya computados y AVEX los refleja en el pase de Apple y Google Wallet. Todas las rutas v1 comparten la misma autenticación — Bearer API key con alcance por cliente — y cada una exige el scope de su vertical. Estos son los ejemplos por vertical; el inventario completo de rutas vive en el mapa de endpoints de entrada. Reemplaza avex_k1_<tu_clave> por la API key real que generas en tu panel.

Referencia completa del API

Explora el contrato OpenAPI interactivo — esquemas de request/response, códigos de error y los headers de cada ruta pública.

Abrir referencia OpenAPI

Educación — carnets de estudiante

scope: passes:updatecareers:write

Refleja el pase del estudiante desde tu SIS. El orden es carreras → pases: resuelve los careerId con el descubrimiento (GET /api/v1/institutions/{institutionId}/careers) o crea el programa que falte con el POST de carreras (scope careers:write; renombrar/retirar sigue siendo del panel), y emite usando el code. El upsert crea en el primer push y refleja los campos mutables en los siguientes; photoUrl debe ser una URL directa (sin redirects — el fetch server-side corta en 301/302) que sirva JPG/PNG/WEBP; event empuja el cambio de estado; revoke retira el pase. Si la institución tiene captura diferida de foto (photoCaptureInInstallEmail), un create sin foto de un passType que la exige responde además photoCaptureUrl (+ photoCaptureUrlExpiresAt, 30 días): el enlace tokenizado de auto-captura que distribuyes al estudiante. Mientras la foto no llegue, el carnet instalado muestra una silueta neutra — en Apple en el espacio de la foto, en Google como banner superior de la tarjeta; al empujar la photoUrl con el mismo upsert, la foto real la reemplaza y el pase instalado se actualiza solo. studentStatus acepta exactamente Active · Inactive · Graduated · Withdrawn · Expelled · Suspended — con mayúscula inicial; un valor fuera del set responde 400. En el upsert es opcional: si viene se refleja tal cual, si se omite se conserva (un create nace Active); el upsert no emite el webhook pass.updated — para notificar a tus suscriptores el cambio de estado usa event.

cURLcareers-create.sh
curl -X POST https://www.avex.com.co/api/v1/institutions/11111111-1111-4111-8111-111111111111/careers \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Content-Type: application/json" \
  -d '{
  "code": "ING-IA",
  "name": "Ingeniería en Inteligencia Artificial",
  "modality": "Virtual",
  "totalPeriods": 10
}'
POST /api/v1/institutions/{institutionId}/careers — tu SIS crea el programa al abrirlo la universidad (scope careers:write; 409 = ya existía, sigue de largo).
cURLpass-upsert.sh
curl -X POST https://www.avex.com.co/api/v1/pass/upsert \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000001" \
  -H "Content-Type: application/json" \
  -d '{
  "institutionId": "11111111-1111-4111-8111-111111111111",
  "externalId": "EST-12345",
  "data": {
    "uniqueIdentifier": "1098765432",
    "careerId": "ING-SIS",
    "name": "Ana María Gómez",
    "email": "ana.gomez@example.edu.co",
    "semester": 6,
    "enrollmentYear": 2023,
    "studentStatus": "Active",
    "academicCalendarLink": null,
    "photoUrl": "https://cdn.example.edu.co/fotos/est-12345.jpg"
  }
}'
POST /api/v1/pass/upsert — crea o refleja un pase. Idempotency-Key opcional para reintentos seguros.
TypeScriptpass-upsert.ts
// Node 22+ / TypeScript — fetch nativo, sin dependencias externas.
const res = await fetch("https://www.avex.com.co/api/v1/pass/upsert", {
  method: "POST",
  headers: {
    Authorization: "Bearer avex_k1_<tu_clave>",
    "Idempotency-Key": crypto.randomUUID(),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
  "institutionId": "11111111-1111-4111-8111-111111111111",
  "externalId": "EST-12345",
  "data": {
    "uniqueIdentifier": "1098765432",
    "careerId": "ING-SIS",
    "name": "Ana María Gómez",
    "email": "ana.gomez@example.edu.co",
    "semester": 6,
    "enrollmentYear": 2023,
    "studentStatus": "Active",
    "academicCalendarLink": null,
    "photoUrl": "https://cdn.example.edu.co/fotos/est-12345.jpg"
  }
}),
});

// 201 en la primera creación, 200 cuando reflejas un pase ya existente.
if (!res.ok) {
  throw new Error(`AVEX respondió ${res.status}`);
}
Mismo upsert desde Node 22 con fetch nativo. crypto.randomUUID() genera la Idempotency-Key.
cURLpass-event.sh
curl -X POST https://www.avex.com.co/api/v1/pass/EST-12345/event \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Content-Type: application/json" \
  -d '{
  "institutionId": "11111111-1111-4111-8111-111111111111",
  "type": "status.changed",
  "data": {
    "studentStatus": "Graduated"
  }
}'
POST /api/v1/pass/{externalId}/event — empuja el cambio de estado (tu SIS es la autoridad).
cURLpass-revoke.sh
curl -X POST https://www.avex.com.co/api/v1/pass/EST-12345/revoke \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Content-Type: application/json" \
  -d '{
  "institutionId": "11111111-1111-4111-8111-111111111111",
  "reason": "Retiro voluntario del programa"
}'
POST /api/v1/pass/{externalId}/revoke — retira el pase con una razón auditable.
cURLpass-period-snapshot.sh
curl -X POST https://www.avex.com.co/api/v1/pass/EST-12345/period/snapshot \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000005" \
  -H "Content-Type: application/json" \
  -d '{
  "institutionId": "11111111-1111-4111-8111-111111111111",
  "periodName": "2026-2",
  "startDate": "2026-07-01",
  "dueDate": "2026-08-15",
  "totalToPay": 2500000,
  "amountPaid": 0,
  "paymentReference": "REF-2026-2-000123",
  "onlinePaymentUrl": "https://pagos.example.com/REF-2026-2-000123"
}'
POST /api/v1/pass/{externalId}/period/snapshot — publica el período de pago que el carnet refleja (201 con periodId). Usa SIEMPRE Idempotency-Key: sin ella cada retry crea un período nuevo.

Lealtad — miembros y saldos

scope: loyalty:writeloyalty:read

Refleja al miembro y su saldo desde tu POS/CRM. Primero resuelve el programId con el descubrimiento (GET /api/v1/loyalty/programs, scope loyalty:read — paginado con page/limit, responde { data, total, page, limit }); luego el upsert crea o refleja al miembro por externalId y el snapshot empuja el balance ya computado más el nivel resuelto (por su nombre, tierName, o su UUID, tierId) — AVEX nunca calcula puntos. La misma familia trae POST .../member/{externalId}/revoke (retira el pase), GET .../member/{externalId}/install-link (el enlace tokenizado para tu canal) y, si tu POS prefiere push firmado en vez de API key, el webhook entrante /api/payments/loyalty-snapshot/{clientSlug} direccionado por accountNumber.

cURLloyalty-upsert.sh
curl -X POST https://www.avex.com.co/api/v1/loyalty/member/upsert \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000002" \
  -H "Content-Type: application/json" \
  -d '{
  "programId": "22222222-2222-4222-8222-222222222222",
  "externalId": "SOCIO-789",
  "data": {
    "name": "Carlos Restrepo",
    "email": "carlos.restrepo@example.com",
    "phone": "+573001234567",
    "accountNumber": "ACC-0099"
  }
}'
POST /api/v1/loyalty/member/upsert — crea o refleja un miembro por su externalId.
cURLloyalty-snapshot.sh
curl -X POST https://www.avex.com.co/api/v1/loyalty/member/SOCIO-789/snapshot \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Content-Type: application/json" \
  -d '{
  "balance": 4500,
  "tierId": null
}'
POST /api/v1/loyalty/member/{externalId}/snapshot — empuja el balance entero ya calculado. El nivel va por tierId (UUID) o por tierName (el nombre del nivel en tu programa, uno u otro); tierId null limpia el nivel.

Inmobiliario — cuotas

scope: installments:write

El orden es upsert → enlace de instalación → snapshot o webhook → verificación. El upsert crea la cuota por tu externalId (y la refleja en pushes siguientes: solo los datos del titular; los financieros son del snapshot) y devuelve el installUrl que tú distribuyes; el snapshot empuja el acumulado pagado, el saldo restante y el estado desde tu sistema de cobranza — AVEX los refleja verbatim en el pase — o, si prefieres push firmado, el webhook entrante /api/webhooks/installment-payment/{clientSlug}. La misma familia trae POST .../{externalId}/revoke (409 si la cuota ya tiene pagos), GET .../{externalId}/install-link y GET /api/v1/verify/real-estate/{installmentId} para consultar el estado sin datos personales.

cURLinstallment-upsert.sh
curl -X POST https://www.avex.com.co/api/v1/installment/upsert \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000006" \
  -H "Content-Type: application/json" \
  -d '{
  "externalId": "CUOTA-456",
  "institutionId": "77777777-7777-4777-8777-777777777777",
  "projectName": "Torres del Parque",
  "unitIdentifier": "Torre 2 · Apto 804",
  "totalInstallments": 24,
  "currentInstallment": 3,
  "installmentAmount": 4200000,
  "dueDate": "2026-09-15",
  "holderName": "Andrés Felipe Cárdenas",
  "holderEmail": "andres.cardenas@example.com",
  "holderDocument": "1020304050"
}'
POST /api/v1/installment/upsert — crea o refleja la cuota por externalId; dueDate acepta YYYY-MM-DD aquí (en el snapshot exige ISO-8601 con offset).
cURLinstallment-snapshot.sh
curl -X POST https://www.avex.com.co/api/v1/installment/CUOTA-456/snapshot \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Content-Type: application/json" \
  -d '{
  "accumulatedPaid": 1500000,
  "remainingBalance": 8500000,
  "status": "Active"
}'
POST /api/v1/installment/{externalId}/snapshot — status ∈ Active | Defaulted | Completed; dueDate/currentInstallment/installmentAmount opcionales mueven la posición del ciclo.

Servicios públicos — facturas de servicios y conjuntos residenciales

scope: utilities:write

Refleja la factura del período desde tu sistema de facturación. Con accountReference, AVEX mantiene UN pase persistente por cuenta que se actualiza cada período sin emitir un pase nuevo; el snapshot empuja el estado de pago que tu biller ya computó (AVEX nunca calcula vencimientos). Perfil conjunto residencial (PA-018): usa serviceType 'administration' y la nomenclatura del apto como accountReference ('Torre 3 · Apto 501') — la unidad se renderiza en la tarjeta del residente y la cuota mensual actualiza su pase existente.

cURLutility-upsert.sh
curl -X POST https://www.avex.com.co/api/v1/utility/bill/upsert \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000003" \
  -H "Content-Type: application/json" \
  -d '{
  "institutionId": "33333333-3333-4333-8333-333333333333",
  "accountHolderName": "Luisa Fernanda Ruiz",
  "accountHolderEmail": "luisa.ruiz@example.com",
  "accountNumber": "1029384821",
  "serviceType": "electricity",
  "billingPeriod": "2026-07",
  "amountDue": 186500,
  "dueDate": "2026-08-15",
  "paymentReference": "FAC-2026-07-004821",
  "accountReference": "CONTRATO-004821"
}'
POST /api/v1/utility/bill/upsert — crea o refleja la factura por paymentReference. accountNumber (número completo, lo que ve el titular) o accountNumberMasked (****1234, legacy): al menos uno. accountReference (opcional) activa el pase persistente por cuenta.
cURLutility-snapshot.sh
curl -X POST https://www.avex.com.co/api/v1/utility/bill/FAC-2026-07-004821/snapshot \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Content-Type: application/json" \
  -d '{
  "paymentStatus": "paid",
  "paidAt": "2026-07-01T14:30:00-05:00"
}'
POST /api/v1/utility/bill/{paymentReference}/snapshot — paymentStatus ∈ pending | paid | overdue | cancelled; overdue solo si tu sistema lo empuja.
cURLutility-install-link.sh
curl https://www.avex.com.co/api/v1/utility/bill/FAC-2026-07-004821/install-link \
  -H "Authorization: Bearer avex_k1_<tu_clave>"
GET /api/v1/utility/bill/{paymentReference}/install-link — el enlace tokenizado para imprimir el QR en el recibo físico.
cURLutility-revoke.sh
curl -X POST https://www.avex.com.co/api/v1/utility/bill/FAC-2026-07-004821/revoke \
  -H "Authorization: Bearer avex_k1_<tu_clave>"
POST /api/v1/utility/bill/{paymentReference}/revoke — sin body: retira la factura y mata el pase en el wallet; una factura ya revocada o inexistente responde 404.

Entretenimiento — entradas de evento

scope: events:readevents:writetickets:credentialtickets:validate

Primero resuelve los identificadores con el descubrimiento (scope events:read): GET /api/v1/events (paginado con page/limit) te da el eventId y GET /api/v1/events/{id}/showtimes las funciones con su showtimeId — ambos UUID los exige el upsert. Luego emite entradas por externalId dentro de esa función, entrega la credencial de instalación con install-link (scope tickets:credential) y valida en puerta con el payload real del wallet. La validación es de un solo uso por entrada (already_used idempotente) y cross-tenant responde 404 sin oráculo. El evento de la función debe estar Publicado — el upsert responde 400 si todavía está en Borrador, Completado o Cancelado.

cURLentertainment-upsert.sh
curl -X POST https://www.avex.com.co/api/v1/entertainment/ticket/upsert \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000004" \
  -H "Content-Type: application/json" \
  -d '{
  "externalId": "TICKET-2026-00891",
  "eventId": "44444444-4444-4444-8444-444444444444",
  "showtimeId": "55555555-5555-4555-8555-555555555555",
  "holderName": "Mariana Duque",
  "holderEmail": "mariana.duque@example.com",
  "seat": "14",
  "row": "C",
  "section": "Platea",
  "tierId": "66666666-6666-4666-8666-666666666666"
}'
POST /api/v1/entertainment/ticket/upsert — crea la entrada por externalId; seat/row/section opcionales. tierId (opcional, A38) asigna la tarifa AL ACUÑAR — un HIT existente lo ignora.
cURLentertainment-install-link.sh
curl "https://www.avex.com.co/api/v1/entertainment/ticket/TICKET-2026-00891/install-link?expiryDays=30" \
  -H "Authorization: Bearer avex_k1_<tu_clave>"
GET /api/v1/entertainment/ticket/{externalId}/install-link — requiere tickets:credential; devuelve el enlace tokenizado (expiryDays 1..365, default 30) para tu checkout o QR.
cURLentertainment-validate.sh
curl -X POST https://www.avex.com.co/api/v1/entertainment/validate \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Content-Type: application/json" \
  -d '{
  "qrPayload": "b3f1c2d4e5f60718293a4b5c6d7e8f90|482913",
  "validatedBy": "puerta-norte-1"
}'
POST /api/v1/entertainment/validate — qrPayload es el valor crudo del QR (token, o token|totp con barcode rotatorio).

Sandbox de validación (dry-run)

Antes de empujar datos reales, valida tu integración con el header X-AVEX-Dry-Run: true. Corre la autenticación y la validación de schema, ownership y existencia, resuelve el recurso igual que el push real (una referencia ausente o ajena responde el mismo 404) y, cuando la fila pasaría, devuelve un 200 con la previsualización del efecto sin crear ni modificar datos — ningún pase llega a los titulares. La autenticación cuenta igual que en el push real: consume tu límite de solicitudes y actualiza el último uso de tu API key. Un 4xx bajo dry-run es el mismo 4xx que recibirías en el push real. Dos cosas solo se evalúan al escribir: el aforo por función (entertainment) y la descarga de la foto (education) — un dry-run en verde no las garantiza.

cURLdry-run.sh
# Sandbox de validación: agrega X-AVEX-Dry-Run: true a cualquier
# endpoint de ingesta (upserts, snapshots, eventos, revocaciones y void).
# AVEX corre auth + validación + el efecto teórico y responde 200 con la
# previsualización — sin crear pases, sin push, sin email. Quita el
# header para ejecutar de verdad. La validación de puerta NO es ingesta:
# validar quema la entrada; su consulta sin quemar es /v1/verify/*.
curl -X POST https://www.avex.com.co/api/v1/installment/CUOTA-456/snapshot \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "X-AVEX-Dry-Run: true" \
  -H "Content-Type: application/json" \
  -d '{"accumulatedPaid":1500000,"remainingBalance":8500000,"status":"Active"}'
El mismo body que el endpoint real; solo cambia el header X-AVEX-Dry-Run.
03 — Webhooks

Firmados con HMAC-SHA256

AVEX firma cada webhook con HMAC-SHA256 sobre el body crudo. El header x-avex-signature lleva una o más firmas sha256=<hex> separadas por coma — durante la ventana de rotación de secret (24h tras rotar) van dos, la nueva primero. Acepta la entrega si cualquiera coincide, verificando cada firma con timingSafeEqual antes de leer el payload — nunca confíes en el header sin validar. Después comprueba que deliveredAt (firmado, nuevo en cada intento) esté a ±5 minutos de tu reloj y deduplica por x-avex-delivery-id.

cURLdebug-signature.sh
# Ejemplo de llamada que AVEX envía a tu webhook.
# La firma HMAC-SHA256 viaja en x-avex-signature: una o más firmas
# sha256=<hex> separadas por coma — durante la ventana de rotación de
# secret (24h tras rotar) van DOS, la del secret nuevo primero —,
# calculadas sobre el body EXACTO que recibes. Headers adicionales:
#   x-avex-event        → tipo de evento (ej. pass.updated)
#   x-avex-delivery-id  → id único de esta entrega (para dedupe)
curl -X POST https://tu-servidor.com/webhooks/avex \
  -H "Content-Type: application/json" \
  -H "User-Agent: AVEX-Webhook/1.0" \
  -H "x-avex-event: pass.updated" \
  -H "x-avex-delivery-id: 3f6c1e2a-..." \
  -H "x-avex-signature: sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$AVEX_WEBHOOK_SECRET" -r | cut -d' ' -f1)" \
  -d "$BODY"
Reproducción local de la firma para depurar — usa exactamente la misma fórmula que el backend.
TypeScriptverify-webhook.ts
import crypto from "node:crypto";

/**
 * Verifica la firma HMAC-SHA256 que AVEX envía en x-avex-signature.
 * El header lleva UNA o MÁS firmas "sha256=<hex>" separadas por coma:
 * durante la ventana de rotación de secret (24h tras rotar) van dos,
 * la del secret nuevo primero. Acepta la entrega si CUALQUIERA de las
 * firmas coincide con tu secret. Cada firma se valida por separado —
 * prefijo, formato y crypto.timingSafeEqual POR FIRMA contra timing
 * attacks. Si el prefijo cambia, AVEX cambió de algoritmo y tu
 * verificador debe rechazar en vez de pasar en silencio.
 */
export function verifyAvexSignature(rawBody: string, headerValue: string | undefined, secret: string): boolean {
  if (!headerValue) return false;

  const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  const expectedBuf = Buffer.from(expected, "hex");

  return headerValue.split(",").some((signature) => {
    const value = signature.trim();
    if (!value.startsWith("sha256=")) return false;

    const hex = value.slice("sha256=".length);
    if (!/^[0-9a-fA-F]{64}$/.test(hex)) return false;

    const receivedBuf = Buffer.from(hex, "hex");
    if (expectedBuf.length !== receivedBuf.length) return false;
    return crypto.timingSafeEqual(expectedBuf, receivedBuf);
  });
}

/** Tolerancia de reloj para deliveredAt: ±5 minutos. */
export const AVEX_MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;

/**
 * Frescura: deliveredAt viaja DENTRO del cuerpo firmado y AVEX lo regenera en
 * cada intento (reintentos y reenvíos incluidos). Llámala DESPUÉS de verificar
 * la firma. Rechaza si deliveredAt falta, no es una fecha válida o está a más de
 * ±5 min de tu reloj: un cuerpo re-enviado tal cual por un tercero queda viejo,
 * y un rechazo tuyo (no-2xx) hace que AVEX reintente con un deliveredAt nuevo.
 */
export function isFreshAvexDelivery(rawBody: string, now: Date = new Date()): boolean {
  let deliveredAt: unknown;
  try {
    deliveredAt = JSON.parse(rawBody)?.deliveredAt;
  } catch {
    return false;
  }
  if (typeof deliveredAt !== "string") return false;
  const sentAt = Date.parse(deliveredAt);
  if (Number.isNaN(sentAt)) return false;
  return Math.abs(now.getTime() - sentAt) <= AVEX_MAX_CLOCK_SKEW_MS;
}

// Uso en tu handler HTTP (lee los headers en minúsculas), en este orden:
//   if (!verifyAvexSignature(rawBody, req.headers["x-avex-signature"], secret)) return 401;
//   if (!isFreshAvexDelivery(rawBody)) return 400; // AVEX reintenta con deliveredAt nuevo
//   const deliveryId = req.headers["x-avex-delivery-id"]; // = deliveryId del cuerpo
//   if (await yaProcesada(deliveryId)) return 200; // duplicado: acusa sin re-aplicar
//   // procesa el evento y guarda deliveryId (≥30 días: un reenvío desde la DLQ conserva el id)
Implementación de referencia: firma multi-firma (split por coma + some, mismo algoritmo que corre en AVEX), frescura de deliveredAt con ±5 minutos y deduplicación por deliveryId.
04 — Eventos

Catálogo de eventos salientes

Los 7 eventos que AVEX puede entregar a tus endpoints suscritos, con la semántica exacta de cada uno: cuándo dispara, cuándo NO, y el payload real que vas a recibir. Suscribes cada webhook a un subconjunto de eventos desde el panel; cada entrega llega firmada como describe la sección Webhooks firmados.

Catálogo de eventos webhook salientes — cuándo dispara y cuándo no cada evento, con payload de ejemplo
EventoVerticales que lo emitenCuándo disparaCuándo NO dispara
pass.createdEducaciónCada vez que un bloque de creación queda confirmado en base de datos. Un lote se procesa en bloques de hasta 100 pases, así que un lote grande produce VARIAS notificaciones: una por bloque confirmado, cada una con el count y el passes[] de ESE bloque (identificador, carrera y tipo de cada pase), más defaultPassType (el tipo de respaldo del lote). No llega una notificación por pase, ni una sola por lote: si necesitas el total del lote, agrega por institutionId de tu lado. Los bloques se procesan en paralelo, así que las notificaciones de un mismo lote pueden llegar desordenadas. Las creaciones individuales (upsert de la API v1 que crea, alta individual del panel) emiten una notificación con count: 1 y la misma forma; el create de la API v1 incluye además externalId por pase (la clave natural del integrador — ignora claves que no conozcas). La carga masiva NUNCA emite una notificación por fila, traiga o no la columna externalId: sus creados se agrupan en los mismos bloques.Bloques que no persistieron ningún pase (fallo de inserción o límite de plan alcanzado), y la creación que corre sin contexto de tenant (procesos programáticos internos).
Ejemplo de payload
{
  "institutionId": "6b2f0c1d-…",
  "defaultPassType": "student",
  "count": 2,
  "passes": [
    {
      "uniqueIdentifier": "1002003001",
      "careerId": "9e4a7d21-…",
      "passType": "student",
      "externalId": "SIS-2026-0042"
    },
    {
      "uniqueIdentifier": "1002003002",
      "careerId": "9e4a7d21-…",
      "passType": "alumni"
    }
  ]
}
pass.updatedEducaciónUna vez por solicitud de cambio de estado de estudiante, con changeType: "student_status" y los pases agrupados por estado destino en groups[]. El emisor de la API v1 (POST /v1/pass/{externalId}/event) incluye además passType y externalId en cada pase (cubre tipos no-student; ignora claves que no conozcas). Bifurca sobre changeType: emisores futuros añadirán nuevos discriminadores en vez de cambiar la forma existente. Un cambio masivo desde el panel se fragmenta en bloques de hasta 500 pases: cada bloque es un evento propio con count = total del lote y un campo adicional batch { id, index, total, passCount } para reensamblar (ninguna fila se descarta); el emisor de la API v1 no lo incluye.Ningún otro camino de actualización emite hoy: ediciones de campos, reemplazos de foto y renovaciones de período de pago NO disparan este evento.
Ejemplo de payload
{
  "institutionId": "6b2f0c1d-…",
  "changeType": "student_status",
  "count": 2,
  "groups": [
    {
      "studentStatus": "Inactive",
      "passes": [
        {
          "uniqueIdentifier": "1002003001",
          "careerId": "9e4a7d21-…",
          "passType": "student",
          "externalId": "SIS-2026-0042"
        },
        {
          "uniqueIdentifier": "1002003002",
          "careerId": "9e4a7d21-…"
        }
      ]
    }
  ]
}
pass.revokedEducaciónUna vez por revocación efectiva de un pase. El payload lleva el identificador completo (institución, identificador único, carrera y tipo de pase), la razón y la fecha de revocación.Revocaciones programáticas sin contexto de tenant (procesos automáticos internos) no emiten.
Ejemplo de payload
{
  "institutionId": "6b2f0c1d-…",
  "uniqueIdentifier": "1002003001",
  "careerId": "9e4a7d21-…",
  "passType": "student",
  "reason": "Retiro del programa",
  "revokedAt": "2026-07-29T15:04:05.000Z"
}
pass.installedTodasPor cada señal de instalación en wallet, en las cinco verticales y ambas plataformas. En Apple la señal es el registro PassKit nuevo; en Google es el callback de guardado (autenticado con ECv2 — es una señal de que el wallet guardó el pase, no una prueba de que siga en el dispositivo). resourceRef es la referencia de PLATAFORMA que emitió la señal (en education el serial de Apple o el objectId de Google; en las otras cuatro el id interno del recurso), así que NO colapsa Apple con Google para el mismo recurso: para correlacionar usa externalId, que es la clave natural que tu propio sistema envió — external_id en education, membresías, cuotas y entradas; paymentReference o accountReference en servicios. entityKind dice qué entidad designan ambas referencias (pass, bill, account, member, ticket o installment) y es lo que distingue, en servicios, una factura de un contrato. Ninguna de las dos lleva datos personales: el identificador único de education (con frecuencia un documento de identidad) nunca viaja en este evento. Trata la entrega como al-menos-una-vez: deduplica los reintentos con x-avex-delivery-id y, para colapsar instalaciones repetidas del mismo recurso en ambas plataformas, con la tupla (vertical, institutionId, entityKind, externalId): externalId solo es único por institución en education, y en servicios entityKind separa una factura de un contrato con la misma referencia. Sin externalId no hay clave natural: deduplica solo por x-avex-delivery-id (resourceRef no colapsa Apple con Google).En Apple, un registro repetido del mismo dispositivo sobre un pase ya registrado no re-emite (solo el registro nuevo dispara). En Google NO existe esa garantía: el callback deduplica por nonce, así que un guardado NUEVO del mismo objeto vuelve a emitir — de ahí el dedupe del lado del integrador. Tampoco emiten las señales de un objeto de generación muerta (tras un traspaso o una rotación de identidad) ni las que llegan fuera de orden cuando ya se aplicó una señal más fresca del mismo objeto. Las dos claves nuevas viajan AUSENTES —jamás en null— pero no juntas: externalId falta cuando el recurso se creó desde el panel de AVEX y no tiene clave tuya, mientras entityKind sigue llegando (AVEX sabe qué entidad es, aunque no tengas clave para ella); las dos faltan solo cuando el recurso ya no existe al momento de la señal o la instalación no trae referencia de entidad.
Ejemplo de payload
{
  "vertical": "education",
  "platform": "apple",
  "resourceRef": "c81d4e2e-…",
  "externalId": "SIS-1001",
  "entityKind": "pass",
  "institutionId": "6b2f0c1d-…",
  "installedAt": "2026-07-29T15:04:05.000Z"
}
payment.receivedServicios públicosUna vez por factura de servicios marcada como pagada a través del webhook entrante firmado. paymentReferenceHash es el hash SHA-256 de la referencia de pago — el texto plano nunca viaja.Reintentos idempotentes del webhook entrante (la misma confirmación repetida) y confirmaciones concurrentes que pierden la carrera de marcado no emiten.
Ejemplo de payload
{
  "billId": "c81d4e2e-…",
  "institutionId": "6b2f0c1d-…",
  "paidAt": "2026-07-29T15:04:05.000Z",
  "paymentReferenceHash": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
}
subscription.tier_changedTodasUna vez por transición real de plan, con previousTier y newTier (nomenclatura simétrica, convención de transiciones de estado).Actualizaciones de solo estado o de solo límites no emiten — únicamente el cambio efectivo de tier dispara. El alta inicial del trial tampoco emite (no hay plan anterior); iniciar un trial en un plan distinto del vigente sí emite.
Ejemplo de payload
{
  "clientId": "d94b1e77-…",
  "previousTier": "starter",
  "newTier": "growth",
  "changedAt": "2026-07-29T15:04:05.000Z"
}
ticket.validatedEntretenimientoUna vez por validación EFECTIVA (la que transiciona la entrada a validada), tanto por escaneo del código en puerta como por entrada manual del validador en el panel. validatedBy llega solo cuando quien valida lo reporta. El barcodeToken (la credencial de escaneo) jamás viaja en el payload.Re-escaneos idempotentes de una entrada ya usada y rechazos de puerta (entrada anulada, función fuera de ventana) no emiten.
Ejemplo de payload
{
  "ticketId": "f37a9c40-…",
  "externalId": "TCK-88412",
  "eventId": "0d9b6c3a-…",
  "showtimeId": "5a1e8f92-…",
  "validatedAt": "2026-07-29T20:15:00.000Z",
  "validatedBy": "puerta-2"
}

Entrega: al menos una vez, con reintentos y cola de mensajes muertos

Cada evento se entrega al menos una vez — diseña tu handler idempotente y deduplica con el header x-avex-delivery-id: los reintentos de una misma entrega reutilizan el mismo identificador. Una entrega tiene hasta 5 intentos: el primero dispara de inmediato y, si tu endpoint no responde 2xx, los reintentos esperan 1 min, 5 min, 15 min, 1 h respectivamente. Agotados los intentos, la entrega pasa a la cola de mensajes muertos (DLQ), visible para los operadores del panel — ninguna entrega fallida se descarta en silencio.

05 — Endpoints de entrada

El contrato de entrada completo en un solo mapa

Todo endpoint al que tu sistema llama hacia AVEX, en una sola tabla, con su mecanismo de autenticación y el tipo de credencial por fila. Tres familias de auth: API key (Bearer avex_k1_* con scope por vertical) para la ingesta v1, HMAC-SHA256 con un secreto entrante por cliente para los webhooks de pago, y el HMAC Svix del webhook de Resend gestionado por la plataforma.

Inventario de endpoints de entrada — método, autenticación y credencial por ruta
EndpointMétodoAutenticaciónCredencial
Ingesta v1 — API key Bearer avex_k1_* + scope por vertical
/api/v1/pass/upsertPOSTAPI key · scope passes:updateAPI key (Bearer avex_k1_*)
/api/v1/pass/{externalId}/academicPATCHAPI key · scope passes:updateAPI key (Bearer avex_k1_*)
/api/v1/pass/{externalId}/transfer-careerPOSTAPI key · scope passes:updateAPI key (Bearer avex_k1_*)
/api/v1/pass/{externalId}/eventPOSTAPI key · scope passes:updateAPI key (Bearer avex_k1_*)
/api/v1/pass/{externalId}/period/snapshotPOSTAPI key · scope passes:updateAPI key (Bearer avex_k1_*)
/api/v1/pass/{externalId}/revokePOSTAPI key · scope passes:updateAPI key (Bearer avex_k1_*)
/api/v1/loyalty/member/upsertPOSTAPI key · scope loyalty:writeAPI key (Bearer avex_k1_*)
/api/v1/loyalty/member/{externalId}/snapshotPOSTAPI key · scope loyalty:writeAPI key (Bearer avex_k1_*)
/api/v1/loyalty/member/{externalId}/revokePOSTAPI key · scope loyalty:writeAPI key (Bearer avex_k1_*)
/api/v1/installment/{externalId}/snapshotPOSTAPI key · scope installments:writeAPI key (Bearer avex_k1_*)
/api/v1/installment/{externalId}/revokePOSTAPI key · scope installments:writeAPI key (Bearer avex_k1_*)
/api/v1/installment/upsertPOSTAPI key · scope installments:writeAPI key (Bearer avex_k1_*)
/api/v1/utility/bill/upsertPOSTAPI key · scope utilities:writeAPI key (Bearer avex_k1_*)
/api/v1/utility/bill/{paymentReference}/snapshotPOSTAPI key · scope utilities:writeAPI key (Bearer avex_k1_*)
/api/v1/utility/bill/{paymentReference}/revokePOSTAPI key · scope utilities:writeAPI key (Bearer avex_k1_*)
/api/v1/utility/bill/{paymentReference}/install-linkGETAPI key · scope utilities:read o utilities:writeAPI key (Bearer avex_k1_*)
/api/v1/entertainment/ticket/upsertPOSTAPI key · scope events:writeAPI key (Bearer avex_k1_*)
/api/v1/entertainment/ticket/{externalId}/install-linkGETAPI key · scope tickets:credentialAPI key (Bearer avex_k1_*)
/api/v1/loyalty/member/{externalId}/install-linkGETAPI key · scope loyalty:read o loyalty:writeAPI key (Bearer avex_k1_*)
/api/v1/installment/{externalId}/install-linkGETAPI key · scope installments:read o installments:writeAPI key (Bearer avex_k1_*)
/api/v1/entertainment/ticket/{externalId}/snapshotPOSTAPI key · scope events:writeAPI key (Bearer avex_k1_*)
/api/v1/entertainment/ticket/{externalId}/voidPOSTAPI key · scope events:writeAPI key (Bearer avex_k1_*)
/api/v1/entertainment/validatePOSTAPI key · scope tickets:validateAPI key (Bearer avex_k1_*)
/api/v1/verify/{institutionId}/{uniqueIdentifier}/{careerCode}GETAPI key · scope passes:readAPI key (Bearer avex_k1_*)
/api/v1/verify/utility/{billId}GETAPI key · scope passes:read · obsoleto (solo pases legacy)API key (Bearer avex_k1_*)
/api/v1/verify/loyalty/{memberId}GETAPI key · scope passes:readAPI key (Bearer avex_k1_*)
/api/v1/verify/real-estate/{installmentId}GETAPI key · scope passes:readAPI key (Bearer avex_k1_*)
/api/v1/verify/entertainment/{ticketId}GETAPI key · scope passes:readAPI key (Bearer avex_k1_*)
/api/v1/institutionsGETAPI key · scope institutions:readAPI key (Bearer avex_k1_*)
/api/v1/institutions/{institutionId}/careersGETAPI key · scope careers:readAPI key (Bearer avex_k1_*)
/api/v1/institutions/{institutionId}/careersPOSTAPI key · scope careers:writeAPI key (Bearer avex_k1_*)
/api/v1/eventsGETAPI key · scope events:readAPI key (Bearer avex_k1_*)
/api/v1/events/{id}/showtimesGETAPI key · scope events:readAPI key (Bearer avex_k1_*)
/api/v1/loyalty/programsGETAPI key · scope loyalty:readAPI key (Bearer avex_k1_*)
Webhooks de pago por cliente — HMAC-SHA256, un secreto entrante por cliente
/api/payments/utility-webhook/{clientSlug}POSTHMAC secreto por clienteSecreto HMAC por cliente
/api/webhooks/installment-payment/{clientSlug}POSTHMAC secreto por clienteSecreto HMAC por cliente
/api/webhooks/payment-confirmation/{clientSlug}POSTHMAC secreto por clienteSecreto HMAC por cliente
/api/payments/loyalty-snapshot/{clientSlug}POSTHMAC secreto por clienteSecreto HMAC por cliente
Webhook de Resend — HMAC Svix, gestionado por la plataforma
/api/webhooks/resendPOSTHMAC Svix (svix-signature)Gestionado por la plataforma

Esta tabla es documentación de solo lectura. Las credenciales se crean y rotan desde Mi organización (para superadmin: Admin cliente) en el panel de AVEX: las API keys en la pestaña API, y el secreto entrante (uno por cliente) en la pestaña Webhook entrante. Cada webhook de pago pertenece a un vertical: la URL vigente para tu cliente es la de su vertical — el panel la muestra — y una entrega dirigida al receptor de otro vertical responde 404. Los webhooks salientes viven en su propia pestaña Webhooks salientes. No existe una URL de webhook con secreto global: si aún no aprovisionas tu secreto entrante, la misma URL por-cliente acepta temporalmente la firma con el secreto de plataforma — con una excepción: el webhook de snapshot loyalty exige el secreto por cliente desde su nacimiento (sin secreto responde 401). El webhook de Resend lo administra la plataforma.

Qué identificador usa cada endpoint

Regla única: los paths de la ingesta v1 se direccionan SIEMPRE por la clave natural que asignas en tu sistema — nunca por un UUID de AVEX. El UUID interno aparece en un path solo donde lo entrega el propio pase (verify) o donde lo pides explícitamente (discovery).

Mapa de identidad — qué identificador viaja en cada superficie de la API
IdentificadorDónde viajaQué es
externalId/api/v1/pass/{externalId}/… · /api/v1/loyalty/member/{externalId}/… · /api/v1/installment/{externalId}/… · /api/v1/entertainment/ticket/{externalId}/…La clave natural que tú asignas en tu SIS / CRM / ERP / sistema de venta de entradas y envías en el upsert. Es el identificador de snapshot, revoke, void e install-link; los upserts de loyalty, real_estate y entertainment te lo devuelven en la respuesta (education opera por externalId sin ecoarlo; utilities responde por paymentReference).
paymentReference/api/v1/utility/bill/{paymentReference}/…La clave natural de utilities: la referencia de pago que emite tu facturador. Cumple el mismo papel que el externalId en las otras verticales.
UUID interno de AVEX/api/v1/verify/… · GET de descubrimientoSolo aquí. En verify el identificador llega dentro del QR del pase; los GET de descubrimiento existen precisamente para resolver los UUIDs que los upserts piden en el CUERPO (institutionId, programId, showtimeId).
id (respuesta del upsert)Cuerpo de la respuesta 200 / 201Informativo: correlaciona el registro con el panel de AVEX y con los webhooks salientes. NO es un path param de v1 — para los verbos siguientes usa tu externalId.
Webhooks de pagoCuerpo del POST (el path solo lleva tu clientSlug)El destinatario viaja en el cuerpo, no en la ruta: paymentReference (utilities), installmentId o externalId —exactamente uno— + paymentReference (real estate), accountNumber (loyalty), referenceId + transactionId (confirmación de pago).

Firma de los webhooks de pago (HMAC-SHA256 con timestamp)

Envía X-Webhook-Timestamp (epoch en segundos) y firma la cadena `${timestamp}.${rawBody}` con tu secreto entrante; el resultado en hex viaja en X-Webhook-Signature. AVEX rechaza firmas con un desfase mayor a ±5 minutos (anti-replay). El timestamp es obligatorio: una firma del body sin timestamp responde 401.

TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | sed 's/^.* //')
curl -X POST "$URL" \
  -H "Content-Type: application/json" \
  -H "X-Webhook-Timestamp: $TS" \
  -H "X-Webhook-Signature: $SIG" \
  -d "$BODY"

El body JSON esperado por cada webhook está publicado en la referencia OpenAPI (operaciones de Webhooks entrantes).

Los webhooks de pago son un canal de eventos en tiempo real: un POST por confirmación, limitado a 10 solicitudes por minuto por IP (un 429 incluye Retry-After). Para reconciliación en lote —reprocesos, cierres de mes, cargas históricas— usa los endpoints de snapshot v1 con tu API key: corren en el tier de integración (300 solicitudes por minuto por clave) y aplican el mismo estado sobre el pase. Una diferencia a tener en cuenta: el evento saliente payment.received lo emite SOLO el webhook entrante firmado de utilities — el snapshot v1 aplica el mismo estado pero no dispara ese evento a tus webhooks salientes.

06 — Flujos

Tres patrones que ya están en producción

Cada diagrama refleja un flujo que clientes integran hoy. Las cajas en azul AVEX son código nuestro; las cajas neutras son tu sistema o un proveedor externo.

Ingesta v1 desde tu SIS o ERP

Tu sistema empuja el pase por externalId a POST /api/v1/pass/upsert con Bearer API key e Idempotency-Key: el primer push crea (201), los siguientes reflejan los campos mutables (200) y AVEX entrega la actualización a Apple Wallet y Google Wallet.

Confirmación de pago

La pasarela firma el body con HMAC, AVEX verifica con timingSafeEqual y delega al servicio de install del vertical para activar el pase.

Install token

AVEX firma un token corto con HMAC y entrega el enlace por email; tu sistema puede repartirlo por su propio canal con el enlace tokenizado de la API (install-link). La ruta pública de instalación — /install/utility/{billId}/{token} y sus equivalentes por vertical — valida firma y expiry antes de emitir el pase.

07 — Rate limits

Tiers públicos sincronizados con producción

Los números de esta tabla se generan desde la misma configuración que corre en producción — no pueden divergir de los límites que la API realmente aplica. Todo 429 emitido por el rate limiter de esta tabla incluye el header Retry-After con los segundos restantes — también en la ingesta v1 con API key. La diferencia entre caminos está en las cabeceras informativas X-RateLimit-*: en el panel y los endpoints públicos acompañan cada respuesta; en la ingesta v1 viajan solo con el 429. Algunos topes de negocio (p. ej. el límite diario de envíos de foto) también responden 429 pero SIN Retry-After — no dependas del header para distinguirlos: usa el contexto del endpoint.

Rate limits por tier — AVEX API pública
TierLímiteVentanaAlcanceEndpoint ejemplo
userUsuario autenticado300 req1 minPor usuarioGET /api/institution
publicEndpoints públicos (lectura)30 req1 minPor IPGET /api/docs/openapi.json
public_strictEndpoints públicos (escritura)10 req1 minPor IPPOST /api/contact
public_installInstalación de pases (descarga)60 req1 minPor IPGET /api/public/loyalty/{memberId}/pass-info/apple
strictEndpoints críticos5 req1 minPor IP o usuarioPOST /api/events/{id}/duplicate
heavy_notificationNotificaciones masivas20 req1 hPor usuario o API keyPOST /api/institution/{institutionId}/pass/notifications
api_integrationIntegraciones por API key300 req1 minPor API keyPOST /api/v1/pass/upsert

Los límites se aplican según la columna Alcance: por IP en los endpoints públicos, por usuario en el panel y por API key en la ingesta v1. Si tu integración requiere un tier más alto, escríbenos al correo del final de la página.

08 — Errores

Un solo formato de error para toda la API

Todos los endpoints responden errores con el mismo formato JSON anidado bajo error: el campo error.code es estable y es sobre el que tu código debe decidir; error.message es un mensaje legible en español — no lo parsees — y error.details es opcional y depende del código.

JSONerror-response.json
{
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Demasiadas solicitudes. Intente de nuevo más tarde."
  }
}
Ejemplo de respuesta 429. Los segundos de espera viajan en el header HTTP Retry-After, no en el body.
JSONvalidation-error.json
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Datos inválidos",
    "details": {
      "errors": [],
      "properties": {
        "data": {
          "errors": [],
          "properties": {
            "email": {
              "errors": ["Correo electrónico inválido"]
            }
          }
        }
      }
    }
  }
}
Ejemplo de respuesta 400. details es el árbol de z.treeifyError(error): cada campo anida sus errores bajo properties.

Códigos comunes

  • HTTP 401SESSION_INVALID

    Falta el header Authorization, o la API key es inválida, expiró o fue revocada.

  • HTTP 401CLIENT_INACTIVE

    La API key es válida pero el cliente al que pertenece está inactivo o suspendido.

  • HTTP 402SUBSCRIPTION_INACTIVE

    La suscripción del cliente no está activa. Las rutas operacionales de puerta (verify y validate) quedan exentas; el resto de la API v1 bloquea hasta renovar.

  • HTTP 402SUBSCRIPTION_LIMIT_EXCEEDED

    El plan del cliente quedó sin cupo para el recurso solicitado (pases, usuarios o instituciones). La suscripción sigue activa; libera cupo o amplía el plan para continuar.

  • HTTP 400VALIDATION_ERROR

    El body no cumple la validación Zod del endpoint. details es el árbol de z.treeifyError: los errores se anidan por campo bajo properties, no llegan como lista plana. Ojo: este mismo code también llega con status 409 en el conflicto de catálogo del POST de carreras (código ya existente) — discrimina por status, no solo por code.

  • HTTP 404RESOURCE_NOT_FOUND

    El recurso no existe o no pertenece al cliente asociado a la API key.

  • HTTP 404IMAGE_SERVICE

    El fetch server-side de una imagen falló (photoUrl del upsert education): URL con redirect, host inaccesible o que filtra el User-Agent, tamaño mayor a 20 MB, timeout de 30 s, o bytes que no son una imagen decodificable. Distinto de RESOURCE_NOT_FOUND: el pase/carrera existen — la que falló fue la foto. Una falla INTERNA al procesar la foto nunca llega con este code: responde 500 INTERNAL_ERROR; y una caída de nuestro almacenamiento responde 502 EXTERNAL_SERVICE_ERROR — en ambos casos reintenta más tarde, no es tu URL.

  • HTTP 409CONFLICT

    La misma Idempotency-Key está en vuelo (otro intento del mismo push sigue procesándose — reintenta en unos segundos) o su resultado original ya no está disponible (re-push con una clave nueva). Una key completada con resultado no da 409: los upserts v1 responden 200 re-entregando la respuesta original almacenada, verbatim.

  • HTTP 409CONFIRMATION_REQUIRED

    La operación exige consentimiento explícito antes de proceder. En los envíos de notificación (pass/notifications, reminders/csv): la audiencia completa es Google Wallet con la cuota de notificaciones visibles (24 h) agotada y sin destinatarios Apple — nadie vería el aviso. Reintenta la MISMA request con acknowledgeNoPush=true para entregar la actualización sin notificación visible, o espera a que la ventana de cuota se libere.

  • HTTP 429RATE_LIMIT_EXCEEDED

    El caller excedió el tier aplicable. Los segundos de espera llegan en el header HTTP Retry-After — nunca en el body.

  • HTTP 403INSUFFICIENT_PERMISSIONS

    La credencial es válida pero no tiene permisos sobre el recurso o la operación: scope de la API key insuficiente o institución fuera del alcance asignado.

  • HTTP 403FEATURE_DISABLED

    La funcionalidad que requiere el endpoint está deshabilitada para el cliente. Un administrador debe activarla antes de reintentar.

  • HTTP 400PASS_TYPE_INVALID

    El tipo de pase enviado no existe en el catálogo de la institución o usa un código reservado del sistema. Corrige el campo passType del request.

  • HTTP 400CUSTOM_FIELD_KEY_RESERVED

    Una clave de customFields (upsert o snapshot de utilities/installment/loyalty/entertainment) colisiona con un campo del motor de campos del vertical — sombrearía el resolver o produciría un render duplicado. Renombra la clave; las reservadas varían por vertical.

  • HTTP 401WEBHOOK_UNAUTHORIZED

    Aplica a los receptores de webhooks entrantes (rutas firmadas de pagos y notificaciones), no a la API v1 con API key: la firma del webhook falta, es inválida o ya fue usada.

  • HTTP 503WEBHOOK_NOT_CONFIGURED

    Aplica a los receptores de webhooks entrantes: la recepción está habilitada pero falta configurar el secreto de verificación del lado de AVEX. El emisor debe reintentar más tarde.

  • HTTP 503SERVICE_UNAVAILABLE

    El gate de validación de entradas de entertainment no está disponible (llave TOTP ausente o registro incompleto). El escáner debe reintentar más tarde; el request era válido.

  • HTTP 502EXTERNAL_SERVICE_ERROR

    Un servicio externo del canal (Apple Wallet, Google Wallet, el proveedor de email o nuestro almacenamiento de imágenes) falló al procesar la operación. El request era válido; reintenta con backoff.

  • HTTP 500INTERNAL_ERROR

    Error interno del servidor. El mensaje es genérico a propósito y no trae detalles; reintenta y reporta a soporte si persiste.

  • HTTP 501NOT_IMPLEMENTED

    El vertical del cliente aún no implementa el flujo solicitado (red de seguridad del contrato vertical-sin-flujo). El request era válido pero la operación no existe para ese vertical.

09 — Idempotencia

Reintenta sin duplicar efectos

Envía Idempotency-Key en los POST de ingesta para que tus reintentos sean seguros aunque la red falle a medio camino. El efecto corre una sola vez: la repetición de la misma clave dentro de la ventana de 24 horas responde 200 con la respuesta original almacenada, sin ejecutar nada de nuevo.

cURLcreate-pass.sh
curl -X POST https://www.avex.com.co/api/v1/pass/upsert \
  -H "Authorization: Bearer avex_k1_<tu_clave>" \
  -H "Idempotency-Key: 00000000-0000-4000-8000-000000000001" \
  -H "Content-Type: application/json" \
  -d '{
  "institutionId": "11111111-1111-4111-8111-111111111111",
  "externalId": "EST-12345",
  "data": {
    "uniqueIdentifier": "1098765432",
    "careerId": "ING-SIS",
    "name": "Ana María Gómez",
    "email": "ana.gomez@example.edu.co",
    "semester": 6,
    "enrollmentYear": 2023,
    "studentStatus": "Active",
    "academicCalendarLink": null,
    "photoUrl": "https://cdn.example.edu.co/fotos/est-12345.jpg"
  }
}'
Misma clave = el efecto corre una sola vez; el replay devuelve la respuesta original con status 200. Reemplaza el UUID por uno generado por tu cliente.
  • Usa un UUID v4 y congela el body

    Genera la clave con un UUID v4 único por operación lógica. La misma clave, reenviada con el MISMO body dentro de la ventana de 24 horas, NO ejecuta el efecto de nuevo. La clave efectiva incluye el hash del payload: reintenta siempre con los mismos bytes — la misma Idempotency-Key con un body distinto se procesa como una operación nueva.

  • El replay devuelve la respuesta original

    En los upserts, la repetición devuelve el body original almacenado, verbatim y con status 200 (aunque la primera ejecución fuera 201 — el body conserva su created). Si la clave sigue en vuelo, responde 409: reintenta en unos segundos.

  • Ámbito por credencial y recurso

    El espacio de claves se compone por recurso y por credencial, no es global: la misma Idempotency-Key enviada con OTRA API key es una entrada distinta (no hay deduplicación cross-key — no la esperes al rotar credenciales), y nunca colisiona con la de otro cliente. Aplica solo a POST — el único verbo de escritura de la ingesta v1 hoy; GET lo ignora para no inducir falsa seguridad.

  • Carreras: idempotencia por clave natural

    El POST de carreras no usa Idempotency-Key (enviarla no tiene efecto): su reintento seguro es el contrato mismo — el code es la clave natural del catálogo, así que repetir el POST responde 409 «ya existía» y tu sync sigue de largo. Para sondear sin crear, usa X-AVEX-Dry-Run.

10 — Estabilidad

Contrato de estabilidad de la API v1

La API v1 es un contrato: lo publicado no se rompe sin aviso. Estas son las reglas de deprecación (ADR-034) y el registro de cambios de contrato — hasta hoy cambios aditivos y correctivos, ningún retiro.

  • Qué es un breaking change

    Quitar o renombrar un endpoint, campo o valor de enum publicado; estrechar la validación de un input existente; cambiar el significado de un status code ya emitido. Añadir endpoints, campos opcionales, campos nuevos de respuesta o valores nuevos de enum NO es breaking: tu cliente debe tolerar campos y valores que no conoce.

  • Ventana de deprecación: 6 meses

    Nada publicado se retira sin al menos 6 meses de convivencia con su reemplazo — dentro de /v1/, o publicando /v2/ y manteniendo /v1/ completo durante la ventana.

  • Señalización mecánica

    Todo lo que entra en deprecación emite los headers Sunset (RFC 8594, con la fecha de retiro) y Deprecation: true, y su operación queda marcada deprecated en la spec OpenAPI pública — monitorea esos headers en tus integraciones.

Registro de cambios de contrato: el contrato nació el 2026-07-24. Las deprecaciones futuras se listarán aquí con su fecha de retiro; los cambios aditivos (nunca breaking) y los correctivos (la respuesta pasa a ser la que el contrato ya prescribía) quedan registrados abajo.

2026-09-26 — Correctivo (#3115): la tupla recomendada el 2026-09-22 para deduplicar pass.installed, (vertical, externalId), no es única dentro de tu cuenta — en education el externalId es único por institución, y en servicios una factura y un contrato pueden compartir referencia. Usa (vertical, institutionId, entityKind, externalId); todos esos campos ya viajan en el evento. Sin externalId no hay clave natural: deduplica solo por x-avex-delivery-id. El payload no cambia.

2026-09-22 — Aditivo (#2585): el evento pass.installed suma dos campos opcionales — externalId (la clave natural que tu propio sistema envió: external_id en education, membresías, cuotas y entradas; paymentReference o accountReference en servicios) y entityKind (pass, bill, account, member, ticket o installment). resourceRef no cambia. Como resourceRef es la referencia de plataforma, no colapsa Apple con Google del mismo recurso: para deduplicar instalaciones usa ahora la tupla (vertical, externalId). Los campos llegan ausentes, nunca en null, y no siempre juntos: si el recurso nació en el panel de AVEX y no tiene clave tuya falta solo externalId y entityKind sigue llegando; ambos faltan solo si el recurso ya no existe al momento de la señal.

2026-09-05 — Aditivo (#1898): en event, revoke y period/snapshot de education, si el pase cambia de carrera dos veces durante la misma llamada la respuesta es 409 reintentable; una transferencia concurrente simple se absorbe re-resolviendo el externalId al pase vigente. En el upsert (HIT) una sola transferencia concurrente ya responde 409 sin escribir: reconciliá el careerId del payload con la carrera nueva y reenviá.

2026-09-05 — Correctivo (#1882): la transferencia de carrera desde el panel mueve el externalId al pase destino. Antes /v1/pass/{externalId} seguía resolviendo al pase revocado — un push con el careerId viejo respondía 200 y con el nuevo 409; ahora es al revés, como la identidad inmutable ya prescribía. Sin efecto para pases nunca transferidos.

2026-09-03 — Aditivo (ADR-034): valor nuevo del enum modality de carreras — «Presencial / Virtual» — en crear/actualizar carrera y en el discovery de carreras.

2026-08-30 — Aditivo (ADR-034): customFields opcional en upsert+snapshot de utilities/installment/loyalty/entertainment.

11 — Próximos pasos

¿Listo para integrar?

Estos son los tres pasos que normalmente siguen los equipos técnicos cuando han terminado de leer esta documentación.