> ## 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.

# Accreditation and access control

> Redeeming tickets for wristbands and controlling zone access from your own application.

VENTRY has **two distinct door operations**, and you probably need both:

<CardGroup cols={2}>
  <Card title="Accreditation" icon="id-card">
    Happens once per attendee. The ticket QR is read and a physical wristband is
    linked to it. From then on, the wristband is their identity.
  </Card>

  <Card title="Zone control" icon="shield-check">
    Happens every time someone crosses an inner door. Only the wristband is read,
    and it is checked against that zone.
  </Card>
</CardGroup>

**Scopes needed:** `checkins.write` (requires manual activation) and, to read the
history, `checkins.read`.

<Warning>
  These calls go **from your server**, never straight from the door application. An
  API key embedded in a mobile app is, for practical purposes, published. Have your
  app talk to your backend and let the backend call VENTRY.
</Warning>

## Accreditation

The operator scans the ticket QR and then the new wristband:

<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"
  }
}
```

The wristband is born with the **zones** of the ticket type plus that specific
ticket's `extra_zones`, and with its **valid days**. There is nothing to
configure: it all comes from the ticket.

| Field                   | Notes                                                                     |
| ----------------------- | ------------------------------------------------------------------------- |
| `wristband.nfc`         | Required. The identifier read from the chip.                              |
| `wristband.uhf`         | Optional. If the wristband has no UHF, the NFC is reused.                 |
| `originality_signature` | Optional. Chip originality signature, in hexadecimal.                     |
| `originality_verified`  | Optional. Whether that signature verified against the manufacturer's key. |

<Tip>
  Send the originality signature even when it fails to verify. It is stored anyway,
  and it is what lets the organiser audit afterwards which wristbands actually got
  into the event.
</Tip>

### Retries

The `Idempotency-Key` identifies the **physical scan**. Generate it when the QR
is read and keep it for every retry of that accreditation:

<CodeGroup>
  ```js Node.js theme={null}
  const scanId = crypto.randomUUID();   // once, when the QR is read
  await withRetries(() => checkIn(ticketCode, nfc, scanId));
  ```

  ```python Python theme={null}
  scan_id = str(uuid.uuid4())   # once, when the QR is read
  with_retries(lambda: check_in(ticket_code, nfc, scan_id))
  ```

  ```php PHP theme={null}
  $scanId = bin2hex(random_bytes(16));   // once, when the QR is read
  withRetries(fn () => accredit($ticketCode, $nfc, $scanId));
  ```
</CodeGroup>

If the network drops right after VENTRY redeems the ticket, the retry returns
**the same wristband** instead of issuing a second one.

### What can go wrong

| Response                     | What happened                                           | What to do                      |
| ---------------------------- | ------------------------------------------------------- | ------------------------------- |
| `404 ticket_not_found`       | The code does not exist, or is outside your key's scope | Refuse entry                    |
| `409 wristband_in_use`       | That wristband is already assigned to another attendee  | Take another wristband          |
| `409 ticket_already_claimed` | **The ticket was already accredited elsewhere**         | See below                       |
| `422 checkin_failed`         | Could not be completed                                  | Retry; if it persists, escalate |

<Warning>
  **Double accreditation.** When two doors try to accredit the same ticket, only
  one wins. The second gets `409 ticket_already_claimed` and the wristband just
  issued is left in **`pending_review`**: it can be scanned — so security is
  alerted — but it cannot spend balance.

  VENTRY does not decide blindly which one is right: it may be the same operator
  retrying at another door, or a duplicated QR. A supervisor resolves it from the
  dashboard. Your application should show the message and ask staff to call someone,
  not retry.
</Warning>

## Zone control

The operator scans only the wristband, at the door of an inner zone:

<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": "VIP pass",
  "wristband_status": "active"
}
```

<Note>
  **Denying is not an error.** The response is always `200`, with `allowed: true` or
  `false` and the reason. A `403` would mean your API key lacks permission, which is
  something else entirely: if you lump both cases into the same `catch`, you will
  end up denying legitimate entries when your key expires.
</Note>

Denial reasons you will see (returned in Spanish):

| `reason`                                    | Situation                                       |
| ------------------------------------------- | ----------------------------------------------- |
| `Pulsera no encontrada`                     | That code matches no wristband                  |
| `Zona no permitida`                         | Their ticket does not grant access to that zone |
| `Fuera de fecha`                            | The wristband is not valid for the current day  |
| `Pulsera blocked` / `lost`                  | Blocked or reported lost                        |
| `Pulsera en revisión — avisar a supervisor` | Unresolved double accreditation                 |

Every call is recorded in the scan history, so the operation shows up in the
organiser's statistics attributed to your integration.

## Reading the history

```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": "Friday" },
      "device_id": "6a3d0620a1e963300ee2cb99",
      "occurred_at": "2026-06-12T19:42:11.004Z"
    }
  ],
  "has_more": false,
  "next_cursor": null
}
```

## Reference implementation

<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",
  };

  /** Called by your door app. `scanId` is generated by the app when the QR is read. */
  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: "Already accredited. Call a supervisor." };
      case "wristband_in_use":
        return { ok: false, retryable: true, message: "Wristband in use. Take another." };
      case "ticket_not_found":
        return { ok: false, message: "Invalid ticket." };
      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 }),
    });

    // A failure here is an integration failure, not a denial: do not turn it into
    // "no entry" without distinguishing them, or an expired key will shut the door
    // on everyone.
    if (!response.ok) throw new Error("VENTRY unavailable");

    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):
      """Called by your door app. `scan_id` is generated when the QR is read."""
      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": "Already accredited. Call a supervisor."}
      if error["code"] == "wristband_in_use":
          return {"ok": False, "retryable": True,
                  "message": "Wristband in use. Take another."}
      if error["code"] == "ticket_not_found":
          return {"ok": False, "message": "Invalid ticket."}

      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,
      )

      # A failure here is an integration failure, not a denial: do not turn it into
      # "no entry" without distinguishing them, or an expired key will shut the
      # door on everyone.
      response.raise_for_status()

      return response.json()
  ```

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

  /** Called by your door app. $scanId is generated when the QR is read. */
  function accredit(string $ticketCode, string $nfc, string $scanId, ?string $uhf = null): array
  {
      $wristband = ["nfc" => $nfc];
      if ($uhf !== null) {
          $wristband["uhf"] = $uhf;
      }

      try {
          // callVentry(): see the implementation on the Errors page.
          $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" => "Already accredited. Call a supervisor.",
              ],
              "wristband_in_use" => [
                  "ok" => false,
                  "retryable" => true,
                  "message" => "Wristband in use. Take another.",
              ],
              "ticket_not_found" => ["ok" => false, "message" => "Invalid ticket."],
              default => [
                  "ok" => false,
                  "message" => $e->getMessage(),
                  "request_id" => $e->requestId,
              ],
          };
      }
  }

  function canEnterZone(string $nfc, string $zoneId): array
  {
      // A failure here is an integration failure, not a denial: do not turn it into
      // "no entry" without distinguishing them, or an expired key will shut the
      // door on everyone.
      return callVentry("POST", "/access-checks", [
          "headers" => ["Content-Type: application/json"],
          "body" => json_encode(["wristband_code" => $nfc, "zone" => $zoneId]),
      ]);
  }
  ```
</CodeGroup>
