/api/scan
The door scanner's three calls — sync a batch, ask what is already in, take one back.
Everything the door scanner does goes through this one path: POST to sync a
batch, GET to ask what is already checked in, DELETE to take a scan back.
All three take the same scanner token and answer 403 the same way, so a
revoked device loses all three at once.
The signed scanner grant is the authority, not a login cookie. Origin is
optional for these three operations: browsers omit it on same-origin reads,
and existing non-browser scanner clients do not need to add it. When supplied,
it must exactly match a configured first-party origin. Grant revocation and
live event/membership checks still apply to every operation.
POST /api/scan
Accepts scans queued on a device, possibly hours after the fact.
Ticket codes are HMAC-verified server-side before any database work, so a forged
code is rejected without a query. Rows are upserted on (ticket_id, scan_key)
with duplicates ignored — replaying a batch cannot double-admit a guest,
which matters because a device on flaky venue wifi genuinely does not know
whether its last batch landed.
Request
Prop
Type
Scan
Prop
Type
curl -X POST "$XPERIENCE_ORIGIN/api/scan" \
-H 'content-type: application/json' \
-d '{
"token": "<scanner grant token>",
"scans": [{
"code": "<signed ticket code>",
"scanKey": "<ticket uuid>:2026-10-17",
"scannedAt": "2026-10-17T11:04:22.000Z",
"device": "door 2"
}]
}'Response
{
"accepted": ["3f694bc0-1757-42cb-816f-4bb2062c47a3:2026-10-17"],
"rejected": [],
"checkedIn": 412
}Prop
Type
Rejected is not an error
A rejected scan means someone presented a code we did not issue. Surface it at the door; do not retry it.
Status codes
| Code | Meaning |
|---|---|
200 | Batch processed. Check accepted and rejected, not just the status. |
400 | Invalid scan record fields or syntactically malformed JSON. |
403 | Scanner token invalid/forged, request origin untrusted, or a well-formed non-object body that reaches the missing-token check. |
404 | Token verified but the event no longer exists. |
413 | More than 500 scans in one batch. The first-party client already sends 200. |
Scanner protocol failures use { error: string }. An origin, JSON parser, or
body limit rejected before the scanner protocol runs uses the API-wide
{ statusCode: number, message: string, error?: string } error envelope. The
affected 400, 403, 413, and 500 response schemas admit both concrete
shapes; the protocol's event-missing 404 uses { error: string }.
Input status precedence
A well-formed non-object JSON body reaches the scanner token check and returns
403. Syntactically invalid JSON is rejected by the request parser with
400. Valid object payloads use the concrete scanner schema.
GET /api/scan
Which tickets this door already knows are in, for one day.
Check-in state used to be per-device, so a ticket scanned at one entrance still read as new at another. A device unions this with what it recorded itself — offline it simply keeps its own answer, which is the same answer it had before this existed.
curl "$XPERIENCE_ORIGIN/api/scan?token=<token>&day=2026-10-17"day is a Kuala Lumpur date, YYYY-MM-DD, matching the tail of a scanKey.
{ "scanKeys": ["3f694bc0-1757-42cb-816f-4bb2062c47a3:2026-10-17"] }Status codes
| Code | Meaning |
|---|---|
200 | Scan keys for that event and day. Empty array if none. |
400 | day was not YYYY-MM-DD. |
403 | Scanner token invalid or forged. |
The token is checked first here
Unlike POST, the token is read before day is validated — so a 400 from
this call means your token was already accepted.
DELETE /api/scan
Takes a scan back.
A door gets mis-scans: the wrong ticket held up, two phones at once, a volunteer scanning the queue rather than the person. The row is not cosmetic — it counts toward attendance and awards points — so removing it gives the points back with it.
curl -X DELETE "$XPERIENCE_ORIGIN/api/scan" \
-H 'content-type: application/json' \
-d '{
"token": "<scanner grant token>",
"code": "<signed ticket code>",
"scanKey": "<ticket uuid>:2026-10-17"
}'{ "undone": "3f694bc0-1757-42cb-816f-4bb2062c47a3:2026-10-17" }Status codes
| Code | Meaning |
|---|---|
200 | Gone, or already gone. Both, deliberately — see below. |
400 | Body was not a JSON object, or carried no usable code and scanKey. |
403 | Scanner token invalid or forged. |
500 | The delete itself failed. Retry is safe. |
A repeated undo is not an error
Replaying an undo matches no row and still returns 200. A device on bad wifi
cannot tell whether its first attempt landed, and making the second attempt an
error would only teach it to hide a real failure.
Undo is recorded
Every successful undo is written to the audit log with who took it back and who had scanned it originally. An organiser can tell a careful scanner from one undoing a fifth of their own scans, which is the point.