# Pokit API

> API pública de Pokit para integrar tu punto de venta o sistemas con tus tarjetas de lealtad: emite tarjetas para Apple Wallet y Google Wallet, ajusta puntos o sellos, programa promociones, reparte y canjea cupones, y consulta tus plantillas. Autentica cada solicitud con una API key (`Authorization: Bearer pk_live_...`) generada desde el panel. Todas las fechas se devuelven en formato ISO 8601.

Referencia de la API de Pokit para asistentes y modelos de lenguaje. Se genera en cada petición a partir del mismo spec OpenAPI que alimenta la documentación para personas, así que siempre refleja la API real.

Base URL: `https://pokit.mx/api/v1`

## Autenticación

Cada solicitud lleva la cabecera `Authorization: Bearer pk_live_...` con una API key creada en el panel (API Keys). Las respuestas son JSON y las fechas van en ISO 8601. Límite: 120 solicitudes por minuto por key; al superarlo responde 429 con `Retry-After`.

```bash
curl "https://pokit.mx/api/v1/templates" -H "Authorization: Bearer pk_live_xxxxxxxxxxxx"
```

## Errores

Todo error responde un objeto JSON con dos campos: `error` (código estable, para programar) y `detail` (explicación para personas, en el idioma de `Accept-Language`). Un recurso de otro negocio responde 404, igual que uno inexistente.

## Endpoints

### GET /templates — Listar plantillas

Devuelve las plantillas de tarjeta de tu negocio con el número de tarjetas emitidas por cada una.

**Respuestas**

- `200` — Lista de plantillas.

