# Scanning & Check-in

When the doors open you need a fast, reliable way to decide one thing for every
person walking up: **let them in, or not?** The scanning endpoints answer exactly
that. You send the QR string read off a ticket and get back a clear verdict —
valid, already used, refunded, or not found — plus the ticket's tier so your
staff know which section it's for.

Every ticket is tamper-proof, so you can trust the verdict without any extra
checks of your own. That covers **both shapes of code a holder can present**:
the opaque QR string you deliver yourself, and the live, refreshing code shown
for tickets held in a **TicketConnect ticket wallet** — the platform verifies
the latter cryptographically on your behalf. None of that machinery is visible
to you: you send the scanned string, you get a verdict.

## Scopes

All three endpoints on this page require the **`scanning:write`** scope.

| Endpoint | Method | Scope |
| --- | --- | --- |
| `/v1/scan/validate` | `POST` | `scanning:write` |
| `/v1/scan/batch` | `POST` | `scanning:write` |
| `/v1/tickets/{id}/attendance` | `POST` | `scanning:write` |

## Online vs. offline check-in

There are two ways to check someone in, and they map to two different endpoints:

* **Validate (online):** `POST /v1/scan/validate` (and its batch sibling) looks a
  ticket up by its QR string and returns a verdict. It is the right call when
  your scanner has a live connection. It **does not** mark the ticket as used —
  it only reports whether the ticket is currently valid.
* **Mark attendance:** `POST /v1/tickets/{id}/attendance` is what actually
  records the check-in and flips the ticket to `used`. This is the
  state-changing step.

For a venue with a steady connection, the simplest flow is: read the QR, call
**attendance** by ticket id, and admit on a `used` result.

For an **offline** scanner (spotty WiFi at the gate, a remote field, etc.), let
the device validate against a cached ticket list locally, queue every scan, and
then **drain the queue** with `POST /v1/scan/batch` once it's back online. Batch
validation tells you, per QR, which scans were good and which weren't, so you can
reconcile after the fact.

## Validate a single ticket at the door

Send the QR string exactly as your scanner read it. Don't parse, trim, or
re-encode it — a live wallet code is longer and looks like a JSON blob; it
must arrive verbatim.

```bash
curl https://api.ticketconnect.example/v1/scan/validate \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "qr": "QR_STRING_FROM_THE_TICKET"
  }'
```

A valid ticket returns:

```json
{
  "valid": true,
  "status": "valid",
  "tier": "General Admission",
  "ticket_id": "tkt_8f2c..."
}
```

A ticket that has already been scanned in returns a verdict you can show your
staff directly:

```json
{
  "valid": false,
  "reason": "already_used",
  "tier": "General Admission",
  "ticket_id": "tkt_8f2c..."
}
```

The possible `reason` values for an invalid result are `not_found`, `refunded`,
`revoked`, `already_used`, and — for live wallet codes — `code_expired`,
`live_code_required`, and `invalid_qr` (see below). A `not_found` result
carries no `ticket_id`.

> **Note:** `POST /v1/scan/validate` is read-only — it never changes the ticket. Use it to
> preview a verdict, then call the attendance endpoint to actually check the person
> in.

## Live codes from wallet-held tickets

Some holders keep their ticket in a **TicketConnect ticket wallet** — any
holder experience built on the TicketConnect rails, the TicketConnect app
being the reference one — instead of (or besides) whatever you delivered. The
wallet shows a **live code that refreshes every few seconds** — an
anti-screenshot measure — and your scanning endpoints validate it exactly like
any other code: send the scanned string verbatim, get a verdict. The platform
checks the code's authenticity and freshness cryptographically before the
usual lifecycle verdict.

Three extra `reason` values exist only for these codes, and each maps to a
clear instruction for door staff:

