# Offline scanning

Venue Wi-Fi fails at the worst moment: thousands of phones in one room, a
basement club, a field with one bar of signal. An online gate
([Scanning & Check-in](#scanning-and-check-in)) stops when the network does.
TicketConnect's **door protocol** keeps it running: a scanner device downloads
what it needs before doors open, decides at the gate with **no network at
all**, and uploads a signed log of every admission when it reconnects.

The protocol is public on `/v1` under **`/v1/scanner/*`**, and the
**`@ticketconnect/offline-scanner`** SDK implements the device side for you.
Gettix's staff mode (Gettix is our flagship licensee, built on TicketConnect)
runs on this same protocol. Every endpoint is documented in the
[Scanner protocol reference](#api-scanner-protocol).

## Which codes can be scanned offline

The offline gate accepts **both shapes of code** a holder can present:

* **TicketConnect-signed wallet codes.** This is the signed code shown by a
  holder's ticket wallet on the TicketConnect rails (Gettix is the reference
  wallet), including its live, refreshing variant. The device verifies it
  cryptographically with the event key, and checks live codes for freshness.
* **Your opaque `qr.data`.** This is the code you deliver through `/v1`. It
  carries no signature. Instead, the ticket feed includes a `barcode_sha256`
  for each ticket: the SHA-256 of the barcode, never the barcode itself. Any
  scanned string that doesn't start with `{` is treated as an opaque code: the
  device hashes it exactly as read and looks the hash up in its armed ticket
  list. An unknown hash is `ticket_not_found`. Opaque codes skip the live-code
  check, just as they do on the online gate.

> **Caveat: an opaque code is only as fresh as the last ticket sync.** While
> online, the device refreshes its ticket list every 60 seconds. If a transfer
> rotates a ticket's barcode after the device's last refresh, the new code is
> unknown to that gate, and the old one still matches, until the next refresh.
> A signed wallet code doesn't have this gap because it is verified on its own
> merits. That makes wallet codes the stronger option at an offline door.

## Trust model in plain language

No single piece of the gate relies on the network or on trusting the phone:

* **Session tokens, minted server-side.** Your backend calls
  `POST /v1/scanner/sessions` with its secret `sk_` key and passes the
  resulting **scanner session token** to the device. **Never ship an `sk_` key
  to a device.** This is the same pattern as Stripe Terminal connection tokens.
  A session covers one staff member and one event, and the event comes from the
  token itself, so no device route takes an event id. A session for event A
  cannot read or write event B. The token lasts at most 12 hours and never
  outlives the event's grace cutoff. The SDK keeps it in your secure key-value
  store, never in the scan database.
* **Sessions carry only door capabilities.** A scanner session can scan
  tickets and redeem perks, nothing else. Selling, staff management and stats
  are always off in it, and it is refused (`401`) everywhere except the
  `/v1/scanner` device endpoints.
* **Live permissions.** On every request, a session's effective permissions
  are what the token grants **and** what the staff member currently has.
  Turning off `can_scan_tickets` or `can_redeem_perks`, deactivating or
  suspending the staff member, unassigning them from the event, suspending
  your account, or revoking the API key that minted the session takes effect
  within about 60 seconds. The device then gets `401`, or its pushed rows come
  back `rejected` with reason `staff_lacks_permission`.
* **Per-event signing keys.** Every event has its own Ed25519 key pair. Ticket
  codes are signed with the private key, which never leaves the server. Devices
  verify them with the event's public key from the manifest.
* **Device keys and device certificates.** The device generates an Ed25519
  key for each staff member who signs in on it and registers the public half.
  Arming fails until registration succeeds. The platform returns a
  **platform-signed device certificate** scoped to that event, listing the
  device's **capabilities** (`scan`, `perk` or both, following the staff
  member's permissions). It expires at the event's grace cutoff or after 7
  days, whichever comes first. Nearby scanners check each other's certificates
  against the platform key (which the SDK pins on first sight and never lets
  change) and drop packet types the certificate doesn't grant, so a phone
  can't pose as a gate and a perk-only phone can't mark tickets used. A staff
  member can hold at most **10 active devices per event**.
* **A local used-set that only moves forward.** Admitting a ticket marks it
  used and appends the device-signed scan record in **one transaction**, so a
  crash can't admit someone without leaving the signed record that reconciles
  it. A server refresh can mark a ticket used but never makes it unused again,
  because the server may not have heard about a local or gossiped admission
  yet.
* **Signed reconciliation, earliest wins.** When a device syncs, the server
  checks that each row belongs to the session's staff member and one of their
  own devices, then verifies its Ed25519 signature against that device's
  registered key. Each row is stored as pending, its effect is applied, and
  only then is it final. Resending an identical row returns its stored outcome
  (or finishes it, if an earlier attempt was cut off) and changes nothing
  else, so retries are safe. If two gates admitted the same ticket within the
  **10-second gossip window**, the earlier scan wins and the later one is
  `superseded`. Scans of the same ticket more than 10 seconds apart are both
  flagged `suspect_duplicate` for your review.
* **Revocation of tickets and devices.** Revoke a ticket
  (`POST /v1/tickets/{id}/revoke`) and it leaves the valid-ticket feed and
  appears in the revoked-ticket feed. Revoke a lost phone
  (`POST /v1/staff/{id}/devices/{device_id}/revoke`) and its signed scans are
  rejected at reconciliation, while armed peers drop its gossip. Deactivating,
  suspending or deleting a staff member, or removing their `can_scan_tickets`
  or `can_redeem_perks`, revokes all of their devices. Unassigning them from
  an event revokes their devices **for that event only**; the devices keep
  working at their other events. Revocation is permanent but **not
  retroactive**: rows the device signed up to 60 seconds after the revocation
  are still processed (and flagged for your review), and only later rows are
  rejected as `device_revoked`. The same applies to scans and redemptions
  made before a permission was removed. On `device_revoked` or
  `device_conflict` during registration, the SDK generates a new device
  identity and registers again.
* **A grace cutoff.** The scanning window closes at **event end + 24 hours**.
  After that, new sessions return `410 event_ended`, the server rejects late
  scans with `event_ended`, and the SDK refuses to scan.
* **Live rotating codes.** On events with live codes, a holder's code
  refreshes every 30 seconds and is signed with a key only that ticket has.
  The device verifies it with the **per-ticket public key** delivered in the
  ticket feed (`rot_pub_key`). The rotation master key never leaves the
  server, so an armed phone can't forge a live code.

## Arming sequence

Arm each device while it is still online, before doors open. `arm()` in the
SDK does all of this; a custom client must follow the same order:

1. **Session:** get a token from your backend (which called
   `POST /v1/scanner/sessions`).
2. **Manifest:** `GET /v1/scanner/manifest` returns the event id (check that it
   matches), the event public key, the platform certificate key, the event end
   and grace cutoff, the live-code settings, and the session's permissions.
3. **Device:** load the device key for this staff member (or generate one),
   then `POST /v1/scanner/devices` to register it and receive a device
   certificate. Pin the platform certificate key once. Registration is
   required: if it fails, `arm()` rejects and the gate stays `not_armed`. On
   `409 device_conflict` or `403 device_revoked` the SDK creates a new
   identity and retries once.
4. **Revoked devices:** read the full list for this event from
   `GET /v1/scanner/devices/revoked`.
5. **Valid tickets:** page through all of `GET /v1/scanner/tickets`.
6. **Revoked tickets:** page through the full list from
   `GET /v1/scanner/tickets/revoked`.
7. **Perk grants:** page through `GET /v1/scanner/perk-grants`.
8. **Used-set:** pull what other gates already admitted, using
   `GET /v1/scanner/scans` and `GET /v1/scanner/perk-redemptions`.

## Scan decision order

`scanTicket(qr)` runs these checks in order and stops at the first failure.
Everything here runs on the device.

| # | Check | Rejection `reason` |
| --- | --- | --- |
| 1 | The device is armed | `not_armed` |
| 2 | The session grants `scan_tickets` (no recorded permissions counts as no) | `permission_denied` |
| 3 | Now is before the grace cutoff (event end + 24 h) | `event_ended` |
| 4 | QR envelope: JSON, required fields, a valid Ed25519 signature from the event key, not past `validUntil` | `malformed_qr`, `missing_required_fields`, `verify_threw`, `bad_signature`, `expired` |
| 5 | The QR's event is this event | `wrong_event` |
| 6 | The ticket is known locally | `ticket_not_found` |
| 7 | On events that enforce live codes: the per-ticket key is present and the live code is fresh (current 30-second step ±1) | `rot_key_unavailable`, `rot_missing`, `rot_malformed`, `rot_stale`, `rot_mismatch` |
| 8 | The ticket is not revoked (revocation outranks "used") | `revoked` |
| 9 | Sign the scan with the device key | `device_key_error` |
| 10 | **Atomically** mark the ticket used and append the signed scan | `already_used`, `unknown_ticket` |

An opaque code (anything that doesn't start with `{`) replaces checks 4 to 7
with the `barcode_sha256` lookup: an unknown hash is `ticket_not_found`, and a
known one continues at check 8.

Success returns `{ ok: true, ticketId, seatNumber, … }` (`qr` is `null` for an
opaque code). After an admission the device broadcasts a gossip packet and
starts a best-effort sync. `redeemPerk(qr)` follows the same pattern, with
`permission_denied` when the session lacks `redeem_perks`.

## Sync order

`startSync()` runs a sync immediately and then every 15 seconds. Each run goes
in this order, so local admissions reach the server before any server state is
applied:

1. Push pending scans: `POST /v1/scanner/scans`.
2. Push pending perk redemptions: `POST /v1/scanner/perk-redemptions`.
3. Refresh valid tickets (at most once every 60 seconds).
4. Pull the revoked-ticket delta (every run).
5. Pull the revoked-device delta (every run).
6. Refresh perk grants (at most once every 60 seconds).
7. Pull other gates' scans, then their perk redemptions.

If either push fails with a network error, `408`, `429` or a `5xx`, the rows
stay pending and that run skips both refreshes (steps 3 and 6), because the
server's view would lag the unpushed rows. The revocation pulls still run.
Any other whole-batch `4xx` (for example `400`) is final: the batch's rows are
marked rejected with the error code, local used state is kept, and a
`push_rejected` alert with `final: true` is raised through `onAlert`. Per-row
outcomes are final too, except `staff_mismatch`, which should never happen:
that row stays pending and raises a `push_rejected` alert with `final: false`.
A row rejected with `staff_lacks_permission` also makes the SDK re-read the
manifest, so the gate starts answering `permission_denied`. Refreshes can only
add "used" state, never remove it. While a perk grant has unconfirmed local or
gossiped redemptions, the device keeps the lower of the server's and its own
remaining uses.

When the token has 5 minutes or less left, or a request returns `401`, the SDK
asks your `SessionProvider` for a new session. The gate disarms until the
next arm if the manifest names a different staff member, or the device shows
up as revoked.

> **Shared phones.** Each row belongs to the staff member who signed it, and
> a sync pushes only the current staff member's rows. If someone else signs in
> on the same phone, the previous staff member's unsynced scans and
> redemptions stay queued on the device until that person signs in on it
> again while online (`countPendingOfOtherStaff()` reports how many; Gettix
> shows a notice on its scanner screens). If a staff member loses every
> scanner permission, no session can be minted for them, so their queued rows
> wait until a permission is restored.

## Peer gossip (optional)

Gates that can reach each other can share admissions within seconds, with no
server involved. After an admission, a device sends a small signed packet that
carries the scan, its signature and the sender's device certificate. A receiver
accepts it only after these checks, in order:

1. The packet's size and event are right, and it is a `v: 2` packet with a
   certificate. Anything else is dropped.
2. The sender is not on the event's revoked-device list.
3. Flood control, keyed on the sender's network source when the transport
   reports one (else on the claimed device id). Senders without a verified
   certificate get 20 packets/s per key before any cryptography, within a
   200/s total. Senders with a verified certificate get 20 packets/s per
   (device id, source), within a 200 verifications/s CPU guard. One spoofed
   key can use at most 20 of those 200, so junk traffic can't crowd out real
   gates.
4. The certificate matches the sender, is for this event, is unexpired,
   grants the packet type (`scan` or `perk`), and is signed by the pinned
   platform key.
5. The row signature verifies. Only then does the packet count against the
   sender's 20/s budget and mark itself as seen, so a forged copy can't block
   the genuine one.

The receiver then records the scan in its own used-set, atomically. Nothing
about peers is persisted: each gossip session verifies a peer's certificate on
its first packet. A device without a certificate doesn't broadcast.

Gossip is transport-agnostic. You supply one or more `PeerTransport`s: UDP on
the venue LAN, an iOS/Android phone-to-phone mesh, BLE, or anything else that
moves strings. Pass the packet's `source` (for example the UDP sender address
or the mesh peer name) so flood budgets can't be exhausted by someone spoofing
a gate's id. The packet format (gossip v2 packets, v3 certificates) and the
canonical signed strings are specified in the SDK README (sections *Gossip
protocol* and *Canonical strings*). The canonical strings are also listed in
the [Scanner protocol reference](#api-scanner-protocol).

## Limits

| Limit | Value |
| --- | --- |
| Rows per push (`scans` or `redemptions`) | 1000; more returns `413 batch_too_large` (the SDK pushes 200 at a time) |
| Push row fields | ids up to 128 characters; `sig` exactly 128 lowercase hex; timestamps ISO-8601 with `Z` or an offset |
| `GET /v1/scanner/tickets`, `/tickets/revoked`, `/perk-grants`, `/devices/revoked` page size | default 1000, max 5000 (larger values are clamped) |
| `GET /v1/scanner/scans`, `/perk-redemptions` page size | default 500, max 2000 |
| Active devices | 10 per staff member per event (`409 device_limit_reached`) |
| Session token lifetime | up to 12 h, never past event end + 24 h (minimum 60 s) |
| Permission, staff and account changes | effective within about 60 s |
| Device certificate lifetime | until event end + 24 h, capped at 7 days (24 h for an undated event) |
| Gossip reconciliation window | 10 s: earliest wins inside it; `suspect_duplicate` outside it |
| Gossip packet size | 2048 characters |
| Gossip rate caps (per receiver) | per key (source, else device id): 20 packets/s; unverified senders 200/s in total; verified senders 200 verifications/s CPU guard |
| Gossip memory bounds | 4000 seen packets, 512 tracked peers, 1000 rejected certificates |
| Live code | 30 s steps, ±1 step tolerance |

## SDK quick start

### 1. Install

The SDK is the package **`@ticketconnect/offline-scanner`** (v0.1.0). It is
distributed as the tarball `ticketconnect-offline-scanner-0.1.0.tgz`:

```bash
npm install ./ticketconnect-offline-scanner-0.1.0.tgz
```

It ships ESM, CommonJS and TypeScript declarations. Its runtime dependencies
are `@noble/curves` and `@noble/hashes`, and it doesn't use React, React
Native, Expo or Node-only APIs, so it runs in React Native, the browser or
Node.

### 2. Mint sessions on your server

Add an endpoint to your backend that your staff app calls after the staff
member signs in. It is the only place your `sk_` key is used. The key needs the
`scanning:write` scope.

```js
// Your backend (Node / Express). The sk_ key never leaves this server.
app.post("/scanner-session", requireStaffLogin, async (req, res) => {
  const r = await fetch("https://api.ticketconnect.example/v1/scanner/sessions", {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.TICKETCONNECT_SECRET_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      staff_id: req.user.ticketconnectStaffId, // from POST /v1/staff
      event_id: req.body.event_id,
    }),
  });
  const body = await r.json();
  if (!r.ok) return res.status(r.status).json(body); // 404 / 403 / 409 / 410
  // body: { object: "scanner_session", token, expires_at, event_id, staff_id }
  res.json({ token: body.token, expires_at: body.expires_at });
});
```

The staff member must be `active`, assigned to the event
([`POST /v1/staff/{id}/assignments`](#api-staff)), and have
`can_scan_tickets` or `can_redeem_perks`.

### 3. Implement the ports

The engine does not do its own I/O. The host app supplies:

| Port | What you provide |
| --- | --- |
| `SessionProvider` | `mint(): Promise<{ token, expiresAt }>`, which calls the endpoint from step 2. |
| `ScannerStore` | Durable storage, one instance per event (for example SQLite): meta, tickets (with an indexed `getTicketByBarcodeHash` lookup), revocation flags, perk grants, and the signed scan and perk outboxes. Methods marked **ATOMIC** (`upsertTickets`, `markTicketsRevoked`, `commitLocalScan`, `recordPeerScan`, `upsertPerkGrants`, `commitLocalPerkRedemption`, `recordPeerPerkRedemption`) must save all of their writes or none, without interleaving. On SQLite, use a single `BEGIN IMMEDIATE … COMMIT`. `upsertTickets` must never turn a used ticket back to unused. The store keeps no peer table. |
| `KeyValueStore` | Secure string storage (Keychain / Keystore) for the device identity (private key, public key and device id, one set per staff member), the scanner session token, device certificates and the pinned platform key. |
| `RandomSource` | `bytes(n)` from a cryptographically secure generator. |
| `PeerTransport` (optional) | A lossy string channel for gossip: `start(onPacket, self)`, `stop()`, `send(json)`. Call `onPacket(json, source)` with the sender's address or peer name when you know it. |
| `Clock`, `Logger` (optional) | Default to `Date.now()` and no logging. |

`MemoryScannerStore` and `MemoryKeyValueStore` are included for tests and
prototypes. Replace them with durable, secure implementations in production.

### 4. Arm, scan, sync, gossip

```ts
import {
  createOfflineScanner,
  MemoryScannerStore,
  MemoryKeyValueStore,
  type SessionProvider,
} from "@ticketconnect/offline-scanner";

const session: SessionProvider = {
  async mint() {
    const res = await fetch("https://your-backend.example/scanner-session", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ event_id: "evt_42" }),
    });
    if (!res.ok) throw new Error(`session mint failed: ${res.status}`);
    const body = await res.json(); // { token, expires_at }
    return { token: body.token, expiresAt: body.expires_at };
  },
};

// One engine per event, shared by your ticket and perk screens.
const scanner = createOfflineScanner({
  eventId: "evt_42",
  baseUrl: "https://api.ticketconnect.example", // paths are appended as /v1/scanner/...
  session,
  store: new MemoryScannerStore(),      // production: durable, transactional store
  keyValue: new MemoryKeyValueStore(),  // production: Keychain / Keystore
  random: { bytes: (n) => crypto.getRandomValues(new Uint8Array(n)) },
  transports: [],                       // add your PeerTransport(s) for gossip
  device: { platform: "ios", appVersion: "1.0.0" },
});

// Arm while online. ensureArmed() reuses a stored session when it is still fresh.
await scanner.arm();

// Keep reconciling in the background: runs now, then every 15 s.
scanner.startSync();
const offAlerts = scanner.onAlert((alert) => {
  if (alert.outcome === "push_rejected") {
    console.error("server refused rows", alert.code, alert.final ? "rejected for good" : "kept queued");
    return;
  }
  console.warn("possible duplicate entry", alert.ticketId);
});

// Optional phone-to-phone gossip. Returns false if the device isn't armed.
await scanner.startGossip();

// At the gate. Works with no network.
const result = await scanner.scanTicket(qrText);
if (result.ok) {
  admit(result.ticketId, result.seatNumber);
} else {
  refuse(result.reason); // "already_used" | "revoked" | "permission_denied" | "rot_stale" | …
}

// Perks use the same engine.
const perk = await scanner.redeemPerk(perkQrText);

// Teardown.
await scanner.stopGossip();
scanner.stopSync();
offAlerts();
```

Debouncing belongs in your camera handler, not the engine. The Gettix
reference ignores the same payload for 10 seconds and any read within 200 ms of
the previous one.

## What the SDK does not include

* **Camera and scanning UI.** You decode the QR and pass the string to
  `scanTicket`, then design your own result screens.
* **A native mesh transport.** The SDK handles packet format, flood control and
  all cryptography, while the host supplies the transports. Gettix's staff
  mode is the reference implementation of the ports: an SQLite store, secure
  storage, UDP on the LAN, and a phone-to-phone mesh over MultipeerConnectivity
  on iOS and Nearby Connections on Android. That code is part of the Gettix app
  and is not included in the SDK.
* **Durable storage.** The in-memory stores are for tests and prototypes.
* **Session minting and staff sign-in.** These live in your backend and app
  (step 2).
* **Gate sales and wristbands.** There are no `/v1` POS endpoints.

## See also

* [Scanner protocol reference](#api-scanner-protocol): every `/v1/scanner`
  endpoint with requests, responses and errors.
* [Scanning & Check-in](#scanning-and-check-in): the online gate.
* [Staff reference](#api-staff): create staff, assign events, and manage their
  devices.