```json
[
  {
    "id": "cmtpl1a2b0003pokittmpl1",
    "name": "Café Aroma — Puntos",
    "type": "POINTS",
    "active": true,
    "createdAt": "2026-05-12T10:00:00.000Z",
    "cards": 342
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /templates/{id} — Detalle de plantilla

Devuelve una plantilla con su diseño, reglas y sucursales. `branches` son las sucursales activas donde vale (todas las del negocio si no se eligieron) en el orden de la plantilla; `branchPass` dice cuáles 10 llegan al pase: `fixed` (las primeras 10) o `nearest` (las 10 más cercanas a la última sucursal donde se movió cada tarjeta).

**Parámetros**

- `id` (path, string, required) — ID de la plantilla.

**Respuestas**

- `200` — Plantilla con sus sucursales.

```json
{
  "id": "cmtpl1a2b0003pokittmpl1",
  "name": "Café Aroma — Puntos",
  "type": "POINTS",
  "description": "Acumula puntos por cada compra.",
  "backgroundColor": "#1a1a2e",
  "foregroundColor": "#ffffff",
  "labelColor": "#a0a0c0",
  "logoText": "Café Aroma",
  "stripImageUrl": null,
  "stampsTotal": 10,
  "pointsPerVisit": 10,
  "rewardText": "Café gratis a los 100 puntos",
  "barcodeFormat": "QR",
  "active": true,
  "createdAt": "2026-05-12T10:00:00.000Z",
  "updatedAt": "2026-06-01T08:00:00.000Z",
  "branchPass": "nearest",
  "branches": [
    {
      "id": "cmbrc4k9p0007pokitbrc01",
      "name": "Okuma Polanco",
      "address": "Av. Presidente Masaryk 120, Polanco, CDMX",
      "latitude": 19.4326,
      "longitude": -99.1937,
      "relevantText": "¡Estás cerca de Okuma Polanco! Muestra tu tarjeta.",
      "active": true,
      "createdAt": "2026-09-02T16:20:00.000Z"
    }
  ],
  "locations": [
    {
      "id": "cmbrc4k9p0007pokitbrc01",
      "templateId": "cmtpl1a2b0003pokittmpl1",
      "name": "Sucursal Centro",
      "latitude": 19.4326,
      "longitude": -99.1332,
      "relevantText": "¡Estás cerca de Café Aroma! Muestra tu tarjeta."
    }
  ],
  "cards": 342
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /cards — Listar tarjetas

Lista paginada de tarjetas del negocio. Filtra por plantilla, email, teléfono, serial o número de socio. Para identificar a quien escanea su pass en caja, busca por `serial`: es el valor del código. Si la plantilla usa código rotativo, lo que se escanea son 14 dígitos: usa `POST /codes/verify`.

**Parámetros**

- `templateId` (query, string) — Filtra por plantilla.
- `email` (query, string) — Filtra por email exacto del cliente.
- `phone` (query, string) — Filtra por teléfono exacto del cliente (10 a 15 dígitos).
- `serial` (query, string) — Filtra por serial: el valor que contiene el código QR o de barras del pass.
- `memberNumber` (query, string) — Filtra por número de socio: los 8 dígitos fijos con los que empieza el código de la tarjeta.
- `page` (query, integer) — Página (desde 1).
- `limit` (query, integer) — Resultados por página (máx. 100).

**Respuestas**

- `200` — Página de tarjetas.

```json
{
  "data": [
    {
      "id": "cmcrd8f2k0001pokitcard1",
      "serial": "cmsrl4x9m0002pokitserl1",
      "memberNumber": "48213907",
      "templateId": "cmtpl1a2b0003pokittmpl1",
      "customerId": "cmcus3k8p0005pokitcust1",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {
        "tu_cumpleanos": "1990-05-14"
      },
      "points": 120,
      "stamps": 0,
      "status": "ACTIVE",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-07-18T09:12:00.000Z"
    }
  ],
  "page": 1,
  "limit": 20,
  "total": 342
}
```

- `401` — API key faltante, malformada o revocada.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /cards — Emitir tarjeta

Crea una tarjeta para un cliente a partir de una plantilla de tu negocio; la respuesta incluye las URLs para compartirla y agregarla al wallet. Hay una sola tarjeta por correo y plantilla: si ya existe, responde 200 con la tarjeta existente en vez de duplicarla, así que reintentar tras un fallo de red es seguro. El correo se guarda en minúsculas. El cuerpo debe ir como JSON con `Content-Type: application/json` (si no, 400 o 415).

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/CreateCardInput"
}
```

Ejemplo:

```json
{
  "templateId": "cmtpl1a2b0003pokittmpl1",
  "customerName": "María González",
  "customerEmail": "maria@ejemplo.com"
}
```

**Respuestas**

- `201` — Tarjeta creada.

```json
{
  "id": "cmcrd8f2k0001pokitcard1",
  "serial": "cmsrl4x9m0002pokitserl1",
  "memberNumber": "48213907",
  "templateId": "cmtpl1a2b0003pokittmpl1",
  "customerId": "cmcus3k8p0005pokitcust1",
  "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
  "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
  "customerName": "María González",
  "customerEmail": "maria@ejemplo.com",
  "customerPhone": "5512345678",
  "data": {
    "tu_cumpleanos": "1990-05-14"
  },
  "points": 0,
  "stamps": 0,
  "status": "ACTIVE",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z",
  "urls": {
    "public": "https://pokit.mx/c/cmcrd8f2k0001pokitcard1",
    "appleWallet": "https://pokit.mx/api/wallet/apple/cmcrd8f2k0001pokitcard1",
    "googleWallet": "https://pokit.mx/api/wallet/google/cmcrd8f2k0001pokitcard1"
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — La plantilla no existe o no pertenece a tu negocio.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /cards/{id} — Detalle de tarjeta

Devuelve una tarjeta con sus URLs públicas y de wallet.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.

**Respuestas**

- `200` — Tarjeta.

```json
{
  "id": "cmcrd8f2k0001pokitcard1",
  "serial": "cmsrl4x9m0002pokitserl1",
  "memberNumber": "48213907",
  "templateId": "cmtpl1a2b0003pokittmpl1",
  "customerId": "cmcus3k8p0005pokitcust1",
  "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
  "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
  "customerName": "María González",
  "customerEmail": "maria@ejemplo.com",
  "customerPhone": "5512345678",
  "data": {
    "tu_cumpleanos": "1990-05-14"
  },
  "points": 120,
  "stamps": 0,
  "status": "ACTIVE",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z",
  "urls": {
    "public": "https://pokit.mx/c/cmcrd8f2k0001pokitcard1",
    "appleWallet": "https://pokit.mx/api/wallet/apple/cmcrd8f2k0001pokitcard1",
    "googleWallet": "https://pokit.mx/api/wallet/google/cmcrd8f2k0001pokitcard1"
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### DELETE /cards/{id} — Revocar tarjeta

Revoca una tarjeta (status `REVOKED`). Una tarjeta revocada ya no acepta ajustes de puntos o sellos.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.

**Respuestas**

- `200` — Tarjeta revocada.

```json
{
  "id": "cmcrd8f2k0001pokitcard1",
  "status": "REVOKED"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /cards/{id}/points — Ajustar puntos o sellos

Suma o resta puntos (o sellos) a una tarjeta. `delta` puede ser negativo; el saldo nunca baja de 0 y los sellos nunca pasan del total de la plantilla. Manda `reason` y `metadata` para dejar trazabilidad: quedan en el historial de la tarjeta. El pass del cliente se actualiza solo en su wallet, con una notificación del saldo nuevo.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/AdjustBalanceInput"
}
```

Ejemplo:

```json
{
  "delta": 10,
  "field": "points",
  "reason": "Compra en sucursal Centro",
  "metadata": {
    "ticket": "A-10482",
    "total": 349.5,
    "cajero": "Ana"
  },
  "branchId": "cmbrc4k9p0007pokitbrc01"
}
```

**Respuestas**

- `200` — Tarjeta con el saldo actualizado.

```json
{
  "id": "cmcrd8f2k0001pokitcard1",
  "serial": "cmsrl4x9m0002pokitserl1",
  "memberNumber": "48213907",
  "templateId": "cmtpl1a2b0003pokittmpl1",
  "customerId": "cmcus3k8p0005pokitcust1",
  "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
  "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
  "customerName": "María González",
  "customerEmail": "maria@ejemplo.com",
  "customerPhone": "5512345678",
  "data": {
    "tu_cumpleanos": "1990-05-14"
  },
  "points": 130,
  "stamps": 0,
  "status": "ACTIVE",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `409` — La tarjeta está revocada (`card_revoked`) o la tarjeta de sellos ya está completa (`stamps_full`). Si la plantilla exige el código rotativo: falta `code` (`code_required`), no es de esta tarjeta (`code_invalid`), ya venció (`code_expired`) o ya se usó en otro canje (`code_reused`).
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /cards/{id}/redeem — Canjear recompensa

Canjea la recompensa de una tarjeta. En tarjetas de sellos consume la tarjeta completa y exige que esté llena; en tarjetas de puntos consume `amount`. El canje cuenta como visita para el vencimiento por inactividad.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.

**Cuerpo (application/json)**

```json
{
  "$ref": "#/components/schemas/RedeemInput"
}
```

Ejemplo:

```json
{
  "amount": 500,
  "reason": "Canje: $100 de descuento",
  "metadata": {
    "ticket": "A-10511"
  }
}
```

**Respuestas**

- `200` — Tarjeta con el saldo tras el canje.

```json
{
  "id": "cmcrd8f2k0001pokitcard1",
  "serial": "cmsrl4x9m0002pokitserl1",
  "memberNumber": "48213907",
  "templateId": "cmtpl1a2b0003pokittmpl1",
  "customerId": "cmcus3k8p0005pokitcust1",
  "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
  "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
  "customerName": "María González",
  "customerEmail": "maria@ejemplo.com",
  "customerPhone": "5512345678",
  "data": {
    "tu_cumpleanos": "1990-05-14"
  },
  "points": 20,
  "stamps": 0,
  "status": "ACTIVE",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `409` — El saldo no alcanza (`insufficient_balance`), la tarjeta está revocada (`card_revoked`) o es de membresía (`nothing_to_redeem`). Si la plantilla exige el código rotativo: falta `code` (`code_required`), no es de esta tarjeta (`code_invalid`), ya venció (`code_expired`) o ya se usó en otro canje (`code_reused`).
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /codes/verify — Verificar un código

Identifica al cliente por lo que enseñó en caja y dice si su código es vigente, sin mover saldo ni gastar el código. `code` acepta los 14 dígitos del código rotativo (identifica y verifica), los 8 del número de socio, el serial del pase o la URL de la tarjeta (sólo identifican). El código de la tarjeta en línea cambia cada 30 segundos y se acepta una ventana de tolerancia; el del pase de Wallet cambia cada 1, 6 o 24 horas y se aceptan la ventana actual y la anterior. Máximo 8 intentos por tarjeta cada 10 minutos.

**Cuerpo (application/json)** (required)

```json
{
  "type": "object",
  "required": [
    "code"
  ],
  "properties": {
    "code": {
      "type": "string",
      "description": "Lo que enseñó el cliente: código de 14 dígitos, número de socio de 8, serial o URL de su tarjeta."
    }
  }
}
```

Ejemplo:

```json
{
  "code": "4821 3907 · 512 904"
}
```

**Respuestas**

- `200` — Cliente identificado. `verified` dice si el código es vigente.

```json
{
  "card": {
    "id": "cmcrd8f2k0001pokitcard1",
    "serial": "cmsrl4x9m0002pokitserl1",
    "memberNumber": "48213907",
    "templateId": "cmtpl1a2b0003pokittmpl1",
    "customerId": "cmcus3k8p0005pokitcust1",
    "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
    "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
    "customerName": "María González",
    "customerEmail": "maria@ejemplo.com",
    "customerPhone": "5512345678",
    "data": {
      "tu_cumpleanos": "1990-05-14"
    },
    "points": 120,
    "stamps": 0,
    "status": "ACTIVE",
    "createdAt": "2026-07-01T15:30:00.000Z",
    "updatedAt": "2026-07-18T09:12:00.000Z"
  },
  "verified": true,
  "source": "page",
  "expiresInSeconds": 41,
  "reason": null
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — Ese código, número de socio, serial o URL no es de una tarjeta de tu negocio.
- `409` — Sólo si la plantilla EXIGE el código para validar al cliente: no llegó un código completo (`code_required`), no es de esa tarjeta (`code_invalid`), ya venció (`code_expired`) o ya se usó en un canje (`code_reused`).
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /cards/{id}/code — Código vigente de una tarjeta

Devuelve el código rotativo vigente de la tarjeta en línea y cuántos segundos le quedan, para integraciones que pintan su propia pantalla del cliente. Pídelo de nuevo cuando venza (cada 30 segundos): no lo guardes. El secreto con el que se calcula nunca sale de Pokit.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.

**Respuestas**

- `200` — El código vigente.

```json
{
  "cardId": "cmcrd8f2k0001pokitcard1",
  "memberNumber": "48213907",
  "code": "48213907512904",
  "formatted": "4821 3907 · 512 904",
  "expiresInSeconds": 23
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `409` — La plantilla de la tarjeta no tiene el código rotativo encendido (`code_not_enabled`) o la tarjeta está revocada (`card_revoked`).
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /notices — Enviar aviso a varios clientes

Manda el mismo aviso a la pantalla de bloqueo de varios clientes. Indica exactamente un destino: `cardIds` (hasta 500 tarjetas), `templateId` (todas las tarjetas activas de esa plantilla) o `all: true` (todas las del negocio). Las tarjetas revocadas se saltan. Máximo 6 envíos a varias tarjetas por hora.

**Cuerpo (application/json)** (required)

```json
{
  "type": "object",
  "required": [
    "message"
  ],
  "properties": {
    "message": {
      "type": "string",
      "minLength": 2,
      "maxLength": 120
    },
    "cardIds": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "maxItems": 500
    },
    "templateId": {
      "type": "string"
    },
    "all": {
      "type": "boolean",
      "enum": [
        true
      ]
    }
  }
}
```

Ejemplo:

```json
{
  "message": "Hoy cerramos a las 6 pm por evento privado",
  "all": true
}
```

**Respuestas**

- `200` — Aviso enviado; `sent` es a cuántas tarjetas llegó.

```json
{
  "sent": 124,
  "androidSilent": 2
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — Ninguna tarjeta activa coincide con el destino (`no_recipients`).
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /cards/{id}/notify — Enviar aviso al cliente

Manda un aviso a la pantalla de bloqueo del cliente a través de su pase en Wallet: «Tu pedido #45 está listo». No mueve saldo. `status` y `orderId` son opcionales y quedan en el historial de la tarjeta (`kind: notice`). Máximo 20 avisos por tarjeta por hora. Si repites exactamente el mismo mensaje, se le añade la hora para que Wallet lo vuelva a notificar.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/NoticeInput"
}
```

Ejemplo:

```json
{
  "message": "Tu pedido #45 está listo",
  "status": "listo",
  "orderId": "45"
}
```

**Respuestas**

- `200` — Aviso registrado y enviado al pase.

```json
{
  "cardId": "tz4a98xxat96iws9zmbrgj3a",
  "notice": "Tu pedido #45 está listo",
  "sentAt": "2026-10-07T19:02:11.000Z",
  "androidSilent": false
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `409` — La tarjeta está revocada (`card_revoked`).
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /cards/{id}/events — Historial de movimientos

Cada suma, canje, corrección, vencimiento, aviso o cupón canjeado de la tarjeta, del más reciente al más antiguo, con su motivo y metadata.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.
- `limit` (query, integer) — Máximo de movimientos (1 a 200).

**Respuestas**

- `200` — Movimientos de la tarjeta.

```json
{
  "data": [
    {
      "id": "cmevt7h3q0009pokitevt1",
      "field": "points",
      "kind": "earn",
      "delta": 10,
      "balanceAfter": 130,
      "reason": "Compra en sucursal Centro",
      "metadata": {
        "ticket": "A-10482"
      },
      "source": "api",
      "createdAt": "2026-07-18T09:12:00.000Z"
    }
  ]
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /promotions — Listar promociones

Las promociones de tu negocio, de la más reciente a la más antigua. `live` dice si está aplicada ahora mismo en los pases; `when` resume su horario en una frase.

**Respuestas**

- `200` — Lista de promociones.

```json
[
  {
    "id": "cmprm3k8d0005pokitprom1",
    "name": "Miércoles de 2×1",
    "enabled": true,
    "live": false,
    "when": "Cada miércoles de 13:00 a 17:00",
    "schedule": {
      "mode": "weekly",
      "days": [
        3
      ],
      "from": "13:00",
      "to": "17:00",
      "until": null
    },
    "text": "2×1 en rollos toda la tarde",
    "message": "Hoy 2×1 en rollos, de 1 a 5 de la tarde",
    "notify": "first",
    "templateIds": [],
    "backgroundColor": null,
    "foregroundColor": null,
    "labelColor": null,
    "hasPhoto": false,
    "createdAt": "2026-10-01T16:00:00.000Z",
    "updatedAt": "2026-10-01T16:00:00.000Z"
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /promotions — Crear promoción

Crea una promoción que cambia temporalmente los pases: un texto en el frente (`text`), los colores y, si quieres, un aviso en la pantalla de bloqueo (`notify` + `message`). `schedule` decide cuándo está en vivo; las horas se leen en la zona horaria del negocio. Debe cambiar algo: `text`, `message` o colores. La foto de la promoción sólo se sube desde el panel.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/PromotionInput"
}
```

Ejemplo:

```json
{
  "name": "Miércoles de 2×1",
  "text": "2×1 en rollos toda la tarde",
  "message": "Hoy 2×1 en rollos, de 1 a 5 de la tarde",
  "notify": "first",
  "schedule": {
    "mode": "weekly",
    "days": [
      3
    ],
    "from": "13:00",
    "to": "17:00"
  }
}
```

**Respuestas**

- `201` — Promoción creada.

```json
{
  "id": "cmprm3k8d0005pokitprom1",
  "name": "Miércoles de 2×1",
  "enabled": true,
  "live": false,
  "when": "Cada miércoles de 13:00 a 17:00",
  "schedule": {
    "mode": "weekly",
    "days": [
      3
    ],
    "from": "13:00",
    "to": "17:00",
    "until": null
  },
  "text": "2×1 en rollos toda la tarde",
  "message": "Hoy 2×1 en rollos, de 1 a 5 de la tarde",
  "notify": "first",
  "templateIds": [],
  "backgroundColor": null,
  "foregroundColor": null,
  "labelColor": null,
  "hasPhoto": false,
  "createdAt": "2026-10-01T16:00:00.000Z",
  "updatedAt": "2026-10-01T16:00:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `422` — Body inválido, `templateIds` con una plantilla ajena, o una promoción que no cambia nada.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /promotions/{id} — Obtener promoción

**Parámetros**

- `id` (path, string, required) — ID de la promoción.

**Respuestas**

- `200` — La promoción.

```json
{
  "id": "cmprm3k8d0005pokitprom1",
  "name": "Miércoles de 2×1",
  "enabled": true,
  "live": false,
  "when": "Cada miércoles de 13:00 a 17:00",
  "schedule": {
    "mode": "weekly",
    "days": [
      3
    ],
    "from": "13:00",
    "to": "17:00",
    "until": null
  },
  "text": "2×1 en rollos toda la tarde",
  "message": "Hoy 2×1 en rollos, de 1 a 5 de la tarde",
  "notify": "first",
  "templateIds": [],
  "backgroundColor": null,
  "foregroundColor": null,
  "labelColor": null,
  "hasPhoto": false,
  "createdAt": "2026-10-01T16:00:00.000Z",
  "updatedAt": "2026-10-01T16:00:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### PATCH /promotions/{id} — Editar promoción

Cambia sólo los campos enviados; el resultado se valida completo. Sirve para encender o apagar (`enabled`), mover el horario o cambiar el texto. Si la promoción está en vivo, los pases se actualizan de inmediato.

**Parámetros**

- `id` (path, string, required) — ID de la promoción.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/PromotionInput"
}
```

Ejemplo:

```json
{
  "enabled": false
}
```

**Respuestas**

- `200` — Promoción actualizada.

```json
{
  "id": "cmprm3k8d0005pokitprom1",
  "name": "Miércoles de 2×1",
  "enabled": false,
  "live": false,
  "when": "Cada miércoles de 13:00 a 17:00",
  "schedule": {
    "mode": "weekly",
    "days": [
      3
    ],
    "from": "13:00",
    "to": "17:00",
    "until": null
  },
  "text": "2×1 en rollos toda la tarde",
  "message": "Hoy 2×1 en rollos, de 1 a 5 de la tarde",
  "notify": "first",
  "templateIds": [],
  "backgroundColor": null,
  "foregroundColor": null,
  "labelColor": null,
  "hasPhoto": false,
  "createdAt": "2026-10-01T16:00:00.000Z",
  "updatedAt": "2026-10-01T16:00:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### DELETE /promotions/{id} — Eliminar promoción

Si estaba en vivo, los pases vuelven a su diseño normal.

**Parámetros**

- `id` (path, string, required) — ID de la promoción.

**Respuestas**

- `204` — Eliminado. Sin cuerpo.
- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /coupons — Listar cupones

Los cupones de tu negocio con sus canjes (`redemptions`) y su estado: `active`, `scheduled` (aún no empieza), `expired`, `exhausted` (se acabaron los usos totales) u `off` (apagado).

**Respuestas**

- `200` — Lista de cupones.

```json
[
  {
    "id": "cmcpn9v2r0006pokitcpn01",
    "name": "Postre gratis",
    "description": "En consumo mínimo de $200. No acumulable con otras promociones.",
    "enabled": true,
    "status": "active",
    "summary": "Para todos los clientes · 1 uso por cliente · 100 en total",
    "audience": "all",
    "validDays": 30,
    "templateIds": [],
    "startsAt": "2026-10-01",
    "endsAt": "2026-10-31",
    "hours": {
      "days": [
        1,
        2,
        3,
        4,
        5
      ],
      "from": "13:00",
      "to": "17:00"
    },
    "maxPerCustomer": 1,
    "maxTotal": 100,
    "notify": true,
    "redemptions": 12,
    "createdAt": "2026-10-01T16:00:00.000Z",
    "updatedAt": "2026-10-01T16:00:00.000Z"
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /coupons — Crear cupón

Un cupón es un beneficio en texto («Postre gratis») que aparece dentro de la tarjeta del cliente: en su pase de Wallet y en su página. Al canjearlo sólo se marca como usado; el beneficio lo aplica quien atiende. `audience` decide quién lo recibe: `all` (todas las tarjetas), `selected` (a quien se lo des con `/coupons/{id}/grant`), `welcome` (clientes nuevos, `validDays` días desde el alta) o `birthday` (`validDays` días desde el cumpleaños, cada año; requiere una pregunta de cumpleaños en el formulario de alta). Todos los límites son opcionales. Con `notify: true` el cliente recibe un aviso en su pantalla de bloqueo al recibirlo (no aplica a `welcome`).

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/CouponInput"
}
```

Ejemplo:

```json
{
  "name": "Postre gratis",
  "description": "En consumo mínimo de $200. No acumulable con otras promociones.",
  "audience": "all",
  "startsAt": "2026-10-01",
  "endsAt": "2026-10-31",
  "hours": {
    "days": [
      1,
      2,
      3,
      4,
      5
    ],
    "from": "13:00",
    "to": "17:00"
  },
  "maxPerCustomer": 1,
  "maxTotal": 100,
  "notify": true
}
```

**Respuestas**

- `201` — Cupón creado.

```json
{
  "id": "cmcpn9v2r0006pokitcpn01",
  "name": "Postre gratis",
  "description": "En consumo mínimo de $200. No acumulable con otras promociones.",
  "enabled": true,
  "status": "active",
  "summary": "Para todos los clientes · 1 uso por cliente · 100 en total",
  "audience": "all",
  "validDays": 30,
  "templateIds": [],
  "startsAt": "2026-10-01",
  "endsAt": "2026-10-31",
  "hours": {
    "days": [
      1,
      2,
      3,
      4,
      5
    ],
    "from": "13:00",
    "to": "17:00"
  },
  "maxPerCustomer": 1,
  "maxTotal": 100,
  "notify": true,
  "redemptions": 0,
  "createdAt": "2026-10-01T16:00:00.000Z",
  "updatedAt": "2026-10-01T16:00:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `422` — Body inválido o `templateIds` con una plantilla ajena.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /coupons/{id} — Obtener cupón

**Parámetros**

- `id` (path, string, required) — ID del cupón.

**Respuestas**

- `200` — El cupón.

```json
{
  "id": "cmcpn9v2r0006pokitcpn01",
  "name": "Postre gratis",
  "description": "En consumo mínimo de $200. No acumulable con otras promociones.",
  "enabled": true,
  "status": "active",
  "summary": "Para todos los clientes · 1 uso por cliente · 100 en total",
  "audience": "all",
  "validDays": 30,
  "templateIds": [],
  "startsAt": "2026-10-01",
  "endsAt": "2026-10-31",
  "hours": {
    "days": [
      1,
      2,
      3,
      4,
      5
    ],
    "from": "13:00",
    "to": "17:00"
  },
  "maxPerCustomer": 1,
  "maxTotal": 100,
  "notify": true,
  "redemptions": 12,
  "createdAt": "2026-10-01T16:00:00.000Z",
  "updatedAt": "2026-10-01T16:00:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### PATCH /coupons/{id} — Editar cupón

Cambia sólo los campos enviados; el resultado se valida completo. Los pases afectados se actualizan de inmediato. Para quitar un límite manda `null` (p. ej. `{ "maxTotal": null }`).

**Parámetros**

- `id` (path, string, required) — ID del cupón.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/CouponInput"
}
```

Ejemplo:

```json
{
  "endsAt": "2026-11-15",
  "maxTotal": 200
}
```

**Respuestas**

- `200` — Cupón actualizado.

```json
{
  "id": "cmcpn9v2r0006pokitcpn01",
  "name": "Postre gratis",
  "description": "En consumo mínimo de $200. No acumulable con otras promociones.",
  "enabled": true,
  "status": "active",
  "summary": "Para todos los clientes · 1 uso por cliente · 200 en total",
  "audience": "all",
  "validDays": 30,
  "templateIds": [],
  "startsAt": "2026-10-01",
  "endsAt": "2026-11-15",
  "hours": {
    "days": [
      1,
      2,
      3,
      4,
      5
    ],
    "from": "13:00",
    "to": "17:00"
  },
  "maxPerCustomer": 1,
  "maxTotal": 200,
  "notify": true,
  "redemptions": 12,
  "createdAt": "2026-10-01T16:00:00.000Z",
  "updatedAt": "2026-10-01T16:00:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### DELETE /coupons/{id} — Eliminar cupón

Desaparece de los pases. Se eliminan sus otorgamientos y canjes; el historial de cada tarjeta conserva los suyos.

**Parámetros**

- `id` (path, string, required) — ID del cupón.

**Respuestas**

- `204` — Eliminado. Sin cuerpo.
- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /coupons/{id}/grant — Dar un cupón a una tarjeta

Le da a una tarjeta un cupón de audiencia `selected`. Si el cupón tiene `notify`, el cliente recibe el aviso «Tienes un cupón: …». Es idempotente: si ya lo tenía responde `created: false` y no vuelve a avisar.

**Parámetros**

- `id` (path, string, required) — ID del cupón.

**Cuerpo (application/json)** (required)

```json
{
  "type": "object",
  "required": [
    "cardId"
  ],
  "properties": {
    "cardId": {
      "type": "string"
    }
  }
}
```

Ejemplo:

```json
{
  "cardId": "cmcrd8f2k0001pokitcard1"
}
```

**Respuestas**

- `200` — Cupón otorgado (o ya lo tenía).

```json
{
  "couponId": "cmcpn9v2r0006pokitcpn01",
  "cardId": "cmcrd8f2k0001pokitcard1",
  "name": "Postre gratis",
  "created": true
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El cupón o la tarjeta no existen o no pertenecen a tu negocio.
- `409` — El cupón no es `selected` (`coupon_not_grantable`), no le sirve a esa tarjeta porque está apagado, vencido, agotado o es de otra plantilla (`coupon_not_available`), o la tarjeta está revocada (`card_revoked`).
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /cards/{id}/coupons — Cupones de una tarjeta

Los cupones que tiene este cliente ahora mismo, con su estado: `redeemable` (se puede canjear ya), `off_hours` (lo tiene, pero no es su día u hora), `exhausted` (ya lo usó o se agotó) o `expired`. Los que no le corresponden no aparecen. `validity` es el texto de vigencia que ve el cliente.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.

**Respuestas**

- `200` — Cupones de la tarjeta.

```json
[
  {
    "id": "cmcpn9v2r0006pokitcpn01",
    "name": "Postre gratis",
    "description": "En consumo mínimo de $200. No acumulable con otras promociones.",
    "audience": "all",
    "status": "redeemable",
    "validity": "Cada lunes, martes, miércoles, jueves y viernes de 13:00 a 17:00 · Hasta el 31 oct",
    "used": 0,
    "maxPerCustomer": 1
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /cards/{id}/coupons/{couponId}/redeem — Canjear un cupón

Marca el cupón como usado por esta tarjeta. No se puede deshacer. El cuerpo es opcional: sólo lleva `code` (el código rotativo que enseñó el cliente) si la plantilla lo pide. Queda en el historial de la tarjeta (`kind: coupon`) y el cupón desaparece de su pase cuando llega a su límite. Dos canjes simultáneos nunca se saltan `maxPerCustomer` ni `maxTotal`.

**Parámetros**

- `id` (path, string, required) — ID de la tarjeta.
- `couponId` (path, string, required) — ID del cupón.

**Cuerpo (application/json)**

```json
{
  "type": "object",
  "properties": {
    "code": {
      "type": "string",
      "description": "Código rotativo que enseñó el cliente: los 14 dígitos de su tarjeta en línea o de su pase (se aceptan espacios, puntos y guiones). Sólo cuenta si la plantilla tiene el código rotativo encendido y lo pide para esta acción; en el resto se ignora. Un código de la tarjeta en línea sirve para un solo canje."
    },
    "branchId": {
      "type": "string",
      "description": "Opcional. ID de la sucursal donde ocurrió el movimiento (ver `GET /branches`). Queda en el historial y en las estadísticas por sucursal, y se vuelve la sucursal de referencia de la tarjeta: si su plantilla lleva al pase las sucursales más cercanas (`branchPass: nearest`), el pase se actualiza con las 10 más cercanas a ésta. Si no es de tu negocio responde 422."
    }
  }
}
```

Ejemplo:

```json
{
  "code": "48213907512904",
  "branchId": "cmbrc4k9p0007pokitbrc01"
}
```

**Respuestas**

- `200` — Cupón canjeado.

```json
{
  "couponId": "cmcpn9v2r0006pokitcpn01",
  "cardId": "cmcrd8f2k0001pokitcard1",
  "name": "Postre gratis",
  "redeemedAt": "2026-10-07T19:02:11.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `409` — El cupón no se puede canjear: la tarjeta no lo tiene (`coupon_not_available`), ya se usó (`coupon_exhausted`), venció (`coupon_expired`), no es su horario (`coupon_off_hours`) o la tarjeta está revocada (`card_revoked`). Si la plantilla exige el código rotativo: falta `code` (`code_required`), no es de esta tarjeta (`code_invalid`), ya venció (`code_expired`) o ya se usó en otro canje (`code_reused`).
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /webhooks — Listar URLs de webhook

Las URLs a las que Pokit envía eventos de tu negocio, con los eventos que recibe cada una. No incluye el `secret`: pídelo con `GET /webhooks/{id}`.

**Respuestas**

- `200` — Lista de URLs.

```json
[
  {
    "id": "cmwhk2b7c0001pokitwhk01",
    "url": "https://pos.cafearoma.mx/webhooks/pokit",
    "description": "Punto de venta de Sucursal Centro",
    "events": [
      "balance.earned",
      "balance.redeemed",
      "card.created"
    ],
    "enabled": true,
    "createdAt": "2026-10-15T16:20:00.000Z",
    "updatedAt": "2026-10-15T16:20:00.000Z"
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /webhooks — Crear URL de webhook

Registra una URL que recibirá un POST por cada evento elegido en `events`. La URL debe ser `https://` y pública: se rechazan las que resuelven a redes privadas, loopback, link-local o metadatos de la nube. La respuesta trae el `secret` (`whsec_…`) con el que se firma cada envío.

**Firma.** Cada envío lleva `Pokit-Signature: t=<unix>,v1=<firma>`, donde la firma es el HMAC-SHA256 en hexadecimal de `<t>.<cuerpo crudo>` con el `secret`. Verifícala comparando en tiempo constante y rechaza las de más de 5 minutos. También llegan `Pokit-Event` (el tipo) y `Pokit-Delivery` (el id de la entrega).

**Entrega.** Responde 2xx en menos de 10 s. Cualquier otra respuesta, un timeout o una redirección (no se siguen) es un fallo y se reintenta a los 1 min, 5 min, 30 min, 2 h, 6 h, 12 h y 24 h; luego la entrega queda `failed`. Un evento puede llegar más de una vez: usa su `id` para descartar repetidos. Máximo 10 URLs por negocio.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/WebhookEndpointInput"
}
```

Ejemplo:

```json
{
  "url": "https://pos.cafearoma.mx/webhooks/pokit",
  "description": "Punto de venta de Sucursal Centro",
  "events": [
    "balance.earned",
    "balance.redeemed",
    "card.created"
  ]
}
```

**Respuestas**

- `201` — URL creada, con su `secret`.

```json
{
  "id": "cmwhk2b7c0001pokitwhk01",
  "url": "https://pos.cafearoma.mx/webhooks/pokit",
  "description": "Punto de venta de Sucursal Centro",
  "events": [
    "balance.earned",
    "balance.redeemed",
    "card.created"
  ],
  "enabled": true,
  "createdAt": "2026-10-15T16:20:00.000Z",
  "updatedAt": "2026-10-15T16:20:00.000Z",
  "secret": "whsec_2f8a6c1e9b0d4a7f3c5e8b1d6a9f0c2e4b7d1a3c5e8f0b2d"
}
```

- `401` — API key faltante, malformada o revocada.
- `409` — Ya tienes el máximo de 10 URLs.
- `422` — Body inválido, evento desconocido o URL no permitida (no es https o apunta a una red interna).
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /webhooks/{id} — Obtener URL de webhook

La URL con su `secret` vigente, para configurar la verificación de la firma.

**Parámetros**

- `id` (path, string, required) — ID de la URL de webhook.

**Respuestas**

- `200` — La URL con su `secret`.

```json
{
  "id": "cmwhk2b7c0001pokitwhk01",
  "url": "https://pos.cafearoma.mx/webhooks/pokit",
  "description": "Punto de venta de Sucursal Centro",
  "events": [
    "balance.earned",
    "balance.redeemed",
    "card.created"
  ],
  "enabled": true,
  "createdAt": "2026-10-15T16:20:00.000Z",
  "updatedAt": "2026-10-15T16:20:00.000Z",
  "secret": "whsec_2f8a6c1e9b0d4a7f3c5e8b1d6a9f0c2e4b7d1a3c5e8f0b2d"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### PATCH /webhooks/{id} — Editar URL de webhook

Cambia sólo los campos enviados; el resultado se valida completo. Con `enabled: false` deja de recibir eventos nuevos (los reintentos pendientes se marcan `failed`).

**Parámetros**

- `id` (path, string, required) — ID de la URL de webhook.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/WebhookEndpointInput"
}
```

Ejemplo:

```json
{
  "events": [
    "balance.earned",
    "balance.redeemed",
    "coupon.redeemed"
  ],
  "enabled": true
}
```

**Respuestas**

- `200` — URL actualizada.

```json
{
  "id": "cmwhk2b7c0001pokitwhk01",
  "url": "https://pos.cafearoma.mx/webhooks/pokit",
  "description": "Punto de venta de Sucursal Centro",
  "events": [
    "balance.earned",
    "balance.redeemed",
    "coupon.redeemed"
  ],
  "enabled": true,
  "createdAt": "2026-10-15T16:20:00.000Z",
  "updatedAt": "2026-10-15T16:20:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### DELETE /webhooks/{id} — Eliminar URL de webhook

Deja de recibir eventos y se borra su bitácora de entregas.

**Parámetros**

- `id` (path, string, required) — ID de la URL de webhook.

**Respuestas**

- `204` — Eliminado. Sin cuerpo.
- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /webhooks/{id}/test — Enviar evento de prueba

Envía un evento `webhook.test` firmado a la URL (aunque esté en pausa) y responde con el resultado de ese intento. La prueba no se reintenta. Sin cuerpo.

**Parámetros**

- `id` (path, string, required) — ID de la URL de webhook.

**Respuestas**

- `200` — Resultado de la prueba: `delivered` si respondió 2xx; si no, `failed` con el error.

```json
{
  "id": "cmdlv3n8q0001pokitdlvr1",
  "endpointId": "cmwhk2b7c0001pokitwhk01",
  "eventId": "evt_5f2c9a1e7b3d4c6f8a0b1c2d",
  "type": "webhook.test",
  "status": "delivered",
  "attempts": 1,
  "lastStatus": 200,
  "lastError": null,
  "nextAttemptAt": null,
  "deliveredAt": "2026-10-15T18:42:07.480Z",
  "createdAt": "2026-10-15T18:42:07.120Z",
  "payload": {
    "id": "evt_9a0b1c2d3e4f5a6b7c8d9e0f",
    "type": "webhook.test",
    "createdAt": "2026-10-15T18:42:07.120Z",
    "businessId": "cmbiz7k2p0001pokitbizn1",
    "data": {
      "message": "Prueba de Pokit: si ves esto, tu URL recibe y verifica eventos.",
      "endpointId": "cmwhk2b7c0001pokitwhk01"
    }
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /webhooks/{id}/rotate-secret — Rotar el secret

Genera un `secret` nuevo. El anterior deja de valer en ese momento: todo lo que salga desde ahora, incluidos los reintentos pendientes, va firmado con el nuevo. Sin cuerpo.

**Parámetros**

- `id` (path, string, required) — ID de la URL de webhook.

**Respuestas**

- `200` — El `secret` nuevo.

```json
{
  "id": "cmwhk2b7c0001pokitwhk01",
  "secret": "whsec_2f8a6c1e9b0d4a7f3c5e8b1d6a9f0c2e4b7d1a3c5e8f0b2d"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /webhooks/{id}/deliveries — Listar entregas

La bitácora de entregas de la URL, de la más reciente a la más antigua: una por evento, con su estado (`pending` mientras reintenta, `delivered`, `failed`), intentos, último código HTTP y error. Se guardan 30 días.

**Parámetros**

- `id` (path, string, required) — ID de la URL de webhook.
- `status` (query, string) — Filtra por estado.
- `limit` (query, integer) — Cuántas devolver (1 a 200, por defecto 50).

**Respuestas**

- `200` — Entregas de la URL.

```json
[
  {
    "id": "cmdlv3n8q0001pokitdlvr1",
    "endpointId": "cmwhk2b7c0001pokitwhk01",
    "eventId": "evt_5f2c9a1e7b3d4c6f8a0b1c2d",
    "type": "balance.earned",
    "status": "pending",
    "attempts": 2,
    "lastStatus": 503,
    "lastError": "HTTP 503: Service Unavailable",
    "nextAttemptAt": "2026-10-15T18:52:07.000Z",
    "deliveredAt": null,
    "createdAt": "2026-10-15T18:42:07.120Z",
    "payload": {
      "id": "evt_5f2c9a1e7b3d4c6f8a0b1c2d",
      "type": "balance.earned",
      "createdAt": "2026-10-15T18:42:07.120Z",
      "businessId": "cmbiz7k2p0001pokitbizn1",
      "data": {
        "card": {
          "id": "cmcrd8f2k0001pokitcard1"
        },
        "field": "points",
        "delta": 10,
        "balance": 130
      }
    }
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /webhooks/{id}/deliveries/{deliveryId}/retry — Reintentar una entrega

Intenta la entrega ahora (también una ya entregada, para reenviarla) y responde con el resultado. Si falla, sigue el calendario de reintentos donde iba; si ya lo había agotado, vuelve a quedar `failed`. Sin cuerpo.

**Parámetros**

- `id` (path, string, required) — ID de la URL de webhook.
- `deliveryId` (path, string, required) — ID de la entrega.

**Respuestas**

- `200` — La entrega tras el intento.

```json
{
  "id": "cmdlv3n8q0001pokitdlvr1",
  "endpointId": "cmwhk2b7c0001pokitwhk01",
  "eventId": "evt_5f2c9a1e7b3d4c6f8a0b1c2d",
  "type": "balance.earned",
  "status": "delivered",
  "attempts": 3,
  "lastStatus": 200,
  "lastError": null,
  "nextAttemptAt": null,
  "deliveredAt": "2026-10-15T19:01:12.000Z",
  "createdAt": "2026-10-15T18:42:07.120Z",
  "payload": {
    "id": "evt_5f2c9a1e7b3d4c6f8a0b1c2d",
    "type": "balance.earned",
    "createdAt": "2026-10-15T18:42:07.120Z",
    "businessId": "cmbiz7k2p0001pokitbizn1",
    "data": {
      "card": {
        "id": "cmcrd8f2k0001pokitcard1"
      },
      "field": "points",
      "delta": 10,
      "balance": 130
    }
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — La entrega no existe, no es de esa URL o ya se borró (se guardan 30 días).
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /branches — Listar sucursales

Las sucursales de tu negocio, activas o no, en el orden en que se dieron de alta. Cada plantilla vale en las sucursales que elijas en el panel (o en todas) y lleva hasta 10 a su pase de Wallet: el pase aparece en la pantalla de bloqueo del cliente cuando está cerca de una de ellas.

**Respuestas**

- `200` — Lista de sucursales.

```json
[
  {
    "id": "cmbrc4k9p0007pokitbrc01",
    "name": "Okuma Polanco",
    "address": "Av. Presidente Masaryk 120, Polanco, CDMX",
    "latitude": 19.4326,
    "longitude": -99.1937,
    "relevantText": "¡Estás cerca de Okuma Polanco! Muestra tu tarjeta.",
    "active": true,
    "createdAt": "2026-09-02T16:20:00.000Z"
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /branches — Crear sucursal

Da de alta una sucursal: un punto en el mapa con el mensaje que verá el cliente en su pantalla de bloqueo al acercarse. Las plantillas que valen en todas las sucursales la incluyen en sus pases de inmediato; a las demás se agrega desde el panel.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/BranchInput"
}
```

Ejemplo:

```json
{
  "name": "Okuma Polanco",
  "address": "Av. Presidente Masaryk 120, Polanco, CDMX",
  "latitude": 19.4326,
  "longitude": -99.1937,
  "relevantText": "¡Estás cerca de Okuma Polanco! Muestra tu tarjeta."
}
```

**Respuestas**

- `201` — Sucursal creada.

```json
{
  "id": "cmbrc4k9p0007pokitbrc01",
  "name": "Okuma Polanco",
  "address": "Av. Presidente Masaryk 120, Polanco, CDMX",
  "latitude": 19.4326,
  "longitude": -99.1937,
  "relevantText": "¡Estás cerca de Okuma Polanco! Muestra tu tarjeta.",
  "active": true,
  "createdAt": "2026-09-02T16:20:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `422` — Body inválido: falta el nombre o las coordenadas están fuera de rango.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /branches/{id} — Obtener sucursal

**Parámetros**

- `id` (path, string, required) — ID de la sucursal.

**Respuestas**

- `200` — La sucursal.

```json
{
  "id": "cmbrc4k9p0007pokitbrc01",
  "name": "Okuma Polanco",
  "address": "Av. Presidente Masaryk 120, Polanco, CDMX",
  "latitude": 19.4326,
  "longitude": -99.1937,
  "relevantText": "¡Estás cerca de Okuma Polanco! Muestra tu tarjeta.",
  "active": true,
  "createdAt": "2026-09-02T16:20:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### PATCH /branches/{id} — Editar sucursal

Cambia sólo los campos enviados. Con `active: false` la sucursal deja de aparecer en los pases y en la Caja, pero conserva su historial y sus estadísticas: es lo recomendable para una sucursal que cerró. Los pases ya instalados se actualizan solos.

**Parámetros**

- `id` (path, string, required) — ID de la sucursal.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/BranchPatch"
}
```

Ejemplo:

```json
{
  "relevantText": "Nuevo horario: abrimos a las 7 am",
  "active": true
}
```

**Respuestas**

- `200` — Sucursal actualizada.

```json
{
  "id": "cmbrc4k9p0007pokitbrc01",
  "name": "Okuma Polanco",
  "address": "Av. Presidente Masaryk 120, Polanco, CDMX",
  "latitude": 19.4326,
  "longitude": -99.1937,
  "relevantText": "Nuevo horario: abrimos a las 7 am",
  "active": true,
  "createdAt": "2026-09-02T16:20:00.000Z"
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `422` — Body vacío o con valores inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### DELETE /branches/{id} — Borrar sucursal

Borra la sucursal para siempre. Sus movimientos se conservan, pero sin sucursal: si quieres seguir viéndola en las estadísticas, mejor desactívala con `PATCH` y `active: false`.

**Parámetros**

- `id` (path, string, required) — ID de la sucursal.

**Respuestas**

- `204` — Eliminado. Sin cuerpo.
- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /customers — Listar clientes

Lista paginada de los perfiles de cliente de tu negocio. Un perfil une todas las tarjetas de una persona (de distintas plantillas): las tarjetas nuevas con el mismo teléfono se ligan solas, y desde el panel o con `/merge` y `/split` se unen o separan a mano. Los más recientes (por última visita) van primero. Cada fila trae cuántas tarjetas tiene, sus visitas (movimientos `earn`) y su última visita.

**Parámetros**

- `q` (query, string) — Busca por nombre, correo, teléfono (basta con los últimos dígitos), número de socio (8 dígitos) o serial de cualquiera de sus tarjetas.
- `page` (query, integer) — Página (desde 1).
- `limit` (query, integer) — Resultados por página (máx. 100).

**Respuestas**

- `200` — Página de clientes.

```json
{
  "data": [
    {
      "id": "cmcus3k8p0005pokitcust1",
      "name": "María González",
      "email": "maria@ejemplo.com",
      "phone": "5512345678",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-07-18T09:12:00.000Z",
      "cards": 2,
      "visits": 14,
      "lastVisit": "2026-10-12T19:04:00.000Z"
    }
  ],
  "page": 1,
  "limit": 20,
  "total": 87
}
```

- `401` — API key faltante, malformada o revocada.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /customers/{id} — Obtener cliente

El perfil con todas sus tarjetas y sus métricas: visitas, última visita, visitas al mes (últimos 90 días), sucursal favorita, cupones canjeados y ticket promedio. `favoriteBranch` es `null` si sus movimientos no traen sucursal; `averageTicket` es `null` si tu caja no manda `metadata.total` numérico al sumar.

**Parámetros**

- `id` (path, string, required) — ID del perfil del cliente (el `customerId` de sus tarjetas).

**Respuestas**

- `200` — El perfil.

```json
{
  "id": "cmcus3k8p0005pokitcust1",
  "name": "María González",
  "email": "maria@ejemplo.com",
  "phone": "5512345678",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z",
  "cards": [
    {
      "id": "cmcrd8f2k0001pokitcard1",
      "serial": "cmsrl4x9m0002pokitserl1",
      "memberNumber": "48213907",
      "templateId": "cmtpl1a2b0003pokittmpl1",
      "customerId": "cmcus3k8p0005pokitcust1",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {},
      "points": 120,
      "stamps": 0,
      "status": "ACTIVE",
      "lastActivityAt": "2026-10-12T19:04:00.000Z",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-10-12T19:04:00.000Z",
      "templateName": "Café Club"
    },
    {
      "id": "cmcrd9a1b0007pokitcard2",
      "serial": "cmsrl5y0n0008pokitserl2",
      "memberNumber": "71592036",
      "templateId": "cmtpl2c3d0009pokittmpl2",
      "customerId": "cmcus3k8p0005pokitcust1",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {},
      "points": 0,
      "stamps": 6,
      "status": "ACTIVE",
      "lastActivityAt": "2026-10-12T19:04:00.000Z",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-10-12T19:04:00.000Z",
      "templateName": "Sellos de pan"
    }
  ],
  "metrics": {
    "visits": 14,
    "lastVisit": "2026-10-12T19:04:00.000Z",
    "visitsPerMonth": 3.3,
    "favoriteBranch": {
      "id": "cmbrn2h6t0006pokitbrnc1",
      "name": "Sucursal Centro",
      "visits": 11
    },
    "couponsRedeemed": 2,
    "averageTicket": 186.5
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El perfil no existe o no pertenece a tu negocio.
- `429` — Límite de 120 solicitudes por minuto excedido.

### PATCH /customers/{id} — Editar cliente

Cambia los datos de contacto del perfil. Sólo cambian los campos enviados; `null` o una cadena vacía borran el correo o el teléfono. El nombre y el correo de cada tarjeta (lo que muestra el pase) no cambian. El correo se guarda en minúsculas y el teléfono sólo con dígitos.

**Parámetros**

- `id` (path, string, required) — ID del perfil del cliente.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/CustomerInput"
}
```

Ejemplo:

```json
{
  "name": "María González Ruiz",
  "phone": "+52 55 1234 5678"
}
```

**Respuestas**

- `200` — Perfil actualizado, con sus tarjetas y métricas.

```json
{
  "id": "cmcus3k8p0005pokitcust1",
  "name": "María González Ruiz",
  "email": "maria@ejemplo.com",
  "phone": "+525512345678",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z",
  "cards": [
    {
      "id": "cmcrd8f2k0001pokitcard1",
      "serial": "cmsrl4x9m0002pokitserl1",
      "memberNumber": "48213907",
      "templateId": "cmtpl1a2b0003pokittmpl1",
      "customerId": "cmcus3k8p0005pokitcust1",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {},
      "points": 120,
      "stamps": 0,
      "status": "ACTIVE",
      "lastActivityAt": "2026-10-12T19:04:00.000Z",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-10-12T19:04:00.000Z",
      "templateName": "Café Club"
    },
    {
      "id": "cmcrd9a1b0007pokitcard2",
      "serial": "cmsrl5y0n0008pokitserl2",
      "memberNumber": "71592036",
      "templateId": "cmtpl2c3d0009pokittmpl2",
      "customerId": "cmcus3k8p0005pokitcust1",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {},
      "points": 0,
      "stamps": 6,
      "status": "ACTIVE",
      "lastActivityAt": "2026-10-12T19:04:00.000Z",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-10-12T19:04:00.000Z",
      "templateName": "Sellos de pan"
    }
  ],
  "metrics": {
    "visits": 14,
    "lastVisit": "2026-10-12T19:04:00.000Z",
    "visitsPerMonth": 3.3,
    "favoriteBranch": {
      "id": "cmbrn2h6t0006pokitbrnc1",
      "name": "Sucursal Centro",
      "visits": 11
    },
    "couponsRedeemed": 2,
    "averageTicket": 186.5
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El perfil no existe o no pertenece a tu negocio.
- `422` — Body inválido o con campos desconocidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /customers/{id}/merge — Unir clientes

Funde el perfil `customerId` en éste (`{id}`): sus tarjetas pasan a este perfil, los datos vacíos de éste se completan con los del otro y el otro perfil se borra. No se puede deshacer de golpe: para revertirlo, separa las tarjetas con `/split`. Ambos perfiles deben ser de tu negocio.

**Parámetros**

- `id` (path, string, required) — ID del perfil que se queda.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/MergeCustomerInput"
}
```

Ejemplo:

```json
{
  "customerId": "cmcus7z2q0010pokitcust2"
}
```

**Respuestas**

- `200` — Perfil resultante, con todas las tarjetas.

```json
{
  "id": "cmcus3k8p0005pokitcust1",
  "name": "María González",
  "email": "maria@ejemplo.com",
  "phone": "5512345678",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z",
  "cards": [
    {
      "id": "cmcrd8f2k0001pokitcard1",
      "serial": "cmsrl4x9m0002pokitserl1",
      "memberNumber": "48213907",
      "templateId": "cmtpl1a2b0003pokittmpl1",
      "customerId": "cmcus3k8p0005pokitcust1",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {},
      "points": 120,
      "stamps": 0,
      "status": "ACTIVE",
      "lastActivityAt": "2026-10-12T19:04:00.000Z",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-10-12T19:04:00.000Z",
      "templateName": "Café Club"
    },
    {
      "id": "cmcrd9a1b0007pokitcard2",
      "serial": "cmsrl5y0n0008pokitserl2",
      "memberNumber": "71592036",
      "templateId": "cmtpl2c3d0009pokittmpl2",
      "customerId": "cmcus3k8p0005pokitcust1",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {},
      "points": 0,
      "stamps": 6,
      "status": "ACTIVE",
      "lastActivityAt": "2026-10-12T19:04:00.000Z",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-10-12T19:04:00.000Z",
      "templateName": "Sellos de pan"
    }
  ],
  "metrics": {
    "visits": 14,
    "lastVisit": "2026-10-12T19:04:00.000Z",
    "visitsPerMonth": 3.3,
    "favoriteBranch": {
      "id": "cmbrn2h6t0006pokitbrnc1",
      "name": "Sucursal Centro",
      "visits": 11
    },
    "couponsRedeemed": 2,
    "averageTicket": 186.5
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — Alguno de los dos perfiles no existe o no pertenece a tu negocio.
- `422` — Falta `customerId` o es el mismo perfil.
- `429` — Límite de 120 solicitudes por minuto excedido.

### POST /customers/{id}/split — Separar tarjeta

Saca la tarjeta `cardId` de este perfil a un perfil nuevo, con el nombre, correo y teléfono de su alta. Úsalo cuando se unieron dos personas distintas. La tarjeta debe ser de este perfil y el perfil debe tener al menos dos tarjetas. Responde con el perfil nuevo.

**Parámetros**

- `id` (path, string, required) — ID del perfil del que sale la tarjeta.

**Cuerpo (application/json)** (required)

```json
{
  "$ref": "#/components/schemas/SplitCustomerInput"
}
```

Ejemplo:

```json
{
  "cardId": "cmcrd9a1b0007pokitcard2"
}
```

**Respuestas**

- `201` — Perfil nuevo con la tarjeta separada.

```json
{
  "id": "cmcus8w4r0011pokitcust3",
  "name": "María González",
  "email": "maria@ejemplo.com",
  "phone": "5512345678",
  "createdAt": "2026-07-01T15:30:00.000Z",
  "updatedAt": "2026-07-18T09:12:00.000Z",
  "cards": [
    {
      "id": "cmcrd9a1b0007pokitcard2",
      "serial": "cmsrl5y0n0008pokitserl2",
      "memberNumber": "71592036",
      "templateId": "cmtpl2c3d0009pokittmpl2",
      "customerId": "cmcus8w4r0011pokitcust3",
      "homeBranchId": "cmbrn2h6t0006pokitbrnc1",
      "lastBranchId": "cmbrn2h6t0006pokitbrnc1",
      "customerName": "María González",
      "customerEmail": "maria@ejemplo.com",
      "customerPhone": "5512345678",
      "data": {},
      "points": 0,
      "stamps": 6,
      "status": "ACTIVE",
      "lastActivityAt": "2026-10-12T19:04:00.000Z",
      "createdAt": "2026-07-01T15:30:00.000Z",
      "updatedAt": "2026-10-12T19:04:00.000Z",
      "templateName": "Sellos de pan"
    }
  ],
  "metrics": {
    "visits": 6,
    "lastVisit": "2026-10-03T13:20:00.000Z",
    "visitsPerMonth": 1.3,
    "favoriteBranch": null,
    "couponsRedeemed": 0,
    "averageTicket": null
  }
}
```

- `401` — API key faltante, malformada o revocada.
- `404` — El perfil o la tarjeta no existen, o la tarjeta no es de este perfil.
- `409` — Es la única tarjeta del perfil: no hay nada que separar.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

### GET /locations — Listar ubicaciones

Obsoleto: usa `GET /branches` y el campo `branches` de `GET /templates/{id}`. Devuelve las sucursales activas donde vale la plantilla con la forma de antes (`templateId` es el de la plantilla consultada); se mantiene para no romper integraciones.

**Parámetros**

- `templateId` (query, string, required) — ID de la plantilla.

**Respuestas**

- `200` — Sucursales de la plantilla, con la forma de las antiguas ubicaciones.

```json
[
  {
    "id": "cmloc7q1w0004pokitloc01",
    "templateId": "cmtpl1a2b0003pokittmpl1",
    "name": "Sucursal Centro",
    "latitude": 19.4326,
    "longitude": -99.1332,
    "relevantText": "¡Estás cerca de Café Aroma! Muestra tu tarjeta."
  }
]
```

- `401` — API key faltante, malformada o revocada.
- `404` — El recurso no existe o no pertenece a tu negocio.
- `422` — Parámetros o body inválidos.
- `429` — Límite de 120 solicitudes por minuto excedido.

## Esquemas

### PromotionInput

```json
{
  "type": "object",
  "required": [
    "name",
    "schedule"
  ],
  "properties": {
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 60,
      "description": "Nombre interno, para reconocerla."
    },
    "templateIds": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Plantillas a las que aplica. Vacío: todas."
    },
    "text": {
      "type": "string",
      "maxLength": 40,
      "description": "Texto en el frente del pase mientras está en vivo."
    },
    "message": {
      "type": "string",
      "maxLength": 120,
      "description": "Mensaje del aviso. Vacío: se usa `text`."
    },
    "backgroundColor": {
      "type": [
        "string",
        "null"
      ],
      "description": "#rrggbb"
    },
    "foregroundColor": {
      "type": [
        "string",
        "null"
      ],
      "description": "#rrggbb"
    },
    "labelColor": {
      "type": [
        "string",
        "null"
      ],
      "description": "#rrggbb"
    },
    "notify": {
      "type": "string",
      "enum": [
        "never",
        "first",
        "every"
      ],
      "default": "never",
      "description": "`first`: avisa sólo la primera vez que empieza. `every`: cada vez."
    },
    "schedule": {
      "description": "Cuándo está en vivo. Las horas son de reloj de pared en la zona horaria del negocio.",
      "oneOf": [
        {
          "type": "object",
          "required": [
            "mode"
          ],
          "properties": {
            "mode": {
              "const": "manual"
            }
          },
          "description": "En vivo mientras `enabled` sea true."
        },
        {
          "type": "object",
          "required": [
            "mode",
            "start",
            "end"
          ],
          "properties": {
            "mode": {
              "const": "once"
            },
            "start": {
              "type": "string",
              "description": "YYYY-MM-DDTHH:MM"
            },
            "end": {
              "type": "string",
              "description": "YYYY-MM-DDTHH:MM"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "mode",
            "days",
            "from",
            "to"
          ],
          "properties": {
            "mode": {
              "const": "weekly"
            },
            "days": {
              "type": "array",
              "items": {
                "type": "integer",
                "minimum": 0,
                "maximum": 6
              },
              "description": "0 = domingo … 6 = sábado."
            },
            "from": {
              "type": "string",
              "description": "HH:MM"
            },
            "to": {
              "type": "string",
              "description": "HH:MM"
            },
            "until": {
              "type": [
                "string",
                "null"
              ],
              "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
              "description": "Último día (inclusive), opcional."
            }
          }
        }
      ]
    },
    "enabled": {
      "type": "boolean",
      "default": true
    }
  }
}
```

### Promotion

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 60,
      "description": "Nombre interno, para reconocerla."
    },
    "templateIds": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Plantillas a las que aplica. Vacío: todas."
    },
    "text": {
      "type": "string",
      "maxLength": 40,
      "description": "Texto en el frente del pase mientras está en vivo."
    },
    "message": {
      "type": "string",
      "maxLength": 120,
      "description": "Mensaje del aviso. Vacío: se usa `text`."
    },
    "backgroundColor": {
      "type": [
        "string",
        "null"
      ],
      "description": "#rrggbb"
    },
    "foregroundColor": {
      "type": [
        "string",
        "null"
      ],
      "description": "#rrggbb"
    },
    "labelColor": {
      "type": [
        "string",
        "null"
      ],
      "description": "#rrggbb"
    },
    "notify": {
      "type": "string",
      "enum": [
        "never",
        "first",
        "every"
      ],
      "default": "never",
      "description": "`first`: avisa sólo la primera vez que empieza. `every`: cada vez."
    },
    "schedule": {
      "description": "Cuándo está en vivo. Las horas son de reloj de pared en la zona horaria del negocio.",
      "oneOf": [
        {
          "type": "object",
          "required": [
            "mode"
          ],
          "properties": {
            "mode": {
              "const": "manual"
            }
          },
          "description": "En vivo mientras `enabled` sea true."
        },
        {
          "type": "object",
          "required": [
            "mode",
            "start",
            "end"
          ],
          "properties": {
            "mode": {
              "const": "once"
            },
            "start": {
              "type": "string",
              "description": "YYYY-MM-DDTHH:MM"
            },
            "end": {
              "type": "string",
              "description": "YYYY-MM-DDTHH:MM"
            }
          }
        },
        {
          "type": "object",
          "required": [
            "mode",
            "days",
            "from",
            "to"
          ],
          "properties": {
            "mode": {
              "const": "weekly"
            },
            "days": {
              "type": "array",
              "items": {
                "type": "integer",
                "minimum": 0,
                "maximum": 6
              },
              "description": "0 = domingo … 6 = sábado."
            },
            "from": {
              "type": "string",
              "description": "HH:MM"
            },
            "to": {
              "type": "string",
              "description": "HH:MM"
            },
            "until": {
              "type": [
                "string",
                "null"
              ],
              "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
              "description": "Último día (inclusive), opcional."
            }
          }
        }
      ]
    },
    "enabled": {
      "type": "boolean",
      "default": true
    },
    "live": {
      "type": "boolean",
      "description": "Aplicada ahora mismo en los pases."
    },
    "when": {
      "type": "string",
      "description": "El horario en una frase."
    },
    "hasPhoto": {
      "type": "boolean"
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}
```

### CouponInput

```json
{
  "type": "object",
  "required": [
    "name"
  ],
  "properties": {
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 40,
      "description": "El beneficio, como lo ve el cliente."
    },
    "description": {
      "type": "string",
      "maxLength": 200,
      "description": "Condiciones."
    },
    "templateIds": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Plantillas a las que aplica. Vacío: todas."
    },
    "audience": {
      "type": "string",
      "enum": [
        "all",
        "selected",
        "welcome",
        "birthday"
      ],
      "default": "all"
    },
    "validDays": {
      "type": "integer",
      "minimum": 1,
      "maximum": 365,
      "default": 30,
      "description": "Sólo `welcome` y `birthday`: días que dura desde el alta o el cumpleaños."
    },
    "startsAt": {
      "type": [
        "string",
        "null"
      ],
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
      "description": "Fecha local del negocio, YYYY-MM-DD."
    },
    "endsAt": {
      "type": [
        "string",
        "null"
      ],
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
      "description": "Último día en que vale (inclusive), YYYY-MM-DD."
    },
    "hours": {
      "type": [
        "object",
        "null"
      ],
      "description": "Días y horario de canje, en la zona horaria del negocio. `null`: a cualquier hora.",
      "required": [
        "days",
        "from",
        "to"
      ],
      "properties": {
        "days": {
          "type": "array",
          "items": {
            "type": "integer",
            "minimum": 0,
            "maximum": 6
          },
          "description": "0 = domingo … 6 = sábado."
        },
        "from": {
          "type": "string",
          "description": "HH:MM"
        },
        "to": {
          "type": "string",
          "description": "HH:MM. Si es menor que `from`, cruza la medianoche."
        }
      }
    },
    "maxPerCustomer": {
      "type": [
        "integer",
        "null"
      ],
      "minimum": 1,
      "default": 1,
      "description": "Usos por cliente; en `birthday`, por año. `null`: sin límite."
    },
    "maxTotal": {
      "type": [
        "integer",
        "null"
      ],
      "minimum": 1,
      "description": "Usos entre todos los clientes. `null`: sin límite."
    },
    "notify": {
      "type": "boolean",
      "default": false,
      "description": "Avisar en la pantalla de bloqueo al recibirlo."
    },
    "enabled": {
      "type": "boolean",
      "default": true
    }
  }
}
```

### Coupon

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 40,
      "description": "El beneficio, como lo ve el cliente."
    },
    "description": {
      "type": "string",
      "maxLength": 200,
      "description": "Condiciones."
    },
    "templateIds": {
      "type": "array",
      "items": {
        "type": "string"
      },
      "description": "Plantillas a las que aplica. Vacío: todas."
    },
    "audience": {
      "type": "string",
      "enum": [
        "all",
        "selected",
        "welcome",
        "birthday"
      ],
      "default": "all"
    },
    "validDays": {
      "type": "integer",
      "minimum": 1,
      "maximum": 365,
      "default": 30,
      "description": "Sólo `welcome` y `birthday`: días que dura desde el alta o el cumpleaños."
    },
    "startsAt": {
      "type": [
        "string",
        "null"
      ],
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
      "description": "Fecha local del negocio, YYYY-MM-DD."
    },
    "endsAt": {
      "type": [
        "string",
        "null"
      ],
      "pattern": "^\\d{4}-\\d{2}-\\d{2}$",
      "description": "Último día en que vale (inclusive), YYYY-MM-DD."
    },
    "hours": {
      "type": [
        "object",
        "null"
      ],
      "description": "Días y horario de canje, en la zona horaria del negocio. `null`: a cualquier hora.",
      "required": [
        "days",
        "from",
        "to"
      ],
      "properties": {
        "days": {
          "type": "array",
          "items": {
            "type": "integer",
            "minimum": 0,
            "maximum": 6
          },
          "description": "0 = domingo … 6 = sábado."
        },
        "from": {
          "type": "string",
          "description": "HH:MM"
        },
        "to": {
          "type": "string",
          "description": "HH:MM. Si es menor que `from`, cruza la medianoche."
        }
      }
    },
    "maxPerCustomer": {
      "type": [
        "integer",
        "null"
      ],
      "minimum": 1,
      "default": 1,
      "description": "Usos por cliente; en `birthday`, por año. `null`: sin límite."
    },
    "maxTotal": {
      "type": [
        "integer",
        "null"
      ],
      "minimum": 1,
      "description": "Usos entre todos los clientes. `null`: sin límite."
    },
    "notify": {
      "type": "boolean",
      "default": false,
      "description": "Avisar en la pantalla de bloqueo al recibirlo."
    },
    "enabled": {
      "type": "boolean",
      "default": true
    },
    "status": {
      "type": "string",
      "enum": [
        "active",
        "scheduled",
        "expired",
        "exhausted",
        "off"
      ]
    },
    "summary": {
      "type": "string",
      "description": "A quién se le da y con qué límites, en una frase."
    },
    "redemptions": {
      "type": "integer",
      "description": "Canjes entre todos los clientes."
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}
```

### CardCoupon

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string"
    },
    "description": {
      "type": "string"
    },
    "audience": {
      "type": "string",
      "enum": [
        "all",
        "selected",
        "welcome",
        "birthday"
      ]
    },
    "status": {
      "type": "string",
      "enum": [
        "redeemable",
        "off_hours",
        "exhausted",
        "expired"
      ]
    },
    "validity": {
      "type": "string",
      "description": "Vigencia como la ve el cliente; vacío si no vence."
    },
    "used": {
      "type": "integer",
      "description": "Usos de este cliente (en `birthday`, los de este año)."
    },
    "maxPerCustomer": {
      "type": [
        "integer",
        "null"
      ]
    }
  }
}
```

### WebhookEndpoint

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "url": {
      "type": "string",
      "format": "uri"
    },
    "description": {
      "type": "string"
    },
    "events": {
      "type": "array",
      "items": {
        "type": "string",
        "enum": [
          "balance.earned",
          "balance.redeemed",
          "balance.adjusted",
          "balance.expired",
          "card.created",
          "card.revoked",
          "card.reactivated",
          "wallet.pass_added",
          "wallet.pass_removed",
          "coupon.granted",
          "coupon.redeemed",
          "notice.sent",
          "promotion.started",
          "promotion.ended"
        ]
      }
    },
    "enabled": {
      "type": "boolean"
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}
```

### WebhookEndpointWithSecret

```json
{
  "allOf": [
    {
      "$ref": "#/components/schemas/WebhookEndpoint"
    },
    {
      "type": "object",
      "properties": {
        "secret": {
          "type": "string",
          "description": "Llave de firma (`whsec_…`). Trátala como contraseña."
        }
      }
    }
  ]
}
```

### WebhookEndpointInput

```json
{
  "type": "object",
  "required": [
    "url",
    "events"
  ],
  "properties": {
    "url": {
      "type": "string",
      "format": "uri",
      "maxLength": 2000,
      "description": "`https://` y pública. Sin usuario ni contraseña."
    },
    "description": {
      "type": "string",
      "maxLength": 200
    },
    "events": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "string",
        "enum": [
          "balance.earned",
          "balance.redeemed",
          "balance.adjusted",
          "balance.expired",
          "card.created",
          "card.revoked",
          "card.reactivated",
          "wallet.pass_added",
          "wallet.pass_removed",
          "coupon.granted",
          "coupon.redeemed",
          "notice.sent",
          "promotion.started",
          "promotion.ended"
        ]
      },
      "description": "Los eventos que recibe. Por familia: balance.earned, balance.redeemed, balance.adjusted, balance.expired · card.created, card.revoked, card.reactivated, wallet.pass_added, wallet.pass_removed · coupon.granted, coupon.redeemed · notice.sent, promotion.started, promotion.ended."
    },
    "enabled": {
      "type": "boolean",
      "default": true
    }
  }
}
```

### WebhookDelivery

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string",
      "description": "Id de la entrega; llega en la cabecera `Pokit-Delivery`."
    },
    "endpointId": {
      "type": "string"
    },
    "eventId": {
      "type": "string",
      "description": "Id del evento, igual en todas sus entregas."
    },
    "type": {
      "type": "string"
    },
    "status": {
      "type": "string",
      "enum": [
        "pending",
        "delivered",
        "failed"
      ]
    },
    "attempts": {
      "type": "integer"
    },
    "lastStatus": {
      "type": [
        "integer",
        "null"
      ],
      "description": "Código HTTP de la última respuesta; `null` si no hubo respuesta."
    },
    "lastError": {
      "type": [
        "string",
        "null"
      ]
    },
    "nextAttemptAt": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "deliveredAt": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time"
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "payload": {
      "$ref": "#/components/schemas/WebhookEvent"
    }
  }
}
```

### WebhookEvent

```json
{
  "type": "object",
  "description": "El cuerpo JSON que recibe tu URL. Verifica `Pokit-Signature` sobre el cuerpo crudo antes de usarlo.",
  "required": [
    "id",
    "type",
    "createdAt",
    "businessId",
    "data"
  ],
  "properties": {
    "id": {
      "type": "string",
      "description": "Id único del evento: úsalo para no procesarlo dos veces."
    },
    "type": {
      "type": "string",
      "enum": [
        "balance.earned",
        "balance.redeemed",
        "balance.adjusted",
        "balance.expired",
        "card.created",
        "card.revoked",
        "card.reactivated",
        "wallet.pass_added",
        "wallet.pass_removed",
        "coupon.granted",
        "coupon.redeemed",
        "notice.sent",
        "promotion.started",
        "promotion.ended",
        "webhook.test"
      ]
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "businessId": {
      "type": "string"
    },
    "data": {
      "oneOf": [
        {
          "type": "object",
          "title": "Movimiento de saldo",
          "description": "`balance.earned`, `balance.redeemed`, `balance.adjusted` y `balance.expired`.",
          "required": [
            "card",
            "field",
            "delta",
            "balance",
            "source"
          ],
          "properties": {
            "card": {
              "$ref": "#/components/schemas/Card"
            },
            "field": {
              "type": "string",
              "enum": [
                "points",
                "stamps"
              ]
            },
            "delta": {
              "type": "integer",
              "description": "Lo que cambió el saldo: positivo al ganar, negativo al canjear o vencer."
            },
            "balance": {
              "type": "integer",
              "description": "Saldo después del movimiento."
            },
            "reason": {
              "type": "string"
            },
            "metadata": {
              "type": [
                "object",
                "null"
              ],
              "additionalProperties": true
            },
            "source": {
              "type": "string",
              "description": "Dónde ocurrió: `panel`, `caja`, `api`, `mcp` o `system` (vencimientos)."
            },
            "branchId": {
              "type": [
                "string",
                "null"
              ],
              "description": "Sucursal donde ocurrió, si se indicó."
            }
          }
        },
        {
          "type": "object",
          "title": "Tarjeta",
          "description": "`card.created`, `card.revoked`, `card.reactivated`, `wallet.pass_added` y `wallet.pass_removed`. Los de Wallet traen `platform`: `apple` (Apple Wallet avisa cuando un iPhone agrega o quita el pase) o `google` (Google Wallet avisa cuando alguien la guarda o la borra en Android).",
          "required": [
            "card"
          ],
          "properties": {
            "card": {
              "$ref": "#/components/schemas/Card"
            },
            "source": {
              "type": "string"
            },
            "platform": {
              "type": "string",
              "enum": [
                "apple",
                "google"
              ]
            }
          }
        },
        {
          "type": "object",
          "title": "Cupón",
          "description": "`coupon.granted` y `coupon.redeemed`.",
          "required": [
            "card",
            "coupon",
            "source"
          ],
          "properties": {
            "card": {
              "$ref": "#/components/schemas/Card"
            },
            "coupon": {
              "type": "object",
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "description": {
                  "type": "string"
                },
                "audience": {
                  "type": "string"
                }
              }
            },
            "redeemedAt": {
              "type": "string",
              "format": "date-time",
              "description": "Sólo en `coupon.redeemed`."
            },
            "source": {
              "type": "string"
            }
          }
        },
        {
          "type": "object",
          "title": "Aviso",
          "description": "`notice.sent`: uno por envío, no uno por cliente. Con un solo destinatario trae `card`; en envíos masivos `card` es `null` y `cardIds` lista hasta 1,000 tarjetas (`recipients` es el total).",
          "required": [
            "message",
            "recipients",
            "cardIds",
            "source"
          ],
          "properties": {
            "message": {
              "type": "string"
            },
            "status": {
              "type": [
                "string",
                "null"
              ]
            },
            "orderId": {
              "type": [
                "string",
                "null"
              ]
            },
            "recipients": {
              "type": "integer"
            },
            "cardIds": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "card": {
              "oneOf": [
                {
                  "$ref": "#/components/schemas/Card"
                },
                {
                  "type": "null"
                }
              ]
            },
            "source": {
              "type": "string"
            }
          }
        },
        {
          "type": "object",
          "title": "Promoción",
          "description": "`promotion.started` y `promotion.ended`: la promoción entró o salió de los pases, por su horario o porque se encendió o apagó.",
          "required": [
            "promotion"
          ],
          "properties": {
            "promotion": {
              "$ref": "#/components/schemas/Promotion"
            }
          }
        },
        {
          "type": "object",
          "title": "Prueba",
          "description": "`webhook.test`: sólo lo manda «Enviar prueba».",
          "properties": {
            "message": {
              "type": "string"
            },
            "endpointId": {
              "type": "string"
            }
          }
        }
      ]
    }
  }
}
```

### Branch

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 80,
      "description": "Nombre visible: en la Caja, en las estadísticas y en el panel."
    },
    "address": {
      "type": "string",
      "maxLength": 200,
      "description": "Dirección legible. Opcional; sólo informativa."
    },
    "latitude": {
      "type": "number",
      "minimum": -90,
      "maximum": 90
    },
    "longitude": {
      "type": "number",
      "minimum": -180,
      "maximum": 180
    },
    "relevantText": {
      "type": "string",
      "maxLength": 120,
      "description": "Mensaje de la pantalla de bloqueo cuando el cliente está cerca. Vacío: Wallet muestra el pase sin texto."
    },
    "active": {
      "type": "boolean",
      "description": "Una sucursal inactiva no aparece en los pases ni en la Caja, pero conserva su historial."
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}
```

### BranchInput

```json
{
  "type": "object",
  "required": [
    "name",
    "latitude",
    "longitude"
  ],
  "properties": {
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 80,
      "description": "Nombre visible: en la Caja, en las estadísticas y en el panel."
    },
    "address": {
      "type": "string",
      "maxLength": 200,
      "description": "Dirección legible. Opcional; sólo informativa."
    },
    "latitude": {
      "type": "number",
      "minimum": -90,
      "maximum": 90
    },
    "longitude": {
      "type": "number",
      "minimum": -180,
      "maximum": 180
    },
    "relevantText": {
      "type": "string",
      "maxLength": 120,
      "description": "Mensaje de la pantalla de bloqueo cuando el cliente está cerca. Vacío: Wallet muestra el pase sin texto."
    },
    "active": {
      "type": "boolean",
      "description": "Una sucursal inactiva no aparece en los pases ni en la Caja, pero conserva su historial."
    }
  }
}
```

### BranchPatch

```json
{
  "type": "object",
  "description": "Los mismos campos que al crear; todos opcionales, al menos uno.",
  "properties": {
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 80,
      "description": "Nombre visible: en la Caja, en las estadísticas y en el panel."
    },
    "address": {
      "type": "string",
      "maxLength": 200,
      "description": "Dirección legible. Opcional; sólo informativa."
    },
    "latitude": {
      "type": "number",
      "minimum": -90,
      "maximum": 90
    },
    "longitude": {
      "type": "number",
      "minimum": -180,
      "maximum": 180
    },
    "relevantText": {
      "type": "string",
      "maxLength": 120,
      "description": "Mensaje de la pantalla de bloqueo cuando el cliente está cerca. Vacío: Wallet muestra el pase sin texto."
    },
    "active": {
      "type": "boolean",
      "description": "Una sucursal inactiva no aparece en los pases ni en la Caja, pero conserva su historial."
    }
  }
}
```

### Customer

```json
{
  "type": "object",
  "description": "Perfil único del cliente dentro de tu negocio: une sus tarjetas de distintas plantillas.",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string"
    },
    "email": {
      "type": [
        "string",
        "null"
      ],
      "format": "email"
    },
    "phone": {
      "type": [
        "string",
        "null"
      ],
      "description": "Sólo dígitos, con `+` inicial opcional."
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}
```

### CustomerSummary

```json
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Customer"
    },
    {
      "type": "object",
      "properties": {
        "cards": {
          "type": "integer",
          "description": "Cuántas tarjetas tiene."
        },
        "visits": {
          "type": "integer",
          "description": "Visitas: movimientos `earn` de todas sus tarjetas."
        },
        "lastVisit": {
          "type": [
            "string",
            "null"
          ],
          "format": "date-time",
          "description": "Última suma o canje en cualquiera de sus tarjetas."
        }
      }
    }
  ]
}
```

### CustomerList

```json
{
  "type": "object",
  "properties": {
    "data": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/CustomerSummary"
      }
    },
    "page": {
      "type": "integer"
    },
    "limit": {
      "type": "integer"
    },
    "total": {
      "type": "integer"
    }
  }
}
```

### CustomerMetrics

```json
{
  "type": "object",
  "properties": {
    "visits": {
      "type": "integer",
      "description": "Visitas: movimientos `earn` de todas sus tarjetas."
    },
    "lastVisit": {
      "type": [
        "string",
        "null"
      ],
      "format": "date-time",
      "description": "Última suma o canje en cualquiera de sus tarjetas."
    },
    "visitsPerMonth": {
      "type": "number",
      "description": "Visitas al mes en los últimos 90 días (o desde su alta, si es más reciente; mínimo un mes)."
    },
    "favoriteBranch": {
      "type": [
        "object",
        "null"
      ],
      "description": "La sucursal donde más movimientos tiene. `null` si ninguno trae sucursal.",
      "properties": {
        "id": {
          "type": "string"
        },
        "name": {
          "type": "string"
        },
        "visits": {
          "type": "integer"
        }
      }
    },
    "couponsRedeemed": {
      "type": "integer",
      "description": "Cupones canjeados en todas sus tarjetas."
    },
    "averageTicket": {
      "type": [
        "number",
        "null"
      ],
      "description": "Promedio de `metadata.total` en los movimientos que lo traen numérico. `null` si ninguno lo trae."
    }
  }
}
```

### CustomerDetail

```json
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Customer"
    },
    {
      "type": "object",
      "properties": {
        "cards": {
          "type": "array",
          "items": {
            "allOf": [
              {
                "$ref": "#/components/schemas/Card"
              },
              {
                "type": "object",
                "properties": {
                  "templateName": {
                    "type": "string"
                  }
                }
              }
            ]
          }
        },
        "metrics": {
          "$ref": "#/components/schemas/CustomerMetrics"
        }
      }
    }
  ]
}
```

### CustomerInput

```json
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "name": {
      "type": "string",
      "minLength": 1,
      "maxLength": 120
    },
    "email": {
      "type": [
        "string",
        "null"
      ],
      "format": "email",
      "description": "`null` o vacío lo borra."
    },
    "phone": {
      "type": [
        "string",
        "null"
      ],
      "description": "10 a 15 dígitos; se aceptan espacios, guiones y paréntesis. `null` o vacío lo borra."
    }
  }
}
```

### MergeCustomerInput

```json
{
  "type": "object",
  "required": [
    "customerId"
  ],
  "properties": {
    "customerId": {
      "type": "string",
      "description": "Perfil que se funde en éste y se borra."
    }
  }
}
```

### SplitCustomerInput

```json
{
  "type": "object",
  "required": [
    "cardId"
  ],
  "properties": {
    "cardId": {
      "type": "string",
      "description": "Tarjeta de este perfil que pasa a un perfil nuevo."
    }
  }
}
```

### Error

```json
{
  "type": "object",
  "required": [
    "error"
  ],
  "properties": {
    "error": {
      "type": "string",
      "description": "Código de error estable, en snake_case."
    },
    "detail": {
      "type": "string",
      "description": "Descripción legible del problema (opcional)."
    }
  }
}
```

### Template

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string"
    },
    "type": {
      "type": "string",
      "enum": [
        "POINTS",
        "STAMPS",
        "MEMBERSHIP"
      ]
    },
    "active": {
      "type": "boolean"
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "cards": {
      "type": "integer",
      "description": "Número de tarjetas emitidas."
    }
  }
}
```

