# Scanner protocol

The offline door protocol: arm a scanner device, feed it the event's tickets,
revocations and perk grants, and reconcile its device-signed scans. The
[Offline scanning](#offline-scanning) guide explains the trust model and the
`@ticketconnect/offline-scanner` SDK, which calls every device endpoint below
for you.

## Two credentials, never interchangeable

| Endpoint | Called by | Auth |
| --- | --- | --- |
| `POST /v1/scanner/sessions` | Your backend | Secret key (`sk_…`), scope **`scanning:write`** |
| `GET /v1/staff/{id}/devices`, `POST /v1/staff/{id}/devices/{device_id}/revoke` | Your backend | Secret key (`sk_…`), scope **`staff:manage`** |
| Every other `/v1/scanner/*` endpoint | The scanner device | **Scanner session token**: `Authorization: Bearer <token>` from `POST /v1/scanner/sessions` |

* A secret key sent to a device route returns `401`. A session token sent to a
  partner route, or to any route outside the `/v1/scanner` device endpoints,
  also returns `401`.
* Device routes take **no event id**. The event comes from the session token,
  so a session for one event can never read or write another.
* A session carries only the door capabilities: `scan_tickets` and
  `redeem_perks`. On every request its **effective** permissions are the
  token's AND the staff member's current flags, so turning a flag off takes
  effect within about 60 seconds.
* A device route returns `401` with `type: "authentication_error"`,
  `code: "unauthorized"` and a message starting with
  `Invalid scanner session:` when the token is missing, malformed, expired or
  tampered with, when the staff member has been deactivated, suspended or
  unassigned from the event, when your account is no longer active, or when
  the API key that minted the session has been revoked. Mint a new session
  and retry.

Errors use the standard envelope `{ "error": { "type", "code", "message", "param" } }`
(see [Errors](#errors)).

### Signed strings

Pushed rows are Ed25519-signed by the device over these exact UTF-8 strings.
`<eventId>` is the session's event id. `sig` is the lowercase hex signature.

```text
scan: v1|<eventId>|<ticket_id>|<scanned_at>|<scanner_staff_id>|<device_id>
perk: v1|perk|<eventId>|<grant_id>|<redeemed_at>|<scanner_staff_id>|<device_id>
cert: v3|device|<device_id>|<event_id>|<public_key lowercase>|<capabilities>|<issued_at_ms>|<expires_at_ms>
```

The platform signs the `cert` string when it issues a device certificate.
`<capabilities>` is the sorted, comma-joined list: `perk`, `scan` or
`perk,scan`. Certificates in the older `v2` format are no longer issued or
accepted.

### Reconciliation outcomes

Each row you push comes back with an `outcome`:

| `outcome` | Meaning | `reason` |
| --- | --- | --- |
| `accepted` | The first valid claim on the ticket or grant. | — |
| `superseded` | Another claim within 10 seconds was earlier, and that one wins. | `lost-to-earlier-scan` / `lost-to-earlier-redemption` |
| `suspect_duplicate` | Another claim exists more than 10 seconds away. Both are flagged for review. | `outside-gossip-window` |
| `rejected` | Not admissible. | Scans: `staff_mismatch`, `device_unknown`, `device_revoked`, `staff_lacks_permission`, `bad_signature`, `event_ended`, `unknown_ticket`, `refunded`, `revoked`. Perks: `staff_mismatch`, `device_unknown`, `device_revoked`, `staff_lacks_permission`, `bad_signature`, `event_ended`, `unknown_grant`, `revoked`, `already_used`. |

* **`staff_mismatch`:** the row's `scanner_staff_id` isn't the session's
  staff member, or its `device_id` belongs to someone else. Nothing is stored.
  The SDK only pushes the session staff member's own rows, so it treats this
  as a bug: the row stays queued on the device.
* **Revocation is not retroactive.** A row from a device revoked at time T
  (everywhere, or for this event) is still processed normally if it was
  signed at or before T + 60 seconds; the server flags it for organizer
  review. Rows signed later are `device_revoked`.
* **`staff_lacks_permission`:** the session no longer has `scan_tickets`
  (for scans) or `redeem_perks` (for perks). This is a per-row verdict in a
  normal `200` response, not an error for the whole request, and nothing is
  stored. Rows the device signed before the permission was removed (up to 60
  seconds after the resulting device revocation, and only if the device was
  certified for that capability) are still accepted.
* **`unknown_ticket` / `unknown_grant`:** the id doesn't exist in the
  session's event. A ticket or grant from another event is reported the same
  way. Nothing is stored.
* **Replays are safe.** Rows are unique per device, ticket or grant, and
  signed timestamp. Each row is stored as `pending`, its effect (ticket
  marked used, perk use consumed, staff credit) is applied, and only then is
  it final. Pushing an identical row again returns its stored `outcome`,
  `reason` and `server_record_id`; if an earlier attempt was cut off while
  the row was still pending, the replay finishes it. A replay never marks a
  ticket twice, consumes another perk use or credits the staff member again.

---

## POST /v1/scanner/sessions

Mint a scanner session for one of your staff members on one of your events.
Call this from your backend and pass the token to the device. **Never pass the
`sk_` key to a device.**

**Scope:** `scanning:write` (secret key)

The token expires after 12 hours, or at the event's grace cutoff (event end +
24 hours) if that comes first. The minimum lifetime is 60 seconds. It carries
only the staff member's `can_scan_tickets` and `can_redeem_perks` flags;
selling, staff management and stats are always off in a scanner session.

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `staff_id` | string | Yes | A staff id from [`POST /v1/staff`](#api-staff). |
| `event_id` | string | Yes | An event you own. |

### Example request

```bash
curl https://api.ticketconnect.example/v1/scanner/sessions \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "staff_id": "staff_1727_ab12", "event_id": "evt_42" }'
```

### Example response

`201 Created`

```json
{
  "object": "scanner_session",
  "token": "eyJhbGciOiJIUzI1NiJ9...",
  "expires_at": "2026-09-30T23:10:00.000Z",
  "event_id": "evt_42",
  "staff_id": "staff_1727_ab12"
}
```

> Errors, checked in this order: `400 parameter_missing` (`param` is
> `staff_id` or `event_id`); `404 resource_missing` for an unknown or unowned
> staff member (`param: staff_id`), then event (`param: event_id`);
> `403 staff_inactive`; `409 staff_not_assigned`; `403 staff_lacks_permission`
> (the staff member has neither `can_scan_tickets` nor `can_redeem_perks`);
> `410 event_ended` (more than 24 hours past the event end).

---

## GET /v1/scanner/manifest

Everything a device needs to arm that isn't paged. It never contains the
live-code master key.

**Auth:** scanner session token

### Example request

```bash
curl https://api.ticketconnect.example/v1/scanner/manifest \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN"
```

### Example response

```json
{
  "object": "scanner_manifest",
  "event_id": "evt_42",
  "event_public_key": "3b6a27bcceb6a42d62a3a8d02a6f0d73653215771de243a63ac048a18b59da29",
  "platform_cert_public_key": "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a",
  "event_end_at": "2026-10-01T02:00:00.000Z",
  "grace_cutoff_at": "2026-10-02T02:00:00.000Z",
  "qr_rotation": { "v": 2, "enforced": true, "period_s": 30 },
  "staff": {
    "id": "staff_1727_ab12",
    "first_name": "Gate",
    "last_name": "Keeper",
    "permissions": {
      "scan_tickets": true,
      "redeem_perks": false,
      "sell_onsite": false,
      "view_stats": false,
      "manage_staff": false
    }
  }
}
```

| Field | Description |
| --- | --- |
| `event_public_key` | Hex Ed25519 key that ticket and perk codes are signed with. `null` if the event has no key; the SDK refuses to arm without one. |
| `platform_cert_public_key` | Hex Ed25519 key that device certificates are signed with. Pin it once. Can be `null` if the platform key isn't configured. |
| `event_end_at`, `grace_cutoff_at` | Event end, and end + 24 hours. `null` for an undated event. |
| `qr_rotation` | Live-code settings, or `null` when the event doesn't enforce live codes. |
| `staff.permissions` | The session's effective permissions: token AND the staff member's current flags. In a scanner session, `sell_onsite`, `view_stats` and `manage_staff` are always `false`. The SDK refuses to scan (`permission_denied`) without `scan_tickets`, and to redeem without `redeem_perks`. |

> Errors: `401` for a bad session; `404 resource_missing` if the session's
> event no longer exists.

---

## POST /v1/scanner/devices

Register the device's Ed25519 public key for the session's staff member and
receive a device certificate scoped to the session's event. The call is
idempotent: re-registering the same `device_id` with the same key returns
`201` again with `already_registered: true` and a fresh certificate.
Registering records the event on the device (see `event_ids` in
[`GET /v1/staff/{id}/devices`](#api-scanner-protocol)). A staff member can hold
at most **10 active devices per event**. Access is checked fresh before and
after the device is written: if the staff member was deactivated, unassigned
or lost a capability in the meantime, the new device is revoked on the spot
and no certificate is returned. Certificates are not stored. They
expire at event end + 24 hours, capped at 7 days (24 hours for an undated
event), and list the gossip `capabilities` the session's effective
permissions allow.

**Auth:** scanner session token

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `device_id` | string | Yes | 8–128 characters, `[A-Za-z0-9_-]`. |
| `public_key` | string | Yes | Exactly 64 hex characters (a raw Ed25519 public key). Stored lowercase. |
| `platform` | string | No | `ios`, `android`, or `web`. |
| `app_version` | string | No | Up to 32 characters. |

### Example request

```bash
curl https://api.ticketconnect.example/v1/scanner/devices \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "device_id": "dev_abc12345",
    "public_key": "8f1e0c5b6a2d4e7f9a3b1c0d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d7e9f",
    "platform": "ios",
    "app_version": "1.4.0"
  }'
```

### Example response

`201 Created`

```json
{
  "object": "scanner_device",
  "id": "dev_abc12345",
  "device_cert": {
    "device_id": "dev_abc12345",
    "event_id": "evt_42",
    "public_key": "8f1e0c5b6a2d4e7f9a3b1c0d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d7e9f",
    "capabilities": ["scan"],
    "issued_at_ms": 1790776717559,
    "expires_at_ms": 1790863117559,
    "sig": "<128 hex characters>"
  },
  "already_registered": false
}
```

`device_cert` is `null` if the platform certificate key isn't configured.

> Errors: `400 parameter_invalid` (`param` is `device_id`, `public_key`,
> `platform` or `app_version`); `401 unauthorized` (the staff member is no
> longer active or assigned: mint a new session);
> `403 staff_lacks_permission` (the session can neither scan nor redeem);
> `409 device_conflict` (the `device_id` is taken, or registered with a
> different key); `403 device_revoked` (the device is revoked, globally or for
> this event: revoked device ids never come back); `409 device_limit_reached`
> (revoke one of the staff member's devices first). On `device_conflict` and
> `device_revoked`, generate a new device key and `device_id` and register
> again; the SDK does this once automatically.

---

## GET /v1/scanner/devices/revoked

The distrust feed: devices whose scans and gossip must no longer be trusted.
A revoked phone keeps a valid-looking certificate until it expires, so armed
devices need this list. It covers every device ever registered for the
session's event that is revoked globally or for this event, including those
of staff who were since deactivated or unassigned, and no devices from other
events.

**Auth:** scanner session token

### Query params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `since` | string | No | ISO-8601 timestamp: the `now` from your previous call. Omit it for the full list. |
| `limit` | integer | No | Default `1000`, max `5000` (larger values are clamped). |

### Example response

```json
{
  "object": "revocation_feed",
  "now": "2026-09-30T14:00:37.521Z",
  "has_more": false,
  "device_ids": ["dev_x"]
}
```

When `has_more` is `true`, `now` is the last returned row's revocation time.
Call again with `since=<now>`. The boundary row may repeat.

> Errors: `400 parameter_invalid` for a non-ISO `since` or a `limit` that isn't
> a positive integer.

---

## GET /v1/scanner/tickets

The event's valid (non-revoked) tickets, used to arm the gate. Ordered by
`ticket_id`.

**Auth:** scanner session token

### Query params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string | No | The `next_cursor` from the previous page. |
| `limit` | integer | No | Default `1000`, max `5000` (larger values are clamped). |

### Example request

```bash
curl "https://api.ticketconnect.example/v1/scanner/tickets?limit=1000" \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN"
```

### Example response

```json
{
  "object": "list",
  "data": [
    {
      "ticket_id": "tkt_1",
      "is_used": false,
      "used_at": null,
      "used_by": null,
      "seat_number": null,
      "section": "GA",
      "row": null,
      "rot_pub_key": "5c1d8e0f2a4b6c8d0e1f3a5b7c9d2e4f6a8b0c1d3e5f7a9b2c4d6e8f0a1b3c5d",
      "barcode_sha256": "9f2b6c1e4d7a0b3c5e8f1a2d4b6c9e0f3a5d7b1c4e6f8a0b2d5c7e9f1a3b5c7d"
    }
  ],
  "next_cursor": null
}
```

| Field | Description |
| --- | --- |
| `rot_pub_key` | Hex public key that verifies this ticket's live code. `null` when the event doesn't enforce live codes. |
| `barcode_sha256` | Lowercase hex SHA-256 of the ticket's opaque `qr.data`, so a device can match your delivered code offline. The plaintext barcode is never sent. |
| `next_cursor` | The last `ticket_id` when the page is full; otherwise `null`. |

> Errors: `400 parameter_invalid` (`param: limit`).

---

## GET /v1/scanner/tickets/revoked

Revoked tickets for the event. Without `since` you get the full current list,
which you need at arming. With `since` you get only revocations after that
time. Revocations that are later undone aren't replayed; re-arm to pick them
up.

**Auth:** scanner session token

### Query params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `since` | string | No | ISO-8601 timestamp: the `now` from an earlier call. |
| `cursor` | string | No | The `next_cursor` from the previous page. |
| `limit` | integer | No | Default `1000`, max `5000` (larger values are clamped). |

### Example response

```json
{
  "object": "revocation_feed",
  "now": "2026-09-30T14:00:37.521Z",
  "ticket_ids": ["tkt_9"],
  "next_cursor": null
}
```

`now` is captured before the query runs. Use the first page's `now` as your
next `since`, so a revocation that lands mid-read is sent again rather than
missed.

> Errors: `400 parameter_invalid` (`param` is `since` or `limit`).

---

## GET /v1/scanner/perk-grants

The event's active perk grants, labelled so the device can show what it is
redeeming. Ordered by `grant_id`.

**Auth:** scanner session token

### Query params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string | No | The `next_cursor` from the previous page. |
| `limit` | integer | No | Default `1000`, max `5000` (larger values are clamped). |

### Example response

```json
{
  "object": "list",
  "data": [
    {
      "grant_id": "grant_a",
      "perk_def_id": "pdef_1",
      "status": "active",
      "uses_remaining": 1,
      "unlimited": false,
      "name": "Free Drink",
      "icon": "cup",
      "color": "#ff0000"
    }
  ],
  "next_cursor": null
}
```

> Errors: `400 parameter_invalid` (`param: limit`).

---

## POST /v1/scanner/scans

Push device-signed scans for reconciliation: one row right after an online
scan, or many when a device drains its offline backlog. Every row must be the
session staff member's own, signed by one of their devices. Each newly
accepted scan marks the ticket used and adds one to the staff member's
`scanned_tickets`; a replayed row adds nothing.

**Auth:** scanner session token

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `scans` | object[] | Yes | At most **1000** rows. |
| `scans[].local_id` | string | No | Your local row id, up to 128 characters. It is echoed back in `processed`. |
| `scans[].ticket_id` | string | Yes | The admitted ticket. Up to 128 characters, no `\|`. |
| `scans[].scanned_at` | string | Yes | ISO-8601 time of the scan with `Z` or an offset (what `Date#toISOString()` produces), exactly as it appears in the signed string. |
| `scans[].scanner_staff_id` | string | Yes | Must be the session's staff member. Up to 128 characters, `[A-Za-z0-9_-]`. |
| `scans[].device_id` | string | Yes | A registered, non-revoked device of that staff member. Up to 128 characters, `[A-Za-z0-9_-]`. |
| `scans[].sig` | string | Yes | Ed25519 signature over the `scan` string above: exactly 128 lowercase hex characters. |

### Example request

```bash
curl https://api.ticketconnect.example/v1/scanner/scans \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "scans": [
      {
        "local_id": "L1",
        "ticket_id": "tkt_1",
        "scanned_at": "2026-09-30T13:55:00.000Z",
        "scanner_staff_id": "staff_1727_ab12",
        "device_id": "dev_abc12345",
        "sig": "<hex signature over v1|evt_42|tkt_1|2026-09-30T13:55:00.000Z|staff_1727_ab12|dev_abc12345>"
      }
    ]
  }'
```

### Example response

```json
{
  "object": "scan_batch_result",
  "cursor": "2026-09-30T13:58:37.571Z",
  "processed": [
    {
      "ticket_id": "tkt_1",
      "local_id": "L1",
      "outcome": "accepted",
      "server_record_id": "6abd..."
    }
  ]
}
```

`processed[]` has one entry per row, with `ticket_id`, the optional
`local_id`, `outcome`, and where they apply, `reason` and `server_record_id`
(see [Reconciliation outcomes](#api-scanner-protocol)).

> Errors: `400 parameter_missing`
> (`param: scans`); `413 batch_too_large` (more than 1000 rows);
> `400 parameter_invalid` naming the row and field (for example
> `param: scans[1].sig`).

---

## GET /v1/scanner/scans

Pull the server's scan records for the event, including other gates'
admissions, to update the local used-set. Only `active` rows consume a ticket.

**Auth:** scanner session token

### Query params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `cursor` | string | No | ISO-8601 `server_received_at` watermark: the previous `next_cursor`. Defaults to the Unix epoch. |
| `limit` | integer | No | Default `500`, max `2000` (larger values are clamped). |

### Example response

```json
{
  "object": "list",
  "data": [
    {
      "ticket_id": "tkt_1",
      "scanned_at": "2026-09-30T13:55:00.000Z",
      "server_received_at": "2026-09-30T13:58:37.571Z",
      "scanner_staff_id": "staff_1727_ab12",
      "device_id": "dev_abc12345",
      "sig": "<hex>",
      "status": "active"
    }
  ],
  "next_cursor": "2026-09-30T13:58:37.571Z"
}
```

`status` is `active`, `superseded`, `suspect_duplicate`, or `rejected`. Rows
still being processed (`pending`) are never returned. When a row's status
changes later (for example an `active` scan is `superseded` by an earlier
one), its `server_received_at` moves forward, so it re-appears after your
cursor and you see the new status. `next_cursor` is **never `null`**: it is
the last row's `server_received_at`, or it echoes `cursor` when there is
nothing new. Stop paging when a page has fewer rows than `limit`.

> Errors: `400 parameter_invalid` (`param` is `cursor` or `limit`).

---

## POST /v1/scanner/perk-redemptions

Push device-signed perk redemptions. This mirrors
[`POST /v1/scanner/scans`](#api-scanner-protocol).

**Auth:** scanner session token

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `redemptions` | object[] | Yes | At most **1000** rows. |
| `redemptions[].local_id` | string | No | Up to 128 characters. Echoed back in `processed`. |
| `redemptions[].grant_id` | string | Yes | The redeemed grant. Up to 128 characters, no `\|`. |
| `redemptions[].redeemed_at` | string | Yes | ISO-8601 time of the redemption with `Z` or an offset. |
| `redemptions[].scanner_staff_id` | string | Yes | Must be the session's staff member. Same rules as for scans. |
| `redemptions[].device_id` | string | Yes | A registered, non-revoked device of that staff member. |
| `redemptions[].sig` | string | Yes | Ed25519 signature over the `perk` string above: exactly 128 lowercase hex characters. |

### Example request

```bash
curl https://api.ticketconnect.example/v1/scanner/perk-redemptions \
  -H "Authorization: Bearer $SCANNER_SESSION_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "redemptions": [
      {
        "local_id": "P1",
        "grant_id": "grant_a",
        "redeemed_at": "2026-09-30T13:57:00.000Z",
        "scanner_staff_id": "staff_1727_ab12",
        "device_id": "dev_abc12345",
        "sig": "<hex signature over v1|perk|evt_42|grant_a|2026-09-30T13:57:00.000Z|staff_1727_ab12|dev_abc12345>"
      }
    ]
  }'
```

### Example response

```json
{
  "object": "perk_redemption_batch_result",
  "cursor": "2026-09-30T13:58:40.000Z",
  "accepted_count": 1,
  "processed": [
    {
      "grant_id": "grant_a",
      "local_id": "P1",
      "outcome": "accepted",
      "server_record_id": "6abd..."
    }
  ]
}
```

`accepted_count` counts only the rows this request newly accepted; a replayed
row reports `accepted` again but isn't counted.

> Errors: `400 parameter_missing`
> (`param: redemptions`); `413 batch_too_large`; `400 parameter_invalid`
> (for example `param: redemptions[0].grant_id`).

---

## GET /v1/scanner/perk-redemptions

Pull the server's perk redemption records. This mirrors
[`GET /v1/scanner/scans`](#api-scanner-protocol): the same `cursor` and `limit`
rules (default `500`, max `2000`), and a `next_cursor` that is never `null`.

**Auth:** scanner session token

### Example response

```json
{
  "object": "list",
  "data": [
    {
      "grant_id": "grant_a",
      "redeemed_at": "2026-09-30T13:57:00.000Z",
      "server_received_at": "2026-09-30T13:58:40.000Z",
      "scanner_staff_id": "staff_1727_ab12",
      "device_id": "dev_abc12345",
      "sig": "<hex>",
      "status": "active"
    }
  ],
  "next_cursor": "2026-09-30T13:58:40.000Z"
}
```

> Errors: `400 parameter_invalid` (`param` is `cursor` or `limit`).

---

## GET /v1/staff/{id}/devices

List a staff member's registered scanner devices, newest first, with the
standard list pagination. Public keys stay on the server.

**Scope:** `staff:manage` (secret key)

### Path params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Staff id. An unknown id, or one from another account, returns `404 resource_missing`. |

### Query params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `limit` | integer | No | Page size, `1`–`100` (default `20`). |
| `starting_after` | string | No | Cursor: the last device `id` you saw on the previous page. An unknown id returns `400 parameter_invalid`. |

### Example request

```bash
curl https://api.ticketconnect.example/v1/staff/staff_1727_ab12/devices \
  -H "Authorization: Bearer sk_test_your_key_here"
```

### Example response

```json
{
  "object": "list",
  "data": [
    {
      "object": "scanner_device",
      "id": "dev_abc12345",
      "staff_id": "staff_1727_ab12",
      "event_ids": ["evt_17", "evt_42"],
      "platform": "android",
      "app_version": "9.9",
      "registered_at": "2026-09-30T13:50:00.000Z",
      "last_seen_at": "2026-09-30T13:58:37.000Z",
      "revoked_at": null,
      "revoked_events": [
        { "event_id": "evt_17", "revoked_at": "2026-09-29T22:00:00.000Z" }
      ]
    }
  ],
  "has_more": false
}
```

`event_ids` lists the events the device has registered for. `revoked_at` is
set when the device is revoked everywhere; `revoked_events` lists events it
is revoked for only (after an unassignment). `platform`, `app_version` and
`last_seen_at` can be `null`.

---

## POST /v1/staff/{id}/devices/{device_id}/revoke

Revoke one of a staff member's scanner devices, for example a lost phone. Its
scans and perk redemptions signed more than 60 seconds after the revocation
are rejected at reconciliation (`device_revoked`); earlier ones are still
processed and flagged for review. It also appears in
[`GET /v1/scanner/devices/revoked`](#api-scanner-protocol) for its events, so
armed peers drop its gossip, and it can never register again: the phone needs
a new `device_id`. The call is idempotent: an already-revoked device keeps its
original `revoked_at` and reason.

Devices are also revoked automatically:

* **Everywhere**, for all of a staff member's devices, when you deactivate or
  suspend them (`DELETE /v1/staff/{id}`, or `PATCH` with a non-`active`
  `status`), delete them in the organizer panel, or switch off
  `can_scan_tickets` or `can_redeem_perks` (their certificates still list the
  old capabilities; the phones register again with fewer).
* **For one event only** when you unassign them from it
  (`DELETE /v1/staff/{id}/assignments/{eventId}`). The event is added to
  `revoked_events`, and the devices keep working at the staff member's other
  events.

**Scope:** `staff:manage` (secret key)

### Path params

| Param | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | Yes | Staff id. |
| `device_id` | string | Yes | A device registered to that staff member. |

### Request body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `reason` | string | No | Stored with the revocation. Defaults to `admin-revoke`. |

### Example request

```bash
curl https://api.ticketconnect.example/v1/staff/staff_1727_ab12/devices/dev_abc12345/revoke \
  -H "Authorization: Bearer sk_test_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{ "reason": "lost phone" }'
```

### Example response

```json
{
  "object": "scanner_device",
  "id": "dev_abc12345",
  "staff_id": "staff_1727_ab12",
  "event_ids": ["evt_42"],
  "platform": "android",
  "app_version": "9.9",
  "registered_at": "2026-09-30T13:50:00.000Z",
  "last_seen_at": "2026-09-30T13:58:37.000Z",
  "revoked_at": "2026-09-30T14:01:00.000Z",
  "revoked_events": []
}
```

> Errors: `404 resource_missing` for an unknown or unowned staff member, or for
> a device that doesn't belong to that staff member.
