> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ventry.es/llms.txt
> Use this file to discover all available pages before exploring further.

# Acreditar y validar accesos

> Canjear entradas por pulseras y controlar el acceso a zonas desde tu propia aplicación.

En VENTRY hay **dos operaciones distintas** en la puerta, y probablemente
necesites las dos:

<CardGroup cols={2}>
  <Card title="Acreditación" icon="id-card">
    Sucede una vez por asistente. Se lee el QR de la entrada y se le vincula una
    pulsera física. A partir de ahí, la pulsera es su identidad.
  </Card>

  <Card title="Control de zona" icon="shield-check">
    Sucede cada vez que alguien cruza una puerta interior. Se lee sólo la
    pulsera y se comprueba si puede pasar a esa zona.
  </Card>
</CardGroup>

**Permisos necesarios:** `checkins.write` (requiere activación manual) y, para
consultar el histórico, `checkins.read`.

<Warning>
  Estas llamadas van **desde tu servidor**, nunca desde la aplicación de puerta
  directamente. Una API key incrustada en una app móvil está, a efectos prácticos,
  publicada. Que tu app hable con tu backend y sea éste quien llame a VENTRY.
</Warning>

## Acreditación

El operario escanea el QR de la entrada y después la pulsera nueva:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.ventry.es/v1/tickets/TR-84213-001/check-in \
    -H "Authorization: Bearer $VENTRY_KEY" \
    -H "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7" \
    -H "Content-Type: application/json" \
    -d '{
      "wristband": { "nfc": "04A2B1C3D4E580", "uhf": "E28068940000501234567890" },
      "originality_signature": "3045022100AB...",
      "originality_verified": true
    }'
  ```

  ```js Node.js theme={null}
  await fetch("https://api.ventry.es/v1/tickets/TR-84213-001/check-in", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.VENTRY_KEY}`,
      "Idempotency-Key": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      wristband: { nfc: "04A2B1C3D4E580", uhf: "E28068940000501234567890" },
      originality_signature: "3045022100AB...",
      originality_verified: true,
    }),
  });
  ```

  ```python Python theme={null}
  requests.post(
      "https://api.ventry.es/v1/tickets/TR-84213-001/check-in",
      headers={
          "Authorization": f"Bearer {os.environ['VENTRY_KEY']}",
          "Idempotency-Key": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
      },
      json={
          "wristband": {"nfc": "04A2B1C3D4E580", "uhf": "E28068940000501234567890"},
          "originality_signature": "3045022100AB...",
          "originality_verified": True,
      },
      timeout=15,
  )
  ```

  ```php PHP theme={null}
  <?php
  callVentry("POST", "/tickets/TR-84213-001/check-in", [
      "headers" => [
          "Idempotency-Key: 7c9e6679-7425-40de-944b-e07fc1f90ae7",
          "Content-Type: application/json",
      ],
      "body" => json_encode([
          "wristband" => [
              "nfc" => "04A2B1C3D4E580",
              "uhf" => "E28068940000501234567890",
          ],
          "originality_signature" => "3045022100AB...",
          "originality_verified" => true,
      ]),
  ]);
  ```
</CodeGroup>

```json theme={null}
{
  "object": "checkin_result",
  "ticket_code": "TR-84213-001",
  "wristband": {
    "id": "6a3d0712a1e963300ee2cc21",
    "nfc": "04A2B1C3D4E580",
    "uhf": "E28068940000501234567890",
    "status": "active"
  }
}
```

La pulsera nace con las **zonas** del tipo de entrada más las `extra_zones` de
esa entrada concreta, y con sus **días de validez**. No hay que configurarla:
todo sale de la entrada.

| Campo                   | Notas                                                           |
| ----------------------- | --------------------------------------------------------------- |
| `wristband.nfc`         | Obligatorio. El identificador leído del chip.                   |
| `wristband.uhf`         | Opcional. Si la pulsera no tiene UHF, se reutiliza el NFC.      |
| `originality_signature` | Opcional. Firma de originalidad del chip, en hexadecimal.       |
| `originality_verified`  | Opcional. Si esa firma verificó contra la clave del fabricante. |

<Tip>
  Manda la firma de originalidad aunque no verifique. Se guarda igualmente, y es lo
  que permite auditar después qué parque de pulseras entró realmente al evento.
</Tip>

### Reintentos

La `Idempotency-Key` identifica el **escaneo físico**. Genérala al leer el QR y
consérvala durante todos los reintentos de esa acreditación:

```js theme={null}
const scanId = crypto.randomUUID();   // una vez, al leer el QR
await withRetries(() => checkIn(ticketCode, nfc, scanId));
```

Si la red se corta justo después de que VENTRY canjee la entrada, el reintento
devuelve **la misma pulsera** en lugar de emitir una segunda.

### Qué puede salir mal