### TemplateDetail

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "name": {
      "type": "string"
    },
    "type": {
      "type": "string",
      "enum": [
        "POINTS",
        "STAMPS",
        "MEMBERSHIP"
      ]
    },
    "description": {
      "type": "string"
    },
    "backgroundColor": {
      "type": "string"
    },
    "foregroundColor": {
      "type": "string"
    },
    "labelColor": {
      "type": "string"
    },
    "logoText": {
      "type": "string"
    },
    "stripImageUrl": {
      "type": [
        "string",
        "null"
      ]
    },
    "stampsTotal": {
      "type": "integer"
    },
    "pointsPerVisit": {
      "type": "integer"
    },
    "rewardText": {
      "type": "string"
    },
    "barcodeFormat": {
      "type": "string",
      "enum": [
        "QR",
        "PDF417",
        "AZTEC",
        "CODE128"
      ]
    },
    "joinFields": {
      "type": "array",
      "description": "Preguntas del formulario de alta. El `key` de cada una es la clave que se usa en `data` al emitir una tarjeta.",
      "items": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string"
          },
          "label": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "phone",
              "text",
              "number",
              "date",
              "select"
            ]
          },
          "required": {
            "type": "boolean"
          },
          "options": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        }
      }
    },
    "expiryMode": {
      "type": "string",
      "enum": [
        "none",
        "fixed",
        "inactivity"
      ],
      "description": "Vencimiento del saldo: no vence, vence `expiryDays` después de ganarse, o vence tras `expiryDays` sin visitas."
    },
    "expiryDays": {
      "type": "integer"
    },
    "passLayout": {
      "type": "object",
      "description": "Diseño del pase: dónde va el saldo y qué datos muestra.",
      "additionalProperties": true
    },
    "active": {
      "type": "boolean"
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    },
    "branchPass": {
      "type": "string",
      "enum": [
        "fixed",
        "nearest"
      ],
      "description": "Cómo llegan las sucursales al pase (Apple acepta 10): `fixed`, las primeras 10 en el orden de la plantilla; `nearest`, las 10 más cercanas a la sucursal de referencia de cada tarjeta."
    },
    "branches": {
      "type": "array",
      "description": "Sucursales activas donde vale la plantilla, en su orden.",
      "items": {
        "$ref": "#/components/schemas/Branch"
      }
    },
    "locations": {
      "type": "array",
      "description": "Obsoleto: las mismas sucursales con la forma de `/locations`. Usa `branches`.",
      "items": {
        "$ref": "#/components/schemas/Location"
      }
    },
    "cards": {
      "type": "integer"
    }
  }
}
```

### Location

```json
{
  "type": "object",
  "description": "Ubicación (obsoleto): una sucursal con la forma de antes.",
  "properties": {
    "id": {
      "type": "string"
    },
    "templateId": {
      "type": "string"
    },
    "name": {
      "type": "string"
    },
    "latitude": {
      "type": "number"
    },
    "longitude": {
      "type": "number"
    },
    "relevantText": {
      "type": "string",
      "description": "Mensaje mostrado en la pantalla de bloqueo."
    }
  }
}
```

### Card

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "serial": {
      "type": "string"
    },
    "memberNumber": {
      "type": "string",
      "description": "Número de socio: 8 dígitos, único y estable. Son los primeros 8 del código de la tarjeta."
    },
    "templateId": {
      "type": "string"
    },
    "customerId": {
      "type": [
        "string",
        "null"
      ],
      "description": "Perfil único del cliente (ver `/customers`): une sus tarjetas de distintas plantillas en tu negocio."
    },
    "homeBranchId": {
      "type": [
        "string",
        "null"
      ],
      "description": "Sucursal donde se dio de alta."
    },
    "lastBranchId": {
      "type": [
        "string",
        "null"
      ],
      "description": "Sucursal de su última visita."
    },
    "customerName": {
      "type": "string"
    },
    "customerEmail": {
      "type": "string",
      "format": "email"
    },
    "customerPhone": {
      "type": [
        "string",
        "null"
      ],
      "description": "Sólo dígitos, con `+` inicial opcional."
    },
    "data": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      },
      "description": "Respuestas al formulario de alta de la plantilla, por `key` de pregunta (ver `joinFields` en GET /templates/{id})."
    },
    "points": {
      "type": "integer"
    },
    "stamps": {
      "type": "integer"
    },
    "status": {
      "type": "string",
      "enum": [
        "ACTIVE",
        "REVOKED"
      ]
    },
    "lastActivityAt": {
      "type": "string",
      "format": "date-time",
      "description": "Última suma o canje. Base del vencimiento por inactividad."
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    },
    "updatedAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}
```

