Attendee journey — buy, check in, rate
Three authenticated endpoints on the experience module that carry a guest from ticket to review. Each gates the next: no check-in without a ticket, no review without attending.
Authorization: Bearer <jwt>. The detail read (GET /:id) is public. Set BASE=http://localhost:5001/api and TOKEN=… in your shell to run the snippets.design/index.html. Attendee flow, left→right:
35 Experiences list→38 Experience detail→40 Checkout · all-in→45 You're in · calendar→46 QR check-in→47 Post-event review.
Host side: 55 Host home58 Host scanner. Edge cases: 39 Sold out · waitlist49 Refund & transfer50 Dispute a check-in.01 · The whole journey, in cURL
Run these in order. Uses jq to thread the ids/token between calls. For check-in you need the experience's qrToken — in the app it's obtained by scanning the venue's QR, which the host reads from GET /experiences/mine → data[].qrToken (see Host side).
BASE="http://localhost:5001/api"
TOKEN="eyJhbGciOi..." # attendee JWT
EXP_ID="104821" # a PUBLISHED experience
# The host publishes it, then reads the check-in code:
# QR=$(curl -s "$BASE/experiences/mine" -H "Authorization: Bearer $HOST_TOKEN" | jq -r --arg id "$EXP_ID" '.data[] | select(.id==$id) | .qrToken')
QR="a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" # experience.qrToken (host displays it at the door)
# 0) See what's on sale (public)
curl -s "$BASE/experiences/$EXP_ID" | jq '.data.ticketTiers[] | {id,name,price,remaining,status}'
# 1) BUY — pick a tier id from step 0
TIER_ID=$(curl -s "$BASE/experiences/$EXP_ID" | jq -r '.data.ticketTiers[0].id')
curl -s -X POST "$BASE/experiences/$EXP_ID/purchase" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{ \"tierId\": $TIER_ID }" | jq '.data | {id,status,price,ticketToken}'
# 3) CHECK IN — scan the venue QR (send its token; the server finds the experience from it)
curl -s -X POST "$BASE/experiences/check-in" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{ \"qrToken\": \"$QR\" }" | jq '.data'
# 5) REVIEW — after it starts
curl -s -X POST "$BASE/experiences/$EXP_ID/review" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "rating": 5, "comment": "Loved it — great host" }' | jq '.data'★ rating then shows up on GET /:id.Where each input comes from
Every id/token you pass into one call is a field returned by another call — nothing is invented on the client. Read this table top-to-bottom to trace the data.
| To call this | You need | Get it from |
|---|---|---|
GET /:id (detail) | :id | a listing — GET /experiences/nearby or /mine → data[].id |
POST /:id/purchase | :id | the listing / detail above |
tierId | GET /:id → data.ticketTiers[].id | |
POST /check-in | qrToken | scan the venue QR — the host read it from GET /experiences/mine → data[].qrToken. The server resolves the experience from this token — no :id is sent. |
| a valid ticket (implicit) | POST /:id/purchase must have happened first | |
POST /:id/review | :id | the detail / your ticket |
| attendance (implicit) | POST /check-in must have succeeded first | |
/ticket-holders, /attendees (host) | :id | GET /experiences/mine → data[].id (each card also carries qrToken) |
02 · View — experience detail
/api/experiences/:id public screen 38design/index.html) — the buy screen. The same call also backs 45 You're in · calendar once you hold a ticket (myTicket), and 39 Sold out · waitlist when every tier is SOLD_OUT.The detail page. Read the tier ids & price to buy, the location to navigate, and the host's rating (now real — computed from reviews).
curl -s "$BASE/experiences/104821" | jq '{title, host, priceFrom, tiers: .data.ticketTiers}'Returns ticketTiers[] (with id, price, remaining, status), host { id, name, avatarUrl, rating, reviewCount }, location, offer, priceFrom. The check-in qrCodeToken is not exposed here (host-side secret).
Called with a token by someone who holds a ticket, the response also includes myTicket (their ticket + its ticketToken — the wallet proof/QR) and checkedIn. Anonymous callers get myTicket: null. So this one call powers the "You're in" screen: experience + your ticket + directions.
:id comes from a listing: GET /experiences/nearby or GET /experiences/mine → data[].id.03 · Step 1 — Buy a ticket
/api/experiences/:id/purchase JWT screen 40Issues one ticket for the chosen tier. Oversell-safe (atomic inventory decrement); one active ticket per user per experience. Payment capture is stubbed (out of scope) — the endpoint represents a completed purchase.
design/index.html) — reached from the Buy CTA on 38 Experience detail. The payment sheets 41 Payment · Stripe42 3D Secure are the stubbed capture; on success the guest lands on 45 You're in · calendar (the ticket this endpoint returns).:id from a listing (/nearby or /mine); tierId from GET /:id → data.ticketTiers[].id (pick a tier that's ON_SALE with remaining > 0).Body
| Field | Type | Req | Notes |
|---|---|---|---|
tierId | number/string | required | a tier id from the detail's ticketTiers[] |
cURL
curl -X POST "$BASE/experiences/104821/purchase" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "tierId": 9001 }'Success — 201
{
"success": true,
"message": "Ticket purchased",
"data": {
"id": "50001", "experienceId": "104821", "tierId": "9001", "tierName": "Early Bird",
"userId": "5", "priceCents": 1200, "price": 12, "currency": "USD",
"ticketToken": "f0e1d2c3b4a5f0e1d2c3b4a5f0e1d2c3",
"status": "VALID", "purchasedAt": "2026-07-06T18:30:00.000Z", "usedAt": null
}
}Errors
| Status | When |
|---|---|
404 | experience or tier not found |
409 | not published · tier closed · sold out · you already have a ticket |
403 | you're the host (can't buy your own) |
400 / 401 | missing tierId · no token |
04 · Step 3 — Check in (scan the venue QR)
/api/experiences/check-in JWT screen 46The attendee scans the event's check-in QR at the venue and sends its token. No experience id in the path — the token is globally unique, so the server resolves which experience it belongs to. On success: attendance is recorded, the ticket flips to USED, and currentGuests ticks up — all atomically.
design/index.html) — the attendee-side scanner that reads the venue QR and fires this call. The host displays that QR from 58 Host scanner. A failed check-in surfaces on 50 Dispute a check-in.qrToken comes from scanning the venue QR, which the host read from GET /experiences/mine (→ data[].qrToken). That single token is enough; the experience is looked up from it. You must also already hold a ticket from POST /:id/purchase (the server finds your VALID ticket automatically).Body
| Field | Type | Req | Notes |
|---|---|---|---|
qrToken | string | required | the scanned token; identifies the experience (matched against qrCodeToken) |
ticketId | number/string | optional | ignored for resolution — the server finds your VALID ticket |
cURL
curl -X POST "$BASE/experiences/check-in" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "qrToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }'Success — 200
{
"success": true,
"message": "You're in — welcome!",
"data": {
"experienceId": "104821",
"attendance": { "status": "CHECKED_IN", "checkedInAt": "2026-07-06T20:04:00.000Z" },
"ticket": { "id": "50001", "status": "USED" },
"currentGuests": 7
}
}Guards & errors
| Status | When |
|---|---|
400 | unknown or expired code — no live experience matches this qrToken (also covers a draft/cancelled event) |
403 | ticketed event but you hold no valid ticket · you're the host (hosts don't check in) |
409 | check-in isn't open yet (>1h early) · closed (>12h after start) · already checked in |
05 · Step 5 — Rate the host
/api/experiences/:id/review JWT screen 47Only attendees can review, once, after the experience has started. The score rolls into the host's average (surfaced on GET /:id as host.rating).
design/index.html) — the star-rating card shown after the event; its submit fires this call, and the resulting average feeds the host's ★ rating back on 38 Experience detail.:id from your ticket / the detail. You can only reach this after POST /check-in succeeded — the attendance record it created is the gate.Body
| Field | Type | Req | Notes |
|---|---|---|---|
rating | integer | required | 1–5 |
comment | string | optional | ≤ 500 chars |
cURL
curl -X POST "$BASE/experiences/104821/review" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d '{ "rating": 5, "comment": "Loved it — great host" }'Success — 201
{
"success": true,
"message": "Thanks for the review!",
"data": {
"review": { "id": "700", "experienceId": "104821", "userId": "5", "rating": 5, "comment": "Loved it — great host", "createdAt": "2026-07-07T00:10:00.000Z" },
"hostRating": { "average": 4.9, "count": 37 }
}
}Errors
| Status | When |
|---|---|
403 | you didn't attend (no check-in) · you're the host |
409 | experience hasn't started yet · you already reviewed it |
400 | rating missing / not 1–5 |
06 · Host side — the QR to display, and who bought / attended
These are owner-only (you must be the experience's host).
:id from GET /experiences/mine → data[].id. That same list is also where the host reads the check-in QR (see below).design/index.html) — 55 Host home lists the host's experiences (buyers/attendees drill-down), and 58 Host scanner is where the host displays the check-in QR and watches the live checked-in roster.The check-in QR — on GET /experiences/mine
/api/experiences/mine host only screen 55 · 58There is no separate "get QR" endpoint. Every card in the host's own list carries qrToken (the check-in code — null until published). The host renders it as a QR and shows it at the door; attendees scan it in-app to check in. The token is globally unique, so no deep link / website is involved, and the list is host-only so the code is never exposed publicly.
curl "$BASE/experiences/mine" -H "Authorization: Bearer $HOST_TOKEN" \
| jq '.data[] | {id, title, status, qrToken}'{
"success": true,
"message": "Experiences fetched",
"data": [
{ "id": "104821", "title": "Natural Wine & Strangers", "status": "PUBLISHED",
"coverImage": null, "startTime": "2026-07-06T20:00:00.000Z",
"locationName": "The Aviary, LES", "currentGuests": 7, "capacity": 10,
"qrToken": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" }
],
"meta": { "page": 1, "limit": 20, "total": 1, "totalPages": 1 }
}Rotate the check-in code optional
/api/experiences/:id/qr/rotate host only screen 58Mints a fresh token and invalidates the old one — so a screenshot of the old QR can't be reused. Returns the new qrToken. Re-render the QR and re-display it.
curl -X POST "$BASE/experiences/104821/qr/rotate" -H "Authorization: Bearer $HOST_TOKEN"Who bought tickets
/api/experiences/:id/ticket-holders host only screen 55Every buyer, newest first, with their tier, ticket status, and whether they've checked in yet. Query: page, limit (≤100), status (VALID/USED). Meta carries the sold count + revenue.
curl "$BASE/experiences/104821/ticket-holders?page=1&limit=50" -H "Authorization: Bearer $HOST_TOKEN"{
"success": true,
"message": "Ticket holders fetched",
"data": [
{
"ticketId": "50001", "tierId": "9001", "tierName": "Early Bird",
"priceCents": 1200, "price": 12, "currency": "USD",
"status": "USED", "checkedIn": true,
"purchasedAt": "2026-07-06T18:30:00.000Z", "usedAt": "2026-07-06T20:04:00.000Z",
"user": { "id": "9", "name": "Leo C.", "avatarUrl": null }
}
],
"meta": { "page": 1, "limit": 50, "total": 6, "totalPages": 1, "soldTotal": 6, "revenueCents": 7200 }
}Who attended (checked in)
/api/experiences/:id/attendees host only screen 58Everyone who scanned in, newest first. Meta carries currentGuests / capacity.
curl "$BASE/experiences/104821/attendees" -H "Authorization: Bearer $HOST_TOKEN"{
"success": true,
"message": "Attendees fetched",
"data": [
{ "userId": "9", "checkedInAt": "2026-07-06T20:04:00.000Z", "ticketId": "50001", "user": { "id": "9", "name": "Leo C.", "avatarUrl": null } }
],
"meta": { "page": 1, "limit": 20, "total": 7, "totalPages": 1, "currentGuests": 7, "capacity": 10 }
}qrToken from GET /experiences/mine) → attendees scan & check in → host watches GET /:id/ticket-holders (with a live checkedIn flag) and GET /:id/attendees fill up.07 · Guards & the dependency chain
Each action's preconditions can only be met by completing the earlier step — so the sequence can't be skipped, and the DB @@unique constraints make the "once only" rules race-safe.
| Action | Preconditions | Enforced by |
|---|---|---|
| Buy | PUBLISHED · not the host · tier open & remaining > 0 · no existing active ticket | atomic quantitySold < quantityTotal increment (no oversell) |
| Check in | token matches · holds a VALID ticket · in window · not already in | @@unique(experienceId,userId) on attendance |
| Review | attended · started · not the host · not already reviewed | @@unique(experienceId,userId) on review |
ticket → check-in → review. Remove a link and the next call can't satisfy its precondition.node src/scripts/applyExperienceJourneyMigration.js — then the tables (ExperienceTicket, ExperienceAttendance, ExperienceReview) exist. Payment capture is stubbed; wire Stripe into purchase when ready.