◆ API · cURL reference

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.

Base http://localhost:5001/api Auth JWT Bearer Buy POST /:id/purchase Check in POST /check-in Rate POST /:id/review
Auth — all three require 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 screens — every endpoint below maps to a mockup in design/index.html. Attendee flow, left→right: 35 Experiences list38 Experience detail40 Checkout · all-in45 You're in · calendar46 QR check-in47 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/minedata[].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'
Steps 2 (get ticket / directions) and 4 (join the room) are consequences, not separate calls — the ticket is the purchase response, and "joined" is the successful check-in. The host's ★ 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 thisYou needGet it from
GET /:id (detail):ida listing — GET /experiences/nearby or /minedata[].id
POST /:id/purchase:idthe listing / detail above
tierIdGET /:iddata.ticketTiers[].id
POST /check-inqrTokenscan the venue QR — the host read it from GET /experiences/minedata[].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:idthe detail / your ticket
attendance (implicit)POST /check-in must have succeeded first
/ticket-holders, /attendees (host):idGET /experiences/minedata[].id (each card also carries qrToken)

02 · View — experience detail

GET/api/experiences/:id public screen 38
📱 Screen 38 · Experience detail (design/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.

🔗 Inputs from:id comes from a listing: GET /experiences/nearby or GET /experiences/minedata[].id.

03 · Step 1 — Buy a ticket

POST/api/experiences/:id/purchase JWT screen 40

Issues 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.

📱 Screen 40 · Checkout · all-in (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).
🔗 Inputs from:id from a listing (/nearby or /mine); tierId from GET /:iddata.ticketTiers[].id (pick a tier that's ON_SALE with remaining > 0).

Body

FieldTypeReqNotes
tierIdnumber/stringrequireda 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

StatusWhen
404experience or tier not found
409not published · tier closed · sold out · you already have a ticket
403you're the host (can't buy your own)
400 / 401missing tierId · no token

04 · Step 3 — Check in (scan the venue QR)

POST/api/experiences/check-in JWT screen 46

The 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.

📱 Screen 46 · QR check-in (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.
🔗 Inputs fromqrToken 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

FieldTypeReqNotes
qrTokenstringrequiredthe scanned token; identifies the experience (matched against qrCodeToken)
ticketIdnumber/stringoptionalignored 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

StatusWhen
400unknown or expired code — no live experience matches this qrToken (also covers a draft/cancelled event)
403ticketed event but you hold no valid ticket · you're the host (hosts don't check in)
409check-in isn't open yet (>1h early) · closed (>12h after start) · already checked in
A valid ticket owned by the caller is required, so photographing the venue QR isn't enough to get in. Free (non-ticketed) experiences skip the ticket check.

05 · Step 5 — Rate the host

POST/api/experiences/:id/review JWT screen 47

Only attendees can review, once, after the experience has started. The score rolls into the host's average (surfaced on GET /:id as host.rating).

📱 Screen 47 · Post-event review (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.
🔗 Inputs from: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

FieldTypeReqNotes
ratingintegerrequired1–5
commentstringoptional≤ 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

StatusWhen
403you didn't attend (no check-in) · you're the host
409experience hasn't started yet · you already reviewed it
400rating 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).

🔗 Inputs from — host endpoints take :id from GET /experiences/minedata[].id. That same list is also where the host reads the check-in QR (see below).
📱 Screens (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

GET/api/experiences/mine host only screen 55 · 58

There 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

POST/api/experiences/:id/qr/rotate host only screen 58

Mints 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

GET/api/experiences/:id/ticket-holders host only screen 55

Every 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)

GET/api/experiences/:id/attendees host only screen 58

Everyone 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 }
}
The loop closes: host publishes → shows the QR (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.

ActionPreconditionsEnforced by
BuyPUBLISHED · not the host · tier open & remaining > 0 · no existing active ticketatomic quantitySold < quantityTotal increment (no oversell)
Check intoken matches · holds a VALID ticket · in window · not already in@@unique(experienceId,userId) on attendance
Reviewattended · started · not the host · not already reviewed@@unique(experienceId,userId) on review
Chain: ticket → check-in → review. Remove a link and the next call can't satisfy its precondition.
Setup: apply the migration once — node src/scripts/applyExperienceJourneyMigration.js — then the tables (ExperienceTicket, ExperienceAttendance, ExperienceReview) exist. Payment capture is stubbed; wire Stripe into purchase when ready.