### CardWithUrls

```json
{
  "allOf": [
    {
      "$ref": "#/components/schemas/Card"
    },
    {
      "type": "object",
      "properties": {
        "urls": {
          "type": "object",
          "properties": {
            "public": {
              "type": "string",
              "format": "uri",
              "description": "Página pública de la tarjeta."
            },
            "appleWallet": {
              "type": [
                "string",
                "null"
              ],
              "format": "uri",
              "description": "Descarga del pass de Apple Wallet. `null` si este servidor no tiene Apple Wallet configurado."
            },
            "googleWallet": {
              "type": [
                "string",
                "null"
              ],
              "format": "uri",
              "description": "Alta en Google Wallet. `null` si este servidor no tiene Google Wallet configurado."
            }
          }
        }
      }
    }
  ]
}
```

### CardList

```json
{
  "type": "object",
  "properties": {
    "data": {
      "type": "array",
      "items": {
        "$ref": "#/components/schemas/Card"
      }
    },
    "page": {
      "type": "integer"
    },
    "limit": {
      "type": "integer"
    },
    "total": {
      "type": "integer"
    }
  }
}
```

### NoticeInput

```json
{
  "type": "object",
  "required": [
    "message"
  ],
  "properties": {
    "message": {
      "type": "string",
      "minLength": 2,
      "maxLength": 120,
      "description": "Lo que el cliente ve en la pantalla de bloqueo."
    },
    "status": {
      "type": "string",
      "maxLength": 40,
      "description": "Estatus del pedido, libre: recibido, en preparación, listo…"
    },
    "orderId": {
      "type": "string",
      "maxLength": 60
    },
    "metadata": {
      "type": "object",
      "description": "Detalles libres; quedan en el historial."
    }
  }
}
```

### CardEvent

```json
{
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "field": {
      "type": "string",
      "enum": [
        "points",
        "stamps"
      ]
    },
    "kind": {
      "type": "string",
      "enum": [
        "earn",
        "redeem",
        "adjust",
        "expire",
        "notice",
        "coupon"
      ],
      "description": "Suma, canje, corrección manual, vencimiento automático, aviso al cliente o cupón canjeado (los dos últimos con delta 0)."
    },
    "delta": {
      "type": "integer"
    },
    "balanceAfter": {
      "type": "integer"
    },
    "reason": {
      "type": "string"
    },
    "metadata": {
      "type": "object",
      "additionalProperties": true
    },
    "source": {
      "type": "string",
      "description": "panel, caja, api o system."
    },
    "createdAt": {
      "type": "string",
      "format": "date-time"
    }
  }
}
```

### RedeemInput

```json
{
  "type": "object",
  "properties": {
    "amount": {
      "type": "integer",
      "minimum": 1,
      "description": "Sólo tarjetas de puntos: cuántos consume el canje."
    },
    "reason": {
      "type": "string",
      "maxLength": 200,
      "description": "Por qué se mueve el saldo. Aparece en el historial de la tarjeta."
    },
    "metadata": {
      "type": "object",
      "additionalProperties": true,
      "description": "Detalles libres de la transacción (ticket, sucursal, cajero, total…). Máximo 2000 caracteres en JSON."
    },
    "code": {
      "type": "string",
      "description": "Código rotativo que enseñó el cliente: los 14 dígitos de su tarjeta en línea o de su pase (se aceptan espacios, puntos y guiones). Sólo cuenta si la plantilla tiene el código rotativo encendido y lo pide para esta acción; en el resto se ignora. Un código de la tarjeta en línea sirve para un solo canje."
    },
    "branchId": {
      "type": "string",
      "description": "Opcional. ID de la sucursal donde ocurrió el movimiento (ver `GET /branches`). Queda en el historial y en las estadísticas por sucursal, y se vuelve la sucursal de referencia de la tarjeta: si su plantilla lleva al pase las sucursales más cercanas (`branchPass: nearest`), el pase se actualiza con las 10 más cercanas a ésta. Si no es de tu negocio responde 422."
    }
  }
}
```