| Respuesta                    | Qué ha pasado                                            | Qué hacer                       |
| ---------------------------- | -------------------------------------------------------- | ------------------------------- |
| `404 ticket_not_found`       | El código no existe, o está fuera del ámbito de tu clave | Rechazar el acceso              |
| `409 wristband_in_use`       | Esa pulsera ya está asignada a otro asistente            | Coger otra pulsera              |
| `409 ticket_already_claimed` | **La entrada ya se acreditó en otro punto**              | Ver abajo                       |
| `422 checkin_failed`         | No se pudo completar                                     | Reintentar; si persiste, avisar |

<Warning>
  **Doble acreditación.** Cuando dos puertas intentan acreditar la misma entrada,
  sólo una gana. La segunda recibe `409 ticket_already_claimed` y la pulsera que se
  acaba de emitir queda en estado **`pending_review`**: se puede escanear —para que
  seguridad se entere— pero no gastar saldo.

  VENTRY no decide a ciegas cuál es la buena: puede ser el mismo operario
  reintentando en otra puerta, o un QR duplicado. Lo resuelve un supervisor desde
  el panel. Tu aplicación debe mostrar el mensaje y pedir que avisen a un
  responsable, no reintentar.
</Warning>

## Control de zona

El operario escanea sólo la pulsera, en la puerta de una zona interior:

<CodeGroup>
  ```bash cURL theme={null}
  curl -X POST https://api.ventry.es/v1/access-checks \
    -H "Authorization: Bearer $VENTRY_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "wristband_code": "04A2B1C3D4E580",
      "zone": "6a3d2a6ea1e963300ee2c99d"
    }'
  ```

  ```js Node.js theme={null}
  const check = await (
    await fetch("https://api.ventry.es/v1/access-checks", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.VENTRY_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        wristband_code: "04A2B1C3D4E580",
        zone: "6a3d2a6ea1e963300ee2c99d",
      }),
    })
  ).json();
  ```

  ```python Python theme={null}
  check = requests.post(
      "https://api.ventry.es/v1/access-checks",
      headers={"Authorization": f"Bearer {os.environ['VENTRY_KEY']}"},
      json={
          "wristband_code": "04A2B1C3D4E580",
          "zone": "6a3d2a6ea1e963300ee2c99d",
      },
      timeout=10,
  ).json()
  ```

  ```php PHP theme={null}
  $check = callVentry("POST", "/access-checks", [
      "headers" => ["Content-Type: application/json"],
      "body" => json_encode([
          "wristband_code" => "04A2B1C3D4E580",
          "zone" => "6a3d2a6ea1e963300ee2c99d",
      ]),
  ]);
  ```
</CodeGroup>

```json theme={null}
{
  "object": "access_check",
  "allowed": true,
  "reason": "Acceso permitido",
  "attendee_name": "Ada Lovelace",
  "ticket_type": "Abono VIP",
  "wristband_status": "active"
}
```

<Note>
  **Denegar no es un error.** La respuesta es siempre `200`, con `allowed: true` o
  `false` y el motivo. Un `403` significaría que tu API key no tiene permiso, que
  es otra cosa completamente distinta: si mezclas ambos casos en el mismo `catch`,
  acabarás denegando accesos legítimos cuando caduque tu clave.
</Note>

Motivos de denegación que verás:

| `reason`                                    | Situación                                    |
| ------------------------------------------- | -------------------------------------------- |
| `Pulsera no encontrada`                     | Ese código no corresponde a ninguna pulsera  |
| `Zona no permitida`                         | Su entrada no da acceso a esa zona           |
| `Fuera de fecha`                            | La pulsera no es válida para el día en curso |
| `Pulsera blocked` / `lost`                  | Bloqueada o dada por perdida                 |
| `Pulsera en revisión — avisar a supervisor` | Doble acreditación sin resolver              |

Cada llamada queda registrada en el histórico de escaneos, así que la operación
aparece en las estadísticas del organizador atribuida a tu integración.

## Consultar el histórico

```bash theme={null}
curl "https://api.ventry.es/v1/checkins?day=6a3d0512...&result=denied&limit=200" \
  -H "Authorization: Bearer $VENTRY_KEY"
```

