From buying a ticket to rating the host
The complete, logically-ordered attendee journey — buy a ticket → travel to the venue → scan the check-in QR → join the room → rate the host. Each step gates the next: you can't check in without a ticket, and you can't rate without having attended.
01 · The journey, as a flow
Every arrow is guarded. The diamonds are the checks that keep the flow honest — a wrong QR, a missing ticket, an early arrival, or a duplicate all bounce back instead of corrupting state.
02 · Step 1 — Buy the ticket
Pick a tier and pay one all-in price
On the experience detail page the guest taps Get tickets, picks a tier, and pays. No fees added at checkout — the price is the price.
ExperienceTicket(VALID); atomic tier.quantitySold += 1; returns the ticket + its ticketToken. Payment capture is stubbed (assumed paid).
remaining > 0 (oversell-safe atomic decrement) and one active ticket per user (a DB partial-unique — a concurrent double-buy rolls the whole purchase back). The host can't buy their own.03 · Step 2 — Your ticket & directions
Hold the proof, navigate to the room
The ticket lives in the wallet with its own QR (proof of purchase). The card shows the venue and time; one tap opens maps to the latitude/longitude from the experience.
myTicket (the ticket + its ticketToken/QR = the proof) and checkedIn, plus the location for directions. Anonymous callers get myTicket: null.
StateTicket stays VALID until it is used at check-in (or expires / is refunded)
ticketToken is not.04 · Step 3 — Scan the check-in QR at the venue
The guest scans the event's code — not the other way around
At the venue the host displays the check-in QR — read from GET /api/experiences/mine → data[].qrToken (the unique check-in code; there's no separate "get QR" endpoint). The attendee opens Check in and scans it; the app sends just the scanned token — no experience id — and the server resolves which experience it is and finds the caller's VALID ticket.
{ "qrToken": "<scanned>", "ticketId": "<mine>" }
Inputs from🔗 qrToken ← scan the venue QR (the host reads it from GET /experiences/mine → data[].qrToken). That one token identifies the experience — no :id is sent. · ticketId optional, from POST /:id/purchase → data.id (the server resolves your VALID ticket either way)
Writeson success → ExperienceAttendance(CHECKED_IN), ticket → USED, currentGuests += 1
qrToken resolves to a live experience (globally unique) · ② the caller holds a VALID ticket for it · ③ now is inside the check-in window (e.g. from startTime − 1h to endTime) · ④ the caller isn't already checked in. Fail any → a specific 4xx, no state change.05 · Step 4 — Join the event
Checked in = in the room
The successful check-in is the moment of joining: the attendee is counted (currentGuests), the event chat opens, and they can see who else is here. The host's scanner/dashboard ticks up live.
check-in response + GET /api/experiences/:id built for the live count
StateAttendance CHECKED_IN unlocks the chat membership & the post-event review (later)
06 · Step 5 — Rate the host
Only attendees can rate — and only once
After the event ends, the attendee rates the host 1–5 with an optional note. The score rolls up into the host's rating (the ★ 4.9 you see on cards and detail).
{ "rating": 5, "comment": "Loved it" }
Writescreates Review, recomputes the host's average rating
Attendance(CHECKED_IN) for this experience (you attended) · ② the event has ended · ③ no existing review from this user (one review each). This is why buying + checking in must come first.07 · QR check-in mechanics (the important bit)
The subtle part is who scans what. In this flow the venue shows the event's QR and the attendee scans it — so a guest can self-serve at the door. The event token proves "you're at the right event"; the ticket proves "you paid". You need both.
qrCodeToken can rotate (short TTL), so a screenshot shared later is already stale.Alternative: host-scans-attendee
The mirror image also works and is sometimes preferred for gated venues: the attendee shows their ticket QR and the host's scanner reads it (POST /experiences/:id/scan {ticketToken}). Same result — an Attendance row — just the opposite direction. This repo already has a ExperienceAttendance model + scan module to build either on.
08 · State machines
Three tiny lifecycles keep the flow consistent. Nothing skips a state.
Ticket
Attendance
Review eligibility
09 · Why the flow is logically correct
Each action has preconditions that can only be satisfied by completing the earlier steps — so the sequence can't be short-circuited.
| Action | Preconditions (must all be true) | Bounced with |
|---|---|---|
| Buy ticket | experience PUBLISHED; tier ON_SALE; remaining > 0; payment captured | 409 sold out · 402 payment failed |
| Check in | holds a VALID ticket; scanned qrToken matches the experience; within check-in window; not already checked in | 403 no ticket · 400 wrong/expired code · 409 too early / already in |
| Join (chat / count) | has Attendance(CHECKED_IN) | gated by check-in — no separate call |
| Rate host | attended (checked in); event ended; no prior review by this user | 403 didn't attend · 409 too early / already reviewed |
ticket ⟶ (enables) ⟶ check-in ⟶ (enables) ⟶ join ⟶ (enables) ⟶ review. Remove any link and the next action has no way to satisfy its precondition — which is exactly what makes it tamper-resistant.10 · API map
All of these are now implemented in the experience module (see the cURL docs, attendee-journey-api-docs.html). The only stub is payment capture — purchase assumes the payment was taken.
| Step | Endpoint | Status | Notes |
|---|---|---|---|
| View | GET /api/experiences/:id | built | detail: tiers, price, location, host, offer |
| 1 · Buy | POST /api/experiences/:id/purchase | built | ExperienceTicket; atomic quantitySold++ |
| 3 · Check in | POST /api/experiences/check-in | built | resolves the experience from qrToken (no id); writes ExperienceAttendance; verifies ticket + time |
| 4 · Join | — | built | a consequence of check-in (count + chat) |
| 5 · Rate | POST /api/experiences/:id/review | built | ExperienceReview; recompute host average; attend-gated |
| Host | GET /api/experiences/mine | built | each card carries qrToken — the check-in QR the host displays (owner-only); no separate get-QR route |
| Host | GET /api/experiences/:id/ticket-holders | built | who bought + a live checkedIn flag |
| Host | GET /api/experiences/:id/attendees | built | who checked in |
Request/response — check-in
curl -X POST "http://localhost:5001/api/experiences/104821/check-in" \
-H "Authorization: Bearer YOUR_JWT_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "qrToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4", "ticketId": "9001" }'
{
"success": true,
"message": "You're in — welcome!",
"data": {
"experienceId": "104821",
"attendance": { "status": "CHECKED_IN", "checkedInAt": "2026-07-06T20:04:00.000Z" },
"ticket": { "id": "9001", "status": "USED" },
"currentGuests": 7
}
}
qrCodeToken + ExperienceAttendance primitives.