### CreateCardInput

```json
{
  "type": "object",
  "required": [
    "templateId",
    "customerName",
    "customerEmail"
  ],
  "properties": {
    "templateId": {
      "type": "string",
      "description": "Plantilla de tu negocio."
    },
    "customerName": {
      "type": "string",
      "maxLength": 120
    },
    "customerEmail": {
      "type": "string",
      "format": "email"
    },
    "customerPhone": {
      "type": "string",
      "description": "Sólo dígitos, con `+` inicial opcional."
    },
    "data": {
      "type": "object",
      "additionalProperties": {
        "type": "string"
      },
      "description": "Respuestas al formulario de alta de la plantilla, por `key` de pregunta (ver `joinFields` en GET /templates/{id})."
    }
  }
}
```

### AdjustBalanceInput

```json
{
  "type": "object",
  "required": [
    "delta"
  ],
  "properties": {
    "delta": {
      "type": "integer",
      "description": "Cantidad a sumar (positivo) o restar (negativo). No puede ser 0 ni pasar de ±1,000,000."
    },
    "field": {
      "type": "string",
      "enum": [
        "points",
        "stamps"
      ],
      "description": "Opcional. Por defecto se ajusta el saldo propio de la tarjeta: puntos en tarjetas de puntos, sellos en tarjetas de sellos. Si lo mandas y no corresponde al tipo de tarjeta, responde 422."
    },
    "reason": {
      "type": "string",
      "maxLength": 200,
      "description": "Por qué se mueve el saldo. Aparece en el historial de la tarjeta."
    },
    "metadata": {
      "type": "object",
      "additionalProperties": true,
      "description": "Detalles libres de la transacción (ticket, sucursal, cajero, total…). Máximo 2000 caracteres en JSON."
    },
    "code": {
      "type": "string",
      "description": "Código rotativo que enseñó el cliente: los 14 dígitos de su tarjeta en línea o de su pase (se aceptan espacios, puntos y guiones). Sólo cuenta si la plantilla tiene el código rotativo encendido y lo pide para esta acción; en el resto se ignora. Un código de la tarjeta en línea sirve para un solo canje."
    },
    "branchId": {
      "type": "string",
      "description": "Opcional. ID de la sucursal donde ocurrió el movimiento (ver `GET /branches`). Queda en el historial y en las estadísticas por sucursal, y se vuelve la sucursal de referencia de la tarjeta: si su plantilla lleva al pase las sucursales más cercanas (`branchPass: nearest`), el pase se actualiza con las 10 más cercanas a ésta. Si no es de tu negocio responde 422."
    }
  }
}
```

