A QR code is not a ticket
Generating the QR image was easy. The real work in CrowdRelay ticketing is deciding who may enter, exactly once.
- Engineering
- Rust
- Architecture
- Security
The first visible result of CrowdRelay ticketing is a QR code. It's also the least interesting part.
A QR code can contain any string. It doesn't prove that a ticket was paid for, issued to the right person, still valid, assigned to this event, or not already used five minutes ago at another entrance. Treating the image as the ticket would move every important decision into the scanner, which is the least trusted and most chaotic part of the system.
The ticket is a state machine
The actual ticket is an admission pass with a lifecycle.
An order may be created and never paid. A payment webhook may arrive after the browser has closed. A pass may be issued, delivered, revoked, replaced or checked in. Staff may scan it several times because the connection was slow and nobody knows whether the first tap worked. Each of those situations needs its own state, not another boolean called used.
Payment comes first
Payment confirmation is where it starts. The success redirect from the checkout page isn't proof, because anybody can open a URL and call it success. CrowdRelay waits for the verified payment event, records it idempotently and issues the pass from durable order state. A repeated webhook returns the same result instead of creating a second ticket.
What the QR contains
The QR contains an opaque pass reference and signed material, with no private fan data. The scanner sends it to a staff endpoint, and the backend decides whether it belongs to the event, whether the pass is active and whether the current staff session has permission to check people in.
Separate staff permissions
I separated scanning from administration too. The person at the entrance needs to validate and redeem passes. They don't need access to campaigns, exports, fan profiles or ticket configuration. “Staff” is not a smaller spelling of “admin”.
Atomic check-in
The final check-in is atomic. Two devices may scan the same pass at almost the same moment. Reading unused, showing a green screen and only then updating the database would let both through. So the state transition happens in one database operation: valid and unused becomes checked in, exactly once. The second scanner receives the existing result and the time of the first check-in.
Simplified, the transition is a single conditional update:
UPDATE admission_passes
SET state = 'checked_in',
checked_in_at = now(),
checked_in_by = $3
WHERE id = $1
AND event_id = $2
AND state = 'active'
RETURNING checked_in_at;If PostgreSQL returns a row, this scan admitted the fan. If it returns nothing, a second read explains why, and that answer goes back to the scanner.
That response matters at the door. “Invalid” and “already used at 19:42” are different situations. So are “wrong event”, “revoked”, “not active yet” and “scanner not authorised”. The system should help staff decide what to do instead of turning every failure into a red rectangle.
In Rust that's an enum rather than a boolean, so the scanner screen has to handle every case:
pub enum CheckIn {
Admitted { at: OffsetDateTime },
AlreadyUsed { at: OffsetDateTime, entrance: Option<String> },
WrongEvent,
Revoked,
NotActiveYet { from: OffsetDateTime },
ScannerNotAuthorised,
}The same boundary protects the website. The public Virya frontend can display a fan's pass through a private signed session, but it never receives CrowdRelay admin credentials. Staff tools use their own route and permissions. The browser presents the interface; PostgreSQL remains the authority.
Asynchronous delivery
Delivery is asynchronous like the rest of CrowdRelay. A pass shouldn't be lost because an email provider had a bad minute. The pass exists first, the outbox records that it needs to be delivered, and a worker handles the external channel. A fan can still recover it from the private wallet.
I may add more convenience around the door later: better device handling, richer staff views and controlled degraded modes. None of that changes the core rule. The QR code is a pointer, and the ticket is the state transition the backend is willing to defend.