Server API contract
How to build a validation endpoint the app can talk to.
POST with JSON to the
API URL you configure. Your server replies with a small JSON object saying whether the ticket is
valid, already used, or
invalid — plus an optional message and ticket details. That's it.
The request
POST <your configured API URL>
Headers
| Header | Value |
|---|---|
Content-Type | application/json |
Accept | application/json |
X-App-Version | App version, e.g. 1.0.0 |
Authorization | Bearer <api key> — only if a key is set |
X-API-Key | <api key> — same key, sent both ways for convenience |
Body
| Field | Type | Description |
|---|---|---|
code | string | The scanned barcode/QR value. |
type | string | Barcode format, e.g. qr, code128, ean13. |
configId | string | Identifier of the scanner configuration in use. |
scannerName | string? | Optional lane/scanner label. Omitted if not set. |
deviceId | string | Stable, random per-install device identifier. |
scannedAt | string | ISO 8601 timestamp of the scan. |
POST /validate HTTP/1.1
Host: api.myevent.com
Content-Type: application/json
Accept: application/json
X-App-Version: 1.0.0
Authorization: Bearer sk_live_…
X-API-Key: sk_live_…
{
"code": "TKT-4F9A21",
"type": "qr",
"configId": "cfg_8a1c",
"scannerName": "Entrance #1",
"deviceId": "a1b2c3d4-e5f6a7b8",
"scannedAt": "2026-06-26T18:14:58.204Z"
}
The response
Reply with a JSON object. The app reads three things from it:
status— the outcome (string). Also acceptsresultorstate, or a booleanvalid: true/false.message— a human-readable line shown in the popup. Also acceptsreason,detail, ortitle. Optional — a sensible default is used per status.ticket— an object of key → value pairs rendered as rows (holder name, type, gate, seat…). Also acceptsholderordata. Nested objects are skipped; everything else is shown as text. Optional.
200 OK
{
"status": "valid",
"message": "Welcome — VIP entry",
"ticket": { "name": "Ada Lovelace", "type": "VIP", "gate": "A", "seat": "12" }
}
Status mapping
The app maps your status string to a traffic-light result. Matching is
case-insensitive.
| Result | Accepted values | Boolean |
|---|---|---|
| Green — valid | valid, ok, success | valid: true |
| Yellow — already used | used, already_scanned, duplicate, warning | — |
| Red — rejected | invalid, denied, not_found, expired | valid: false |
| Error | anything unrecognized, a non-JSON body, or HTTP ≥ 500 | — |
Return HTTP 200 for the normal valid / used / invalid outcomes — these are business
results, not transport errors. Any response below 500 is parsed for its JSON body;
500+ always shows the error popup regardless of body.
Examples
Valid → green
{ "status": "valid", "message": "Welcome — VIP entry",
"ticket": { "name": "Ada Lovelace", "type": "VIP", "gate": "A" } }
Already used → yellow
{ "status": "used", "message": "Ticket already scanned",
"ticket": { "name": "Alan Turing", "firstScan": "2026-06-26T19:42:10Z" } }
Invalid → red
{ "status": "invalid", "message": "Unknown ticket" }
Minimal boolean form
{ "valid": true, "message": "OK" }
Authentication
If the configuration includes an API key, it arrives on every request as both an
Authorization: Bearer <key> header and an X-API-Key: <key>
header — read whichever you prefer. Reject unauthenticated requests however you like; returning a
red/error JSON body (or a 401) both surface clearly to the operator. Keys are
provisioned per configuration, so you can issue a different key per event or per door.
Example server
A complete validation endpoint in Node.js / Express:
import express from 'express';
const app = express();
app.use(express.json());
app.post('/validate', (req, res) => {
// --- auth ---
const key = req.get('X-API-Key')
|| (req.get('Authorization') || '').replace(/^Bearer /, '');
if (key !== process.env.SCANNER_KEY) {
return res.status(401).json({ status: 'invalid', message: 'Bad API key' });
}
// --- look up the ticket ---
const { code, scannedAt, scannerName } = req.body;
const ticket = db.find(code); // your lookup
if (!ticket) {
return res.json({ status: 'invalid', message: 'Unknown ticket' });
}
if (ticket.usedAt) {
return res.json({
status: 'used',
message: 'Already scanned',
ticket: { name: ticket.name, type: ticket.type, firstScan: ticket.usedAt },
});
}
// --- mark used + accept ---
db.markUsed(code, { at: scannedAt, by: scannerName });
res.json({
status: 'valid',
message: 'Welcome!',
ticket: { name: ticket.name, type: ticket.type, gate: ticket.gate },
});
});
app.listen(3000);
Whether a code counts as valid or used is entirely your server's call — typically by recording the first scan and treating repeats as duplicates.
Already have an API with a different shape?
You don't have to match this contract exactly. All response handling lives in a single adapter file
— src/tickets/parseTicketResponse.ts — which is the only place coupled to your JSON.
Edit STATUS_MAP, pickStatus, pickMessage, and
pickTicketFields to read your fields, and the rest of the app is unaffected.
See it on GitHub.
Notes & limits
- HTTPS is strongly recommended — the API key and scan data travel in the request.
- Timeout: the app waits up to 10 seconds for a reply, then shows an error. Keep validation fast.
- No CORS needed — requests come from a native app, not a browser.
- Transport failures (no network, DNS, TLS, timeout) show an error popup; your server is never reached, so there's nothing to return.
deviceIdis stable per install and resets on reinstall — handy for attributing scans to a specific device, but don't treat it as a security credential.- Online only: there is no offline queue today; each scan needs a live response.
→ Generate a setup code once your endpoint is live.