## Servidor MCP

`https://pokit.mx/api/mcp` — servidor Model Context Protocol (Streamable HTTP, sin estado). Dos formas de conectar: (1) OAuth 2.1 — basta dar esta URL al cliente MCP; sin credencial responde 401 con `WWW-Authenticate` y el cliente descubre el servidor de autorización en `/.well-known/oauth-protected-resource/api/mcp`, se registra solo (registro dinámico o Client ID Metadata Document), el dueño del negocio inicia sesión y autoriza, y puede revocar el acceso al instante desde el panel (API Keys → Aplicaciones conectadas); (2) API key — la misma de la API REST en la cabecera `Authorization: Bearer pk_live_...`. Herramientas:

### get_business (sólo lectura)

Datos del negocio: nombre, zona horaria (en la que se leen los horarios de las promociones) y sus plantillas de tarjeta con tipo (POINTS, STAMPS o MEMBERSHIP), recompensa y reglas. Empieza por aquí.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {}
}
```

### search_cards (sólo lectura)

Busca tarjetas de clientes por nombre, correo, teléfono o número de serie (lo que trae el código del pase). Devuelve hasta 20 con su saldo.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "query": {
      "type": "string",
      "minLength": 2,
      "maxLength": 120,
      "description": "Texto a buscar."
    }
  },
  "required": [
    "query"
  ]
}
```

### get_card (sólo lectura)

Una tarjeta con su saldo, sus enlaces, sus cupones (status: redeemable, off_hours, exhausted o expired) y sus últimos movimientos y avisos.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "cardId": {
      "type": "string",
      "minLength": 1,
      "description": "Id de la tarjeta (campo `id` de search_cards)."
    }
  },
  "required": [
    "cardId"
  ]
}
```

### verify_code (sólo lectura)

Identifica a un cliente por lo que enseña en caja y dice si su código rotativo es vigente, sin mover saldo. Acepta los 14 dígitos del código (identifica y verifica), los 8 del número de socio, el serial del pase o la URL de la tarjeta (sólo identifican). Devuelve la tarjeta, `verified` y `source`.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "code": {
      "type": "string",
      "minLength": 1,
      "maxLength": 200,
      "description": "Lo que enseña el cliente: 14 dígitos (código), 8 (número de socio), serial o URL de su tarjeta."
    }
  },
  "required": [
    "code"
  ]
}
```

### adjust_balance

