From QR scan to entry confirmation: the three-step validator handshake
The decision this framework is for
A QR-coded ticket arriving at a venue door looks like a one-step interaction: scan, allow, or deny. It is actually a three-step interaction, and treating it as one is the most common reason door queues stall on busy nights. The framework below is for any team building a ticket-validation flow where the door scanner is online (which it almost always is, since the customer's phone is the scanner in modern setups) and the latency budget per scan is under a second.
The decision is what the scanner-side handshake should look like. Three calls — read, verify, mark — each doing one thing, each replayable, each survivable if the network drops between steps.
The framework — the three-step validator handshake
The flow has three steps with deliberately small responsibilities.
- Scan. The scanner reads the QR payload and POSTs it to a single validation endpoint. No client-side parsing, no client-side cache.
- Verify. The server checks the payload's signature (or the embedded ids' existence), the order status, the seat status, and the event session timing. It returns a structured decision: allow, deny-and-reason, or already-used.
- Mark. On allow, the server marks the seat as entered in the same request. A second tap of the same QR returns "already-used" without re-running the full verify path.
The name worth remembering is "decision in step two, write in step three, both behind one request." The customer-facing scanner round-trips once; the server fans the work out internally.
Each step with one paragraph of explanation
Scan. The scanner is a thin layer over the camera. Modern browsers expose enough of the camera API that a Next.js page using a library like @yudiel/react-qr-scanner can read the QR string and POST it to the server in fifteen lines of TypeScript. The scanner does not parse, does not verify, does not cache. If the network is down, the request fails and the door staff scan again — but the scanner never decides for itself, because the scanner is the one component you cannot trust.
Verify. The server is the one place that has the full picture: the Order, the Seat, the EventSession, and the entry log. Verification is a single transactional read that resolves the QR to a (Order, Seat) pair, checks that the Order is COMPLETED, that the seat belongs to the right session, and that the session has actually started (entry usually opens an hour before showtime, not on the day the ticket was bought). Every failure mode has a name. The scanner displays the name. The door staff decide.
Mark. Marking is the write that makes the scan idempotent. A separate EntryLog row, or a usedAt column on the seat-order relation. The mark happens in the same request as the verify, in the same transaction, so the database refuses a double-allow even under network retries. The second scan of the same QR returns "already-used" because the mark is now in the database, not because the scanner remembered.
Walk the framework through a real artifact
The validator endpoint in an event-ticketing platform looks like this. It is mounted under /api/public/validate deliberately — the door scanner runs on shared venue tablets and cannot carry per-staff credentials. The scoping is at the venue-staff session layer, not the request layer:
// app/(api)/api/public/validate/route.ts
import { prisma } from '@/libs/prisma';
export async function POST(req: Request) {
const { qrPayload } = await req.json();
// QR payload format: `${eventSessionOrderId}-${seatId}`
const [orderId, seatId] = parseQrPayload(qrPayload);
if (!orderId || !seatId) {
return Response.json({ decision: 'deny', reason: 'invalid-payload' }, { status: 400 });
}
const result = await prisma.$transaction(async (tx) => {
const order = await tx.eventSessionOrder.findUnique({
where: { id: orderId },
include: { eventSession: true, seats: true },
});
if (!order) return { decision: 'deny', reason: 'order-not-found' };
if (order.status !== 'COMPLETED') return { decision: 'deny', reason: `order-${order.status.toLowerCase()}` };
const seat = order.seats.find((s) => s.id === seatId);
if (!seat) return { decision: 'deny', reason: 'seat-not-on-order' };
// Session must have started — entry typically opens 60 minutes before.
const entryOpensAt = new Date(order.eventSession.startsAt);
entryOpensAt.setMinutes(entryOpensAt.getMinutes() - 60);
if (entryOpensAt > new Date()) {
return { decision: 'deny', reason: 'too-early', entryOpensAt };
}
// Check whether this (order, seat) was already used.
const existing = await tx.entryLog.findUnique({
where: { orderId_seatId: { orderId, seatId } },
});
if (existing) {
return { decision: 'already-used', usedAt: existing.usedAt, scannedBy: existing.scannedBy };
}
// Step 3: mark.
await tx.entryLog.create({
data: { orderId, seatId, scannedBy: 'venue-scanner', usedAt: new Date() },
});
return { decision: 'allow', seat: { name: seat.name }, order: { pnr: order.pnrNumber } };
});
return Response.json(result);
}
The decision tree is in step two; the write is in step three; both are inside one transaction. There is no second round-trip from the scanner. The scanner posts once and renders one of four UI states: allow (green, brief sound), deny (red, with the reason in plain language), already-used (yellow, with the timestamp), too-early (yellow, with the entry-opens-at time).
The EntryLog model is the durable record of who passed and when. It earns its keep because the venue manager will be asked the next morning how many people entered, and the answer is one SELECT COUNT(*) against EntryLog scoped to the session — not a derived guess from order status:
model EntryLog {
id String @id @default(cuid())
orderId String
seatId String
scannedBy String
usedAt DateTime @default(now())
notes String?
@@unique([orderId, seatId])
}
The composite unique constraint is the database-level guarantee that a double-tap cannot create two entries.
Where the framework fails
Offline scanners. The framework assumes the scanner can reach the server every time. At a festival with a flaky venue network this assumption breaks. The mitigation is a thin offline mode: the scanner caches a signed list of valid (order, seat) pairs at the start of the night, decides locally if the network is down, and syncs EntryLog writes when the network returns. The mitigation adds two non-trivial moving parts (the signed list, the sync queue), and the right time to add them is the first time a venue tells you their wifi died at gate-open.
QR theft. If the QR payload is a plain ${orderId}-${seatId} string, a customer who screenshots their ticket and shares it with a friend has effectively given the friend a working ticket — until one of them scans. The framework's already-used path handles the second scan correctly, but the first scan wins, and the customer who paid may not be the customer who entered. Two mitigations: rotating tokens (the QR re-derives every few seconds via a shared secret), or a "name on the door" backup check. Most ticketing platforms rely on the social-cost-of-fraud being higher than the payoff and ship with neither.
Validator-clock skew. The "too-early" check above uses the server clock. If venue staff are testing the scanner an hour before showtime and the server clock is off, the door will deny everyone with too-early until the actual start. NTP on the server fixes this, but the production lesson is to surface a small countdown next to the deny reason so the staff know whether to retry.
CTA — the prompt or question that triggers the framework
If you are about to ship a ticket-scanning interaction, the first question is: where does the decision happen? If the answer is "in the scanner," your architecture is already in trouble — caches drift, devices clone, decisions desync. The decision belongs on the server. The scanner is a camera and a UI.
The second question is: how do you protect against a double-tap? If the answer is "the scanner remembers," replace it with a database constraint. @@unique([orderId, seatId]) on the entry log is one line of schema that closes the door on every retry scenario the scanner could imagine.
Trade-off
The framework accepts a round-trip per scan. On a fast venue network this is invisible (sub-200ms). On a slow one — and stadium networks at gate-open are slow — the scan can feel sluggish, and door staff may scan the same ticket twice before the first response arrives. The already-used path saves the day there (the second response says "this seat just entered" instead of failing), but the UX is not the best.
The alternative — local caching and offline validation — buys speed at the cost of every failure mode listed above. The framework trade is "always correct, sometimes slow" against "always fast, occasionally wrong." For ticketed events, correct is the default that ticketing platforms ship with. Speed is the optimisation you add for the specific venue that needs it.
Business impact
Door operations are the most visible failure surface in a ticketing platform. A customer who could not buy a ticket complains in a private DM; a customer stuck outside a venue at showtime complains on social media in a way that costs the venue future bookings. The validator handshake is the contract between the platform and the venue staff. It needs to work consistently, give clear reasons when it does not, and not turn into a queue at gate-open.
The framework's structural choice — server-side decision, idempotent writes, named failure reasons — is what makes the venue staff trust the scanner. Trust translates to faster lines, fewer escalations to the box office, and a quieter night for the platform engineer on call.
What to do next
If you are starting fresh, model the EntryLog first and the scanner second. The unique constraint is the spine of the framework; the rest is shape.
If you have a scanner that decides locally, the smallest experiment is to add the validator endpoint and have the scanner call it after its local decision, just to compare. The first time the local decision disagrees with the server decision, the discussion shifts naturally toward making the server the source of truth.
The artifact to copy: a POST endpoint that parses a QR payload, runs the four-check decision tree inside a transaction, writes an EntryLog row on allow, and returns one of four named decisions. Forty lines of TypeScript, one Prisma model, one composite unique index. The rest is venue-specific polish.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox