Work · Ticketing & check-in · High-ticket education business · sibling of CS-03
Event ticketing & door check-in
The dashboard said 50 registered. The room had 251 people in it.
A ticketing system that turns a workshop purchase into a signed single-use QR ticket, emails it under the client’s branding, and checks attendees in on any phone at the door — attendance, no-shows and per-ticket revenue landing straight in the CRM.
- Next.js
- GoHighLevel
- QR + jsQR
- Vercel
- Role · sole designer & builder
- 7 min
- tickets issued at the first live event
- 57Scope
- checked in at the door — an 87% show rate, measured
- 48Scope
- duplicate sends across a 166-ticket backlog
- 0Scope
- System size
- System flow · 9 stages · 44 components reconstructed from the build record
The situation
The client runs paid workshops. Tickets sold, people showed up, and somebody stood at the door with a printed list and a pen. Attendance was counted by hand, revenue lived in a spreadsheet, and neither number made it onto the customer record in the CRM.
Then a live event reported 50 registrants when 251 people were actually on the list. It also reported zero attended. Nobody could tell whether the system had lost people, whether the door had failed, or whether the number itself was simply wrong.
I was asked to build the ticketing system, and to find out what had happened at that event. The two turned out to be the same job. The investigation produced most of the design.
What was broken
- Attendance was counted by hand at the door, so no-shows and per-ticket revenue never reached the CRM.
- The dashboard reported 50 registrants against 251 real ones. An earlier event showed 51 against 57, which looked believable enough that nobody noticed.
- The door scanner gated camera scanning on a browser capability one major mobile platform has never shipped. Staff saw a message saying the browser could not scan, and were left with manual entry for a queue of 251 people.
- Per-event settings were hardcoded, so every new event needed a developer edit and a redeploy, and both settings failed silently when wrong.
- A ticket's price was read from the buyer's most recent purchase with nothing tying it to the event, so a free event reported real revenue.
- A stray tag could mint tickets against an event that had already finished, silently corrupting its registered count.
Constraints
- The CRM stays the system of record. The client edits their own ticket email, so the code had to hand delivery back rather than send mail itself.
- The confirmation and reminder ladder the client already ran had to keep working untouched while the new path was proven beside it.
- Door staff cannot wait for a deploy, so passcodes and per-event settings had to be changeable in the CRM.
- The scanner had to run on whatever phone a staff member happened to be holding.
- Nothing could write to the live account until an operator explicitly allowed it.
- The platform's own relation reads could not be trusted, which is what the investigation established.
FIG 01 · System Flow
How the system actually runs
Every component below is reconstructed from the project’s own build records. Nothing is added to make it look bigger.
- components
- 44
- components
- stages
- 9
- stages
- decisions & guards
- 14
- decisions & guards
- AI
- 0
- AI
- human handoffs
- 6
- human handoffs
- platforms
- 5
- platforms
- Stage 1, Per-event configuration: Everything a new event needs lives on a CRM record, not in code.
- Event record (CRM record, GoHighLevel object). One record per event carrying name, a stable event id, type, status, start and end dates and venue fields. Connects to Three config fields when staff fills the three fields; Staff picks the event when scheduled or in progress.
- Three config fields (CRM record, GoHighLevel object). Registrant tag, walk-in tag and product ids, created live on the event object and backfilled for existing events. Connects to Issuance webhook when resolve by tag or product id; Walk-in door register when walk-in tag.
- Ticket record (CRM record, GoHighLevel object). One record per seat: ticket id, type, status, price, purchase date, buyer name, redeemed-at, issued-via and per-day check-in days. Connects to Signed event-scoped token; No-show finalisation when still issued on a finished event; Two-ended ticket read.
- Write-mode gate (Guard, Environment). A single variable resolves to dry-run, log-only, add-only or write-approved, with anything unset or unrecognised treated as dry-run. Connects to Issuance coordinator when write-approved mode; Check-in endpoint when write-approved mode.
- Stage 2, Purchase / registration: The real-world act that should produce a seat.
- Purchase or registration (Payment, GoHighLevel + Stripe). A buyer completes a paid workshop purchase, or a registrant submits the free-event form. Connects to Registrant tag applied.
- Registrant tag applied (CRM record, GoHighLevel workflow). The client’s existing tagging workflow applies a per-event registrant tag; the confirmation and reminder ladder they already had is left completely unchanged. Connects to Bridge workflow when ticketed segment; Backlog backfill when registered before go-live.
- Bridge workflow (Workflow, GoHighLevel). A tag-triggered workflow posts the contact to the issuance endpoint, carrying the shared automation secret. Connects to Issuance webhook when POST with shared secret.
- Backlog backfill (Scheduled job, Node script). An operator-run script that finds every contact holding a given registrant tag and runs each through the same issuance path, printing an issued / reused / skipped / failed summary. Connects to Issuance webhook when one contact every ~1.5s.
- Stage 3, Ticket issuance: Resolve which event, dedupe, price honestly, and mint exactly one ticket.
- Issuance webhook (API / service, Next.js route). Authenticates the shared secret, then resolves the event four ways, first match wins: an explicit event id, a named registrant tag, the contact’s own tag list, or a product id resolved through the event record. Connects to Finished-event refusal when automated path only; Issuance coordinator when event resolved.
- Finished-event refusal (Guard, Coordinator). The automated path refuses to issue against an event whose last day has passed. The human paths — the door register and the admin desk — deliberately leave the flag off. Connects to Issuance coordinator when last day has not passed.
- Issuance coordinator (Workflow, Server module). Resolves the event’s record id, runs the dedupe lookup, and builds a write plan so issuance passes through the same safety gate the rest of the system uses. Connects to Dedupe or reuse; Event-scoped pricing; Ticket record when mint or reuse a seat; Attendance history row.
- Dedupe or reuse (Decision, Ticket service). Before creating, search for an existing ticket for this contact and event; if one exists, reuse its code and re-deliver rather than minting a second. Connects to Write ticket URLs when created OR reused.
- Event-scoped pricing (Guard, Order lookup). A ticket’s price is read from the contact’s most recent completed order — but only for a product this event actually sells. An event that sells nothing is free, and its tickets cost nothing. Connects to Write ticket URLs when price resolved, or zero.
- Attendance history row (Data store, GoHighLevel object). An attendance-history record created alongside the ticket, deduped to one row per contact and event. Connects to Per-event metrics.
- Stage 4, QR delivery: Get a scannable, branded ticket into the attendee’s hands — and keep re-delivery possible.
- Write ticket URLs (CRM record, GoHighLevel contact). Writes two merge-field URLs onto the contact: the live QR image endpoint and the hosted ticket page. Connects to Delivery tag added when URL write succeeded; Never email a broken QR when URL write failed.
- Never email a broken QR (Guard, Issuance). If the URL write fails, the ticket is kept but the delivery tag is not added, so no email goes out.
- Delivery tag added (CRM record, GoHighLevel). A ticket-issued tag is applied to the contact. Connects to Ticket email workflow.
- Ticket email workflow (Workflow, GoHighLevel). Fires on the delivery tag, sends the client-branded email with the QR as a merge-field image and a “view your ticket” button, then removes the tag as its last step. Connects to Delivery tag added when tag removed last, so a re-issue fires again; QR image & ticket page when branded email delivered.
- QR image & ticket page (API / service, Next.js). A QR endpoint that looks up the ticket, re-signs its token deterministically and draws the PNG at request time; and a branded hosted page with logo, event details, attendee name and a full-screen QR. Connects to Read the QR when attendee presents the QR.
- Stage 5, Door operations: Authenticate staff, pick the event, and read a code on whatever phone is in someone’s hand.
- Passcode to session (Guard, Next.js route). Two tiers, scanner and admin. Passcodes are read live from CRM settings so the client can rotate door codes without a redeploy, then exchanged for a short-lived signed session. Connects to Staff picks the event when valid session; Walk-in door register when admin tier only.
- Staff picks the event (API / service, Next.js route). Lists scheduled and in-progress events for the scanner dropdown; the operator selects which event this door is for, fixing the expected event id for every scan that follows. Connects to Read the QR when expected event id fixed.
- Read the QR (Capture, Phone camera). The camera decodes the attendee’s QR, or the operator types the code by hand. Connects to Two-path decoder.
- Two-path decoder (Capture, Browser). Uses the browser’s native barcode detector where it exists, and a JavaScript decoder (jsQR) everywhere else. Connects to Check-in endpoint when a code decoded; Manual code entry when no decoder available at all.
- Manual code entry (Human, Scanner UI). Typing the code by hand, retained as a genuine fallback. Connects to Check-in endpoint.
- Walk-in door register (Human, Admin page). An admin-tier desk that looks a person up by email or name, creates the contact if needed, issues a ticket on the spot stamped as a door walk-in, and can check someone in by record id with no QR at all. Connects to Issuance coordinator when issue on the spot, stamped a walk-in; Loud silent failure when no walk-in tag configured; Ticket state machine when check in by record, no QR.
- Loud silent failure (Staff alert, Admin page). Registering a walk-in for an event with no walk-in tag configured returns a warning on screen and logs an error, instead of silently issuing a ticket whose confirmation email will never send.
- Stage 6, Check-in decision: Verify, branch on ticket state, and write the redemption as an audited change.
- Signed event-scoped token (Guard, Server module). The QR carries an opaque token — code plus event id, HMAC-signed with a truncated signature and constant-time compared on verify — not the raw ticket id and not a public redeem URL. Connects to Check-in endpoint when signature + event id checked.
- Check-in endpoint (API / service, Next.js route). Session-authenticated. Verifies the signature, requires the token’s event to match the picked event, finds the ticket, then branches on status. Connects to Ticket state machine when signature valid AND event matches.
- Ticket state machine (Decision, Server module). Issued becomes redeemed on a valid scan (green, with name and ticket type and a redemption time); already redeemed shows yellow with when; cancelled, refunded, wrong event or not found show red — the wrong-event case naming the event the ticket IS valid for. Connects to Multi-day check-in when multi-day event; Per-event metrics.
- Multi-day check-in (Data store, Ticket field). A per-ticket list of check-in days, so a two-day event records arrival on each day and a same-day re-scan raises an “already today” alarm rather than a silent no-op. Connects to Per-event metrics when attendance by day.
- Stage 7, Overnight finalisation: Turn silence into a recorded no-show — safely, and never as an accusation.
- Cron secret gate (Guard, Vercel Cron). A daily scheduled request with no user session, guarded by a bearer secret; until that secret is set the route refuses and the cron is inert. Connects to No-show finalisation when bearer secret matches.
- No-show finalisation (Scheduled job, Server module). For every active event whose end date, as an event-local calendar date, is strictly before today and within the last three days, flip every still-issued ticket to no-show and write a no-show attendance row. Connects to Parked-event guard when event id is parked; Messages nobody; Attendance history row when a no-show row per finalised ticket; Per-event metrics when no-show counts now real.
- Messages nobody (Guard, Server module). The sweep performs a status flip and an attendance row and sends nothing — no CRM messaging at all, by standing sign-off rule.
- Parked-event guard (Guard, Code list + env). A named set of events the sweep must never finalise, plus an environment variable that parks future events with no deploy. Shipped before the scheduled run on the morning after the incident event. Connects to Settled by hand when settled by hand instead.
- Settled by hand (Human, Script + review). The 33 people who signed the day’s paper check-in form were marked checked in, each stamped with their own form-submission time rather than the time the repair ran. Connects to Per-event metrics.
- Stage 8, Reporting: Registered, attended, no-show and revenue, derived from two independent reads.
- Two-ended ticket read (Data store, Server module). Reads the event’s relations in one request and checks the result against the total the platform itself reports — then independently re-derives the same list from the other end, sweeping the ticket object by event name and confirming each candidate through its own relations. Connects to Per-event metrics; No-show finalisation when same reader, so the two cannot disagree.
- Per-event metrics (Report, Server module + page). Registered = issued + redeemed + no-show (cancelled and refunded excluded); attended = redeemed; show rate = attended over registered; revenue = the sum of price over non-dead tickets. Connects to Run live at two events; 50 shown, 251 real when the number contradicts the room.
- Run live at two events (Outcome, Production). 57 tickets issued at the first live event with 48 checked in at the door — a measured 87% show rate — and the 166-ticket backlog sent with zero duplicates.
- Stage 9, Root cause & hardening: The two production failures, how they were measured, and what was built because of them.
- 50 shown, 251 real (Staff alert, Production). The dashboard reported 50 registered for an event with 251 real registrants, and 0 attended. An earlier event was affected too — 51 shown against 57 real — but 51 looked believable, so nobody noticed. Connects to Interleaved, unstable paging; The scanner could not scan when attended reads zero.
- Interleaved, unstable paging (Guard, Investigation). Two behaviours compounding: every registrant puts two relations on the event — a ticket and an attendance row — returned interleaved, so a page of 100 held about 50 tickets; and the endpoint does not sort stably, so each next-page request re-slices a reshuffled list. Connects to One read, total-checked.
- One read, total-checked (Guard, Server module). The relation read now issues one request at a large limit, checks what came back against the total the platform reports, retries once at exactly that total, dedupes, and only then falls back to paging behind a loud warning. Connects to Two-ended ticket read when plus an independent re-derivation; Four-pass review loop when after the first deploy.
- The scanner could not scan (Staff alert, Investigation). Found after the first report was written, from a screenshot taken on the day: the scanner gated camera scanning on a browser feature one major mobile platform has never shipped, so the door saw “this browser can’t scan QR codes” and was offered only manual entry, for 251 people. Connects to Two-path decoder when decode in software everywhere else; Report corrected itself when the earlier write-up was wrong; Parked-event guard when no scans means no evidence of absence.
- Report corrected itself (Report, Client doc). The status document records that the first write-up had described the failure as a process choice by the door team — “that was wrong, and unfair to the door team”.
- Four-pass review loop (Guard, Review process). A live account data audit, a codebase hardcoding audit, an adversarial code review and a future-event readiness verification, all run after the first deploy. Connects to One read, total-checked when five shipped defects, one false positive.
Someone buys a workshop ticket at 9pm when nobody is awake, and walks up to the door two weeks later.
0/16Someone buys a workshop ticket at 9pm when nobody is awake, and walks up to the door two weeks later.
Read this system as text
Stage 01
Per-event configuration
Everything a new event needs lives on a CRM record, not in code.
- Event record · CRM record · GoHighLevel object — One record per event carrying name, a stable event id, type, status, start and end dates and venue fields. → Three config fields (staff fills the three fields), Staff picks the event (scheduled or in progress)
- Three config fields · CRM record · GoHighLevel object — Registrant tag, walk-in tag and product ids, created live on the event object and backfilled for existing events. → Issuance webhook (resolve by tag or product id), Walk-in door register (walk-in tag)
- Ticket record · CRM record · GoHighLevel object — One record per seat: ticket id, type, status, price, purchase date, buyer name, redeemed-at, issued-via and per-day check-in days. → Signed event-scoped token, No-show finalisation (still issued on a finished event), Two-ended ticket read
- Write-mode gate · Guard · Environment — A single variable resolves to dry-run, log-only, add-only or write-approved, with anything unset or unrecognised treated as dry-run. → Issuance coordinator (write-approved mode), Check-in endpoint (write-approved mode)
Stage 02
Purchase / registration
The real-world act that should produce a seat.
- Purchase or registration · Payment · GoHighLevel + Stripe — A buyer completes a paid workshop purchase, or a registrant submits the free-event form. → Registrant tag applied
- Registrant tag applied · CRM record · GoHighLevel workflow — The client’s existing tagging workflow applies a per-event registrant tag; the confirmation and reminder ladder they already had is left completely unchanged. → Bridge workflow (ticketed segment), Backlog backfill (registered before go-live)
- Bridge workflow · Workflow · GoHighLevel — A tag-triggered workflow posts the contact to the issuance endpoint, carrying the shared automation secret. → Issuance webhook (POST with shared secret)
- Backlog backfill · Scheduled job · Node script — An operator-run script that finds every contact holding a given registrant tag and runs each through the same issuance path, printing an issued / reused / skipped / failed summary. → Issuance webhook (one contact every ~1.5s)
Stage 03
Ticket issuance
Resolve which event, dedupe, price honestly, and mint exactly one ticket.
- Issuance webhook · API / service · Next.js route — Authenticates the shared secret, then resolves the event four ways, first match wins: an explicit event id, a named registrant tag, the contact’s own tag list, or a product id resolved through the event record. → Finished-event refusal (automated path only), Issuance coordinator (event resolved)
- Finished-event refusal · Guard · Coordinator — The automated path refuses to issue against an event whose last day has passed. The human paths — the door register and the admin desk — deliberately leave the flag off. → Issuance coordinator (last day has not passed)
- Issuance coordinator · Workflow · Server module — Resolves the event’s record id, runs the dedupe lookup, and builds a write plan so issuance passes through the same safety gate the rest of the system uses. → Dedupe or reuse, Event-scoped pricing, Ticket record (mint or reuse a seat), Attendance history row
- Dedupe or reuse · Decision · Ticket service — Before creating, search for an existing ticket for this contact and event; if one exists, reuse its code and re-deliver rather than minting a second. → Write ticket URLs (created OR reused)
- Event-scoped pricing · Guard · Order lookup — A ticket’s price is read from the contact’s most recent completed order — but only for a product this event actually sells. An event that sells nothing is free, and its tickets cost nothing. → Write ticket URLs (price resolved, or zero)
- Attendance history row · Data store · GoHighLevel object — An attendance-history record created alongside the ticket, deduped to one row per contact and event. → Per-event metrics
Stage 04
QR delivery
Get a scannable, branded ticket into the attendee’s hands — and keep re-delivery possible.
- Write ticket URLs · CRM record · GoHighLevel contact — Writes two merge-field URLs onto the contact: the live QR image endpoint and the hosted ticket page. → Delivery tag added (URL write succeeded), Never email a broken QR (URL write failed)
- Never email a broken QR · Guard · Issuance — If the URL write fails, the ticket is kept but the delivery tag is not added, so no email goes out.
- Delivery tag added · CRM record · GoHighLevel — A ticket-issued tag is applied to the contact. → Ticket email workflow
- Ticket email workflow · Workflow · GoHighLevel — Fires on the delivery tag, sends the client-branded email with the QR as a merge-field image and a “view your ticket” button, then removes the tag as its last step. → Delivery tag added (tag removed last, so a re-issue fires again), QR image & ticket page (branded email delivered)
- QR image & ticket page · API / service · Next.js — A QR endpoint that looks up the ticket, re-signs its token deterministically and draws the PNG at request time; and a branded hosted page with logo, event details, attendee name and a full-screen QR. → Read the QR (attendee presents the QR)
Stage 05
Door operations
Authenticate staff, pick the event, and read a code on whatever phone is in someone’s hand.
- Passcode to session · Guard · Next.js route — Two tiers, scanner and admin. Passcodes are read live from CRM settings so the client can rotate door codes without a redeploy, then exchanged for a short-lived signed session. → Staff picks the event (valid session), Walk-in door register (admin tier only)
- Staff picks the event · API / service · Next.js route — Lists scheduled and in-progress events for the scanner dropdown; the operator selects which event this door is for, fixing the expected event id for every scan that follows. → Read the QR (expected event id fixed)
- Read the QR · Capture · Phone camera — The camera decodes the attendee’s QR, or the operator types the code by hand. → Two-path decoder
- Two-path decoder · Capture · Browser — Uses the browser’s native barcode detector where it exists, and a JavaScript decoder (jsQR) everywhere else. → Check-in endpoint (a code decoded), Manual code entry (no decoder available at all)
- Manual code entry · Human · Scanner UI — Typing the code by hand, retained as a genuine fallback. → Check-in endpoint
- Walk-in door register · Human · Admin page — An admin-tier desk that looks a person up by email or name, creates the contact if needed, issues a ticket on the spot stamped as a door walk-in, and can check someone in by record id with no QR at all. → Issuance coordinator (issue on the spot, stamped a walk-in), Loud silent failure (no walk-in tag configured), Ticket state machine (check in by record, no QR)
- Loud silent failure · Staff alert · Admin page — Registering a walk-in for an event with no walk-in tag configured returns a warning on screen and logs an error, instead of silently issuing a ticket whose confirmation email will never send.
Stage 06
Check-in decision
Verify, branch on ticket state, and write the redemption as an audited change.
- Signed event-scoped token · Guard · Server module — The QR carries an opaque token — code plus event id, HMAC-signed with a truncated signature and constant-time compared on verify — not the raw ticket id and not a public redeem URL. → Check-in endpoint (signature + event id checked)
- Check-in endpoint · API / service · Next.js route — Session-authenticated. Verifies the signature, requires the token’s event to match the picked event, finds the ticket, then branches on status. → Ticket state machine (signature valid AND event matches)
- Ticket state machine · Decision · Server module — Issued becomes redeemed on a valid scan (green, with name and ticket type and a redemption time); already redeemed shows yellow with when; cancelled, refunded, wrong event or not found show red — the wrong-event case naming the event the ticket IS valid for. → Multi-day check-in (multi-day event), Per-event metrics
- Multi-day check-in · Data store · Ticket field — A per-ticket list of check-in days, so a two-day event records arrival on each day and a same-day re-scan raises an “already today” alarm rather than a silent no-op. → Per-event metrics (attendance by day)
Stage 07
Overnight finalisation
Turn silence into a recorded no-show — safely, and never as an accusation.
- Cron secret gate · Guard · Vercel Cron — A daily scheduled request with no user session, guarded by a bearer secret; until that secret is set the route refuses and the cron is inert. → No-show finalisation (bearer secret matches)
- No-show finalisation · Scheduled job · Server module — For every active event whose end date, as an event-local calendar date, is strictly before today and within the last three days, flip every still-issued ticket to no-show and write a no-show attendance row. → Parked-event guard (event id is parked), Messages nobody, Attendance history row (a no-show row per finalised ticket), Per-event metrics (no-show counts now real)
- Messages nobody · Guard · Server module — The sweep performs a status flip and an attendance row and sends nothing — no CRM messaging at all, by standing sign-off rule.
- Parked-event guard · Guard · Code list + env — A named set of events the sweep must never finalise, plus an environment variable that parks future events with no deploy. Shipped before the scheduled run on the morning after the incident event. → Settled by hand (settled by hand instead)
- Settled by hand · Human · Script + review — The 33 people who signed the day’s paper check-in form were marked checked in, each stamped with their own form-submission time rather than the time the repair ran. → Per-event metrics
Stage 08
Reporting
Registered, attended, no-show and revenue, derived from two independent reads.
- Two-ended ticket read · Data store · Server module — Reads the event’s relations in one request and checks the result against the total the platform itself reports — then independently re-derives the same list from the other end, sweeping the ticket object by event name and confirming each candidate through its own relations. → Per-event metrics, No-show finalisation (same reader, so the two cannot disagree)
- Per-event metrics · Report · Server module + page — Registered = issued + redeemed + no-show (cancelled and refunded excluded); attended = redeemed; show rate = attended over registered; revenue = the sum of price over non-dead tickets. → Run live at two events, 50 shown, 251 real (the number contradicts the room)
- Run live at two events · Outcome · Production — 57 tickets issued at the first live event with 48 checked in at the door — a measured 87% show rate — and the 166-ticket backlog sent with zero duplicates.
Stage 09
Root cause & hardening
The two production failures, how they were measured, and what was built because of them.
- 50 shown, 251 real · Staff alert · Production — The dashboard reported 50 registered for an event with 251 real registrants, and 0 attended. An earlier event was affected too — 51 shown against 57 real — but 51 looked believable, so nobody noticed. → Interleaved, unstable paging, The scanner could not scan (attended reads zero)
- Interleaved, unstable paging · Guard · Investigation — Two behaviours compounding: every registrant puts two relations on the event — a ticket and an attendance row — returned interleaved, so a page of 100 held about 50 tickets; and the endpoint does not sort stably, so each next-page request re-slices a reshuffled list. → One read, total-checked
- One read, total-checked · Guard · Server module — The relation read now issues one request at a large limit, checks what came back against the total the platform reports, retries once at exactly that total, dedupes, and only then falls back to paging behind a loud warning. → Two-ended ticket read (plus an independent re-derivation), Four-pass review loop (after the first deploy)
- The scanner could not scan · Staff alert · Investigation — Found after the first report was written, from a screenshot taken on the day: the scanner gated camera scanning on a browser feature one major mobile platform has never shipped, so the door saw “this browser can’t scan QR codes” and was offered only manual entry, for 251 people. → Two-path decoder (decode in software everywhere else), Report corrected itself (the earlier write-up was wrong), Parked-event guard (no scans means no evidence of absence)
- Report corrected itself · Report · Client doc — The status document records that the first write-up had described the failure as a process choice by the door team — “that was wrong, and unfair to the door team”.
- Four-pass review loop · Guard · Review process — A live account data audit, a codebase hardcoding audit, an adversarial code review and a future-event readiness verification, all run after the first deploy. → One read, total-checked (five shipped defects, one false positive)
Platforms · Next.js · GoHighLevel · Stripe · QR + jsQR · Vercel Cron
What I built
The event as a first-class record
Each event is one CRM record carrying name, a stable event id, type, status, start and end dates and venue fields. Status runs scheduled, in progress, completed and cancelled, and the end date drives no-show timing in the event's own local calendar days. Three configuration fields sit on that record: registrant tag, walk-in tag and product ids. Those were the two hardcoded settings, and moving them onto the record is what lets a new event be configured by filling in a form rather than by shipping code.
Every seat is its own ticket record: ticket id, type, status, price, purchase date, buyer name, redeemed-at, issued-via and per-day check-in days. Revenue and attendance live on the seat, so a report becomes a query rather than a spreadsheet.
Issuance, deduped and honestly priced
A purchase or registration applies the client's existing registrant tag. A bridge workflow posts the contact to the issuance endpoint with a shared secret, because the CRM cannot mint a signed ticket and has to hand off to code at exactly one point.
The endpoint resolves the event four ways, first match wins: an explicit event id, a named registrant tag, the contact's own tag list, or a product id resolved through the event record. That generic tag path is what lets one published workflow serve every future event with no hardcoded value in it. Before creating anything it searches for an existing ticket for this contact and event. If one exists it reuses the code and re-delivers rather than minting a second, which is the mechanism behind zero duplicate sends across the 166-ticket backlog.
Pricing is scoped to the event. A ticket's price is read from the contact's most recent completed order, but only for a product this event actually sells. An event that sells nothing is free and its tickets cost nothing.
Delivery the client controls
Issuance writes two merge-field URLs onto the contact: a live QR image endpoint and a hosted ticket page. A ticket-issued tag then fires the client's own branded email workflow, which removes the tag as its last step. Removing it is what makes re-delivery possible for someone who lost their ticket.
A door that works on the phone in someone's hand
Staff sign in with a passcode read live from CRM settings, exchanged for a short-lived signed session, so the client rotates door codes without a redeploy.
The scanner uses the browser's native barcode detector where it exists and a JavaScript decoder everywhere else. That fallback is the fix for the live failure. Frames are downscaled to a 640-pixel longest edge before decoding, which cuts the work about ninefold while the token is still a comfortably readable code.
The QR itself carries an opaque token, code plus event id, signed and constant-time compared. It is not the raw ticket id and not a public redeem URL, so a photographed code cannot be turned into a redeem link. Check-in verifies the signature, requires the token's event to match the event the operator picked, then branches on ticket status.
Overnight finalisation that accuses nobody
A nightly job finds every active event whose end date has passed within the last three days, flips still-issued tickets to no-show, and writes an attendance row. It sends no messages at all, by standing sign-off rule. Being labelled a no-show is not something a person should learn by text at 3am.
Edge cases and failure handling
- A URL write that fails means the delivery tag is not added, so no email goes out. A ticket email with a dead QR is worse than no email.
- A resolvable-but-unknown event, tag or product returns a 200 marked skipped rather than an error, because the CRM retries any non-2xx and retrying a misconfiguration only produces a retry storm.
- Redeem is idempotent, so two simultaneous scans are safe and a replayed scan reads as already checked in. That is also how a shared screenshot gets caught, which is why no anti-replay layer was needed.
- Registering a walk-in for an event with no walk-in tag configured returns a visible warning and logs an error, instead of issuing a ticket whose email will never send.
- Check-in is deliberately online-only and rate-limited. A dead network shows a retry state, because an offline queue that loses a scan is worse than a door that says try again.
- The no-show sweep was parked before its first scheduled run on the incident event, which had 251 tickets and one recorded redemption. Finalising would have branded roughly 250 people absent on evidence that did not exist.
How I verified it
The undercount was measured rather than theorised. Two behaviours were compounding. Every registrant puts two relations on an event, a ticket and an attendance row, returned interleaved, so a page of 100 held about 50 tickets. The endpoint also does not sort stably, so each next-page request re-sliced a reshuffled list. Loss was measured across five page sizes on a 502-relation event: 12 rows at page size 25, 25 at 50, 50 at 100, 100 at 200, and zero once the page size met the total. Paging was the second half of the bug, not the fix.
The fix is self-checking. One read at a large limit, compared against the total the platform itself reports, retried once at exactly that total, deduped, and only then falling back to paging behind a loud warning. A second, independent re-derivation sweeps the ticket object from the other end and confirms each candidate through its own relations. The re-derivation doubles as a cache, removing roughly 250 serial reads per report.
After the first deploy I ran four review passes: a live account data audit, a codebase hardcoding audit, an adversarial code review, and a future-event readiness verification. They caught five real defects that had already shipped, including a regression the fix itself introduced and test fixtures whose blank fields disabled the very code paths under test. One reviewer claim was wrong, based on a repo-only search, and was explicitly not acted on. The false positive is recorded, which is what makes the true positives credible.
What changed
The system has run live at two real events. The first issued 57 tickets and checked 48 in at the door, a measured 87% show rate, produced by the system rather than counted by hand. A 166-ticket backlog was sent with zero duplicates, and several mistyped email addresses were caught before send.
Attendance, no-shows and per-ticket revenue now land on the contact record, derived from two independent reads of the same roster. Registered, attended, show rate and revenue are defined once in one module, and the KPI, the roster and the nightly sweep all consume that same reader, so they cannot disagree.
A new event is configured by filling in a CRM record.
The client-facing report on the incident opens with the failing screenshot and states plainly that it was the software's fault and not the door team's, and that using the paper form was the correct call. The first write-up had said otherwise, and the correction withdrawing it is on the record.
Before / after
- Before
- ✕ Attendance counted by hand at the door
- ✕ A live event reporting 50 registrants when 251 were on the list
- ✕ Tickets and revenue living in spreadsheets, not the CRM
- ✕ Every new event needing developer time
- After
- ✓ Signed single-use QR tickets, scanned on any phone
- ✓ The undercount root-caused, measured and fixed with defence-in-depth
- ✓ Attendance, no-shows and per-ticket revenue on the contact record
- ✓ New events configured by filling in a CRM record
ScopeFigures on this page describe what was built, covered or verified — not a measured lift.
Related systems
Not a one-off.
High-ticket education business
CRM Systems Audit & Cleanup
Same client — the revenue that audit found untraceable, now recorded per ticket.
22.6kcontacts auditedScope
High-ticket education business · sibling of CS-03
Ads & sales reporting dashboard
Same client — both stopped trusting a platform number that was provably wrong.
13purpose-built screens, live on the client’s domainScope
Coach running weekly live trainings
Training & Onboarding Automation
Both automate attendance and no-show handling for live sessions.
100%trainings automatedMeasured
Have something close to this? Book a call. I’ll tell you if I’ve already built it.
One Manila business day to reply. All client work is under NDA; what you see here is redrawn architecture with real metrics.