Suma (delta positivo) o resta (delta negativo) puntos o sellos a una tarjeta. El cliente recibe la notificación de su nuevo saldo en Wallet. Para canjear la recompensa usa redeem_reward.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "cardId": {
      "type": "string",
      "minLength": 1,
      "description": "Id de la tarjeta (campo `id` de search_cards)."
    },
    "delta": {
      "type": "integer",
      "minimum": -1000000,
      "maximum": 1000000
    },
    "reason": {
      "type": "string",
      "minLength": 3,
      "maxLength": 200,
      "description": "Por qué se mueve el saldo; queda en el historial."
    },
    "code": {
      "description": "Código rotativo de 14 dígitos que enseña el cliente. Sólo hace falta si la plantilla lo pide para esta acción.",
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "branchId": {
      "description": "Opcional: id de la sucursal donde ocurrió (de list_branches). Queda en el historial y en las estadísticas por sucursal.",
      "type": "string",
      "minLength": 1,
      "maxLength": 64
    }
  },
  "required": [
    "cardId",
    "delta",
    "reason"
  ]
}
```

### redeem_reward

Canjea la recompensa de una tarjeta: en sellos consume la tarjeta llena; en puntos, la cantidad indicada en `amount`.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "cardId": {
      "type": "string",
      "minLength": 1,
      "description": "Id de la tarjeta (campo `id` de search_cards)."
    },
    "amount": {
      "description": "Sólo tarjetas de puntos.",
      "type": "integer",
      "minimum": 1,
      "maximum": 1000000
    },
    "reason": {
      "type": "string",
      "maxLength": 200
    },
    "code": {
      "description": "Código rotativo de 14 dígitos que enseña el cliente. Sólo hace falta si la plantilla lo pide para esta acción.",
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    },
    "branchId": {
      "description": "Opcional: id de la sucursal donde ocurrió (de list_branches). Queda en el historial y en las estadísticas por sucursal.",
      "type": "string",
      "minLength": 1,
      "maxLength": 64
    }
  },
  "required": [
    "cardId"
  ]
}
```

### send_notice

Manda un aviso a la pantalla de bloqueo del cliente a través de su pase, p. ej. «Tu pedido #45 está listo». Máximo 20 por tarjeta por hora. Úsalo para estatus de pedidos o mensajes puntuales a UN cliente; para todos los clientes, crea una promoción con aviso.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "cardId": {
      "type": "string",
      "minLength": 1,
      "description": "Id de la tarjeta (campo `id` de search_cards)."
    },
    "message": {
      "type": "string",
      "minLength": 2,
      "maxLength": 120,
      "description": "Lo que ve el cliente."
    },
    "status": {
      "description": "Estatus del pedido, si aplica: «listo», «en camino»…",
      "type": "string",
      "maxLength": 40
    },
    "orderId": {
      "type": "string",
      "maxLength": 60
    }
  },
  "required": [
    "cardId",
    "message"
  ]
}
```

### broadcast_notice

Manda el mismo aviso a la pantalla de bloqueo de VARIOS clientes: a tarjetas concretas (`cardIds`), a todas las de una plantilla (`templateId`) o a todo el negocio (`all: true`). Máximo 6 envíos de este tipo por hora. Llega a personas reales: confirma el texto y el destino antes de usarla.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "message": {
      "type": "string",
      "minLength": 2,
      "maxLength": 120,
      "description": "Lo que ve el cliente."
    },
    "cardIds": {
      "minItems": 1,
      "maxItems": 500,
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1,
        "maxLength": 64
      }
    },
    "templateId": {
      "type": "string",
      "minLength": 1,
      "maxLength": 64
    },
    "all": {
      "type": "boolean",
      "const": true
    }
  },
  "required": [
    "message"
  ]
}
```

### list_promotions (sólo lectura)

Las promociones del negocio: cuáles están en vivo, su horario y qué cambian en el pase.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {}
}
```

### create_promotion

Crea una promoción que cambia temporalmente los pases: un texto visible (`text`), colores, y opcionalmente un aviso en pantalla de bloqueo (`notify` + `message`). `schedule` decide cuándo: {mode:'manual'} (en vivo mientras `enabled`), {mode:'once', start, end} con fecha y hora local 'YYYY-MM-DDTHH:MM', o {mode:'weekly', days:[0-6, 0=domingo], from:'HH:MM', to:'HH:MM', until:'YYYY-MM-DD' opcional}. Las horas son de la zona horaria del negocio. La foto de la promoción sólo se sube desde el panel.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 60
    },
    "templateIds": {
      "default": [],
      "maxItems": 50,
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1
      }
    },
    "text": {
      "default": "",
      "type": "string",
      "maxLength": 40
    },
    "message": {
      "default": "",
      "type": "string",
      "maxLength": 120
    },
    "backgroundColor": {
      "anyOf": [
        {
          "type": "string",
          "pattern": "^#[0-9a-fA-F]{6}$"
        },
        {
          "type": "null"
        }
      ]
    },
    "foregroundColor": {
      "anyOf": [
        {
          "type": "string",
          "pattern": "^#[0-9a-fA-F]{6}$"
        },
        {
          "type": "null"
        }
      ]
    },
    "labelColor": {
      "anyOf": [
        {
          "type": "string",
          "pattern": "^#[0-9a-fA-F]{6}$"
        },
        {
          "type": "null"
        }
      ]
    },
    "notify": {
      "default": "never",
      "type": "string",
      "enum": [
        "never",
        "first",
        "every"
      ]
    },
    "schedule": {
      "oneOf": [
        {
          "type": "object",
          "properties": {
            "mode": {
              "type": "string",
              "const": "manual"
            }
          },
          "required": [
            "mode"
          ]
        },
        {
          "type": "object",
          "properties": {
            "mode": {
              "type": "string",
              "const": "once"
            },
            "start": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
            },
            "end": {
              "type": "string",
              "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$"
            }
          },
          "required": [
            "mode",
            "start",
            "end"
          ]
        },
        {
          "type": "object",
          "properties": {
            "mode": {
              "type": "string",
              "const": "weekly"
            },
            "days": {
              "minItems": 1,
              "maxItems": 7,
              "type": "array",
              "items": {
                "type": "integer",
                "minimum": 0,
                "maximum": 6
              }
            },
            "from": {
              "type": "string",
              "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
            },
            "to": {
              "type": "string",
              "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
            },
            "until": {
              "anyOf": [
                {
                  "type": "string",
                  "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
                },
                {
                  "type": "null"
                }
              ]
            }
          },
          "required": [
            "mode",
            "days",
            "from",
            "to"
          ]
        }
      ]
    },
    "enabled": {
      "default": true,
      "type": "boolean"
    }
  },
  "required": [
    "name",
    "schedule"
  ]
}
```

### set_promotion_enabled

Enciende o apaga una promoción. En las manuales es ponerla en vivo o quitarla al instante; en las programadas, apagada no se activa aunque le toque.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "promotionId": {
      "type": "string",
      "minLength": 1
    },
    "enabled": {
      "type": "boolean"
    }
  },
  "required": [
    "promotionId",
    "enabled"
  ]
}
```

### delete_promotion

Elimina una promoción. Si estaba en vivo, los pases vuelven a su diseño normal.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "promotionId": {
      "type": "string",
      "minLength": 1
    }
  },
  "required": [
    "promotionId"
  ]
}
```

### list_coupons (sólo lectura)

Los cupones del negocio: qué ofrecen, a quién se le dan (audience), sus límites, cuántas veces se han canjeado y su estado (active, scheduled, expired, exhausted u off).

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {}
}
```

### create_coupon

Crea un cupón que aparece dentro de la tarjeta del cliente (pase y página). `name` es el beneficio que ve el cliente («Postre gratis»); `description`, las condiciones. `audience`: 'all' (todas las tarjetas), 'selected' (sólo a quien se le dé con grant_coupon), 'welcome' (clientes nuevos, `validDays` días desde el alta) o 'birthday' (`validDays` días desde el cumpleaños, cada año). Límites opcionales: `maxPerCustomer` (por defecto 1; null = sin límite), `maxTotal`, `startsAt`/`endsAt` ('YYYY-MM-DD') y `hours` {days:[0-6, 0=domingo], from:'HH:MM', to:'HH:MM'} en la zona horaria del negocio. `notify: true` avisa en la pantalla de bloqueo al recibirlo (no aplica a 'welcome').

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "name": {
      "type": "string",
      "minLength": 2,
      "maxLength": 40
    },
    "description": {
      "default": "",
      "type": "string",
      "maxLength": 200
    },
    "templateIds": {
      "default": [],
      "maxItems": 50,
      "type": "array",
      "items": {
        "type": "string",
        "minLength": 1
      }
    },
    "audience": {
      "default": "all",
      "type": "string",
      "enum": [
        "all",
        "selected",
        "welcome",
        "birthday"
      ]
    },
    "validDays": {
      "default": 30,
      "type": "integer",
      "minimum": 1,
      "maximum": 365
    },
    "startsAt": {
      "anyOf": [
        {
          "type": "string",
          "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
        },
        {
          "type": "null"
        }
      ]
    },
    "endsAt": {
      "anyOf": [
        {
          "type": "string",
          "pattern": "^\\d{4}-\\d{2}-\\d{2}$"
        },
        {
          "type": "null"
        }
      ]
    },
    "hours": {
      "anyOf": [
        {
          "type": "object",
          "properties": {
            "days": {
              "minItems": 1,
              "maxItems": 7,
              "type": "array",
              "items": {
                "type": "integer",
                "minimum": 0,
                "maximum": 6
              }
            },
            "from": {
              "type": "string",
              "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
            },
            "to": {
              "type": "string",
              "pattern": "^([01]\\d|2[0-3]):[0-5]\\d$"
            }
          },
          "required": [
            "days",
            "from",
            "to"
          ]
        },
        {
          "type": "null"
        }
      ]
    },
    "maxPerCustomer": {
      "default": 1,
      "anyOf": [
        {
          "type": "integer",
          "minimum": 1,
          "maximum": 1000
        },
        {
          "type": "null"
        }
      ]
    },
    "maxTotal": {
      "anyOf": [
        {
          "type": "integer",
          "minimum": 1,
          "maximum": 1000000
        },
        {
          "type": "null"
        }
      ]
    },
    "notify": {
      "default": false,
      "type": "boolean"
    },
    "enabled": {
      "default": true,
      "type": "boolean"
    }
  },
  "required": [
    "name"
  ]
}
```

### set_coupon_enabled

Enciende o apaga un cupón. Apagado desaparece de los pases y no se puede canjear.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "couponId": {
      "type": "string",
      "minLength": 1,
      "description": "Id del cupón (campo `id` de list_coupons)."
    },
    "enabled": {
      "type": "boolean"
    }
  },
  "required": [
    "couponId",
    "enabled"
  ]
}
```

### grant_coupon

Le da a una tarjeta un cupón de audiencia 'selected'. Si el cupón tiene aviso, el cliente lo ve en su pantalla de bloqueo. Dárselo dos veces no hace nada (`created: false`).

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "couponId": {
      "type": "string",
      "minLength": 1,
      "description": "Id del cupón (campo `id` de list_coupons)."
    },
    "cardId": {
      "type": "string",
      "minLength": 1,
      "description": "Id de la tarjeta (campo `id` de search_cards)."
    }
  },
  "required": [
    "couponId",
    "cardId"
  ]
}
```

### redeem_coupon

Marca un cupón como usado por una tarjeta (el beneficio lo aplica quien atiende). No se puede deshacer. Sólo funciona si get_card lo muestra con status 'redeemable'.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "couponId": {
      "type": "string",
      "minLength": 1,
      "description": "Id del cupón (campo `id` de list_coupons)."
    },
    "cardId": {
      "type": "string",
      "minLength": 1,
      "description": "Id de la tarjeta (campo `id` de search_cards)."
    },
    "code": {
      "description": "Código rotativo de 14 dígitos que enseña el cliente. Sólo hace falta si la plantilla lo pide para esta acción.",
      "type": "string",
      "minLength": 1,
      "maxLength": 200
    }
  },
  "required": [
    "couponId",
    "cardId"
  ]
}
```

### list_branches (sólo lectura)

Lista las sucursales del negocio (id, nombre, dirección, coordenadas, mensaje de pantalla de bloqueo y si está activa). Usa su id como branchId en adjust_balance o redeem_reward para registrar dónde ocurrió el movimiento.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {}
}
```

### search_customers (sólo lectura)

Busca perfiles de clientes por nombre, correo, teléfono o número de socio. Un perfil une todas las tarjetas de una persona en el negocio. Devuelve hasta 20 con su número de tarjetas, visitas y última visita.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "query": {
      "type": "string",
      "minLength": 2,
      "maxLength": 120,
      "description": "Texto a buscar."
    }
  },
  "required": [
    "query"
  ]
}
```

### get_customer (sólo lectura)

Perfil completo de un cliente: datos de contacto, todas sus tarjetas con su saldo y métricas (visitas, última visita, visitas al mes en los últimos 90 días, sucursal favorita, cupones canjeados y ticket promedio si la caja manda `total` en metadata).

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "customerId": {
      "type": "string",
      "minLength": 1,
      "description": "ID del perfil del cliente (lo devuelve search_customers o el campo customerId de una tarjeta)."
    }
  },
  "required": [
    "customerId"
  ]
}
```

### list_webhooks (sólo lectura)

Las URLs de webhook del negocio: a dónde se envían los eventos, cuáles recibe cada una y si están activas. No incluye la llave de firma.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {}
}
```

### list_webhook_deliveries (sólo lectura)

Bitácora de entregas de webhooks (30 días): qué evento salió, a qué URL, si llegó (delivered), sigue reintentando (pending) o falló (failed), con el código HTTP y el error. Sirve para diagnosticar una integración.

```json
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "properties": {
    "endpointId": {
      "description": "Sólo las entregas de esta URL de webhook (id de list_webhooks).",
      "type": "string",
      "minLength": 1
    },
    "status": {
      "description": "Filtra por estado: pending, delivered o failed.",
      "type": "string",
      "enum": [
        "pending",
        "delivered",
        "failed"
      ]
    },
    "limit": {
      "default": 20,
      "description": "Cuántas entregas devolver (1 a 100).",
      "type": "integer",
      "minimum": 1,
      "maximum": 100
    }
  }
}
```