| `reason` | What happened | What staff should do |
| --- | --- | --- |
| `code_expired` | The code was real but too old — typically a screenshot or a phone that has been sitting on the same frame. | Ask the holder to reopen the ticket in their wallet and re-scan. |
| `live_code_required` | A static copy of a wallet ticket was presented for an event that requires the live, refreshing code. | Refuse — ask for the live code in the wallet (or scan the code **you** delivered, which stays valid). |
| `invalid_qr` | The code failed cryptographic verification — tampered or forged. | Refuse entry. |

Freshness comes before lifecycle: a stale code on an already-used ticket reads
`code_expired`, not `already_used`. Have the holder refresh, re-scan, and trust
the second verdict.

The static QR string you deliver (`qr.data`) is unaffected by any of this — it
keeps validating by itself, side by side with the wallet's live code.

Live codes are **on by default for every event you create** and togglable per
event: set `live_codes: false` at creation or flip it later with
`PATCH /v1/events/{id}` (see the
[events API reference](#api-events)). With live codes off, wallet-held tickets
show a static code and none of the extra `reason` values above can occur.

## Batch / offline draining

When an offline scanner reconnects, send everything it collected in one call.
Pass an array of QR strings in `qrs` (up to **200** per request).

```bash
curl https://api.ticketconnect.example/v1/scan/batch \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json" \
  -d '{
    "qrs": [
      "QR_STRING_1",
      "QR_STRING_2",
      "QR_STRING_3"
    ]
  }'
```

You get one verdict per QR, in a `results` array:

```json
{
  "results": [
    { "qr": "QR_STRING_1", "valid": true,  "status": "valid", "tier": "VIP", "ticket_id": "tkt_a1..." },
    { "qr": "QR_STRING_2", "valid": false, "reason": "already_used", "tier": "VIP", "ticket_id": "tkt_b2..." },
    { "qr": "QR_STRING_3", "valid": false, "reason": "not_found" }
  ]
}
```

> **Warning:** A single batch may contain at most **200** entries. Larger backlogs should be
> split into multiple requests. Like `validate`, batch is a read-only check — it
> reports verdicts but does not mark tickets used.


## Mark attendance by ticket id

This is the call that records the check-in. Use the ticket's id (not the QR
string), which you have from issuance, from a list, or from a `validate`
response.

```bash
curl https://api.ticketconnect.example/v1/tickets/tkt_8f2c.../attendance \
  -X POST \
  -H "Authorization: Bearer sk_test_..." \
  -H "Content-Type: application/json"
```

A first successful check-in returns:

```json
{ "id": "tkt_8f2c...", "status": "used" }
```

### Idempotent re-scans

Door scanning is messy — the same person gets scanned twice, a request times out
and the device retries, two lanes scan the same group. The attendance endpoint is
built for this:

* Calling it again on a ticket that is **already** checked in is a **safe
  no-op**. Instead of an error, you get a `200` with `status: "already_used"`:

  ```json
  { "id": "tkt_8f2c...", "status": "already_used" }
  ```

* For exactly-once semantics on a flaky connection, send an `Idempotency-Key`
  header. A replay with the same key returns the original response rather than
  processing the check-in twice.

  ```bash
  curl https://api.ticketconnect.example/v1/tickets/tkt_8f2c.../attendance \
    -X POST \
    -H "Authorization: Bearer sk_test_..." \
    -H "Idempotency-Key: door-lane-2-scan-90183" \
    -H "Content-Type: application/json"
  ```

A successful check-in also triggers an `attendance.verified` webhook, so any
back-office dashboards stay in sync automatically.

> **Note:** A ticket that doesn't exist (or isn't yours) returns `404` with a
> `resource_missing` error. A refunded ticket is reported as invalid by
> `validate`; don't mark it attended.


## See also

* [Webhooks](#webhooks) — receive `attendance.verified` in real time.
* [Marketplace & resale](#marketplace-and-resale) — secondary listings.
* [API reference](#api-overview) — full endpoint details.