```json theme={null}
{
  "object": "list",
  "data": [
    {
      "object": "checkin",
      "id": "6a3d0819a1e963300ee2cd44",
      "ticket_code": "TR-84213-092",
      "result": "denied",
      "denial_reason": "Entrada ya utilizada",
      "day": { "id": "6a3d0512...", "name": "Viernes" },
      "device_id": "6a3d0620a1e963300ee2cb99",
      "occurred_at": "2026-06-12T19:42:11.004Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

## Implementación de referencia

<CodeGroup>
  ```js Node.js theme={null}
  const VENTRY = "https://api.ventry.es/v1";
  const headers = {
    Authorization: `Bearer ${process.env.VENTRY_KEY}`,
    "Content-Type": "application/json",
  };

  /** Llamado por tu app de puerta. `scanId` lo genera la app al leer el QR. */
  export async function accredit({ ticketCode, nfc, uhf, scanId }) {
    const response = await fetch(`${VENTRY}/tickets/${ticketCode}/check-in`, {
      method: "POST",
      headers: { ...headers, "Idempotency-Key": scanId },
      body: JSON.stringify({ wristband: { nfc, ...(uhf && { uhf }) } }),
    });

    if (response.ok) {
      return { ok: true, wristband: (await response.json()).wristband };
    }

    const { error } = await response.json();

    switch (error.code) {
      case "ticket_already_claimed":
        return { ok: false, escalate: true, message: "Ya acreditada. Avisa a un supervisor." };
      case "wristband_in_use":
        return { ok: false, retryable: true, message: "Pulsera en uso. Coge otra." };
      case "ticket_not_found":
        return { ok: false, message: "Entrada no válida." };
      default:
        return { ok: false, message: error.message, requestId: error.request_id };
    }
  }

  export async function canEnterZone(nfc, zoneId) {
    const response = await fetch(`${VENTRY}/access-checks`, {
      method: "POST",
      headers,
      body: JSON.stringify({ wristband_code: nfc, zone: zoneId }),
    });

    // Un fallo aquí es un fallo de la integración, no una denegación: no lo
    // conviertas en "no pasa" sin distinguirlo, o un token caducado cerrará la
    // puerta a todo el mundo.
    if (!response.ok) throw new Error("VENTRY no disponible");

    return response.json();
  }
  ```

  ```python Python theme={null}
  import os, requests

  VENTRY = "https://api.ventry.es/v1"
  HEADERS = {"Authorization": f"Bearer {os.environ['VENTRY_KEY']}"}

  def accredit(ticket_code, nfc, scan_id, uhf=None):
      """Llamado por tu app de puerta. `scan_id` lo genera la app al leer el QR."""
      wristband = {"nfc": nfc}
      if uhf:
          wristband["uhf"] = uhf

      response = requests.post(
          f"{VENTRY}/tickets/{ticket_code}/check-in",
          headers={**HEADERS, "Idempotency-Key": scan_id},
          json={"wristband": wristband},
          timeout=15,
      )

      if response.ok:
          return {"ok": True, "wristband": response.json()["wristband"]}

      error = response.json()["error"]

      if error["code"] == "ticket_already_claimed":
          return {"ok": False, "escalate": True,
                  "message": "Ya acreditada. Avisa a un supervisor."}
      if error["code"] == "wristband_in_use":
          return {"ok": False, "retryable": True,
                  "message": "Pulsera en uso. Coge otra."}
      if error["code"] == "ticket_not_found":
          return {"ok": False, "message": "Entrada no válida."}

      return {"ok": False, "message": error["message"],
              "request_id": error["request_id"]}

  def can_enter_zone(nfc, zone_id):
      response = requests.post(
          f"{VENTRY}/access-checks",
          headers=HEADERS,
          json={"wristband_code": nfc, "zone": zone_id},
          timeout=10,
      )

      # Un fallo aquí es un fallo de la integración, no una denegación: no lo
      # conviertas en "no pasa" sin distinguirlo, o un token caducado cerrará la
      # puerta a todo el mundo.
      response.raise_for_status()

      return response.json()
  ```

  ```php PHP theme={null}
  <?php
  const VENTRY = "https://api.ventry.es/v1";

  /** Llamado por tu app de puerta. $scanId lo genera la app al leer el QR. */
  function accredit(string $ticketCode, string $nfc, string $scanId, ?string $uhf = null): array
  {
      $wristband = ["nfc" => $nfc];
      if ($uhf !== null) {
          $wristband["uhf"] = $uhf;
      }

      try {
          // callVentry(): ver la implementación en la página de Errores.
          $result = callVentry("POST", "/tickets/$ticketCode/check-in", [
              "headers" => [
                  "Idempotency-Key: $scanId",
                  "Content-Type: application/json",
              ],
              "body" => json_encode(["wristband" => $wristband]),
          ]);

          return ["ok" => true, "wristband" => $result["wristband"]];
      } catch (VentryApiError $e) {
          return match ($e->code) {
              "ticket_already_claimed" => [
                  "ok" => false,
                  "escalate" => true,
                  "message" => "Ya acreditada. Avisa a un supervisor.",
              ],
              "wristband_in_use" => [
                  "ok" => false,
                  "retryable" => true,
                  "message" => "Pulsera en uso. Coge otra.",
              ],
              "ticket_not_found" => ["ok" => false, "message" => "Entrada no válida."],
              default => [
                  "ok" => false,
                  "message" => $e->getMessage(),
                  "request_id" => $e->requestId,
              ],
          };
      }
  }

  function canEnterZone(string $nfc, string $zoneId): array
  {
      // Un fallo aquí es un fallo de la integración, no una denegación: no lo
      // conviertas en "no pasa" sin distinguirlo, o un token caducado cerrará la
      // puerta a todo el mundo.
      return callVentry("POST", "/access-checks", [
          "headers" => ["Content-Type: application/json"],
          "body" => json_encode(["wristband_code" => $nfc, "zone" => $zoneId]),
      ]);
  }
  ```
</CodeGroup>
