An event platform I took from first commit to a live three-day conference in 24 days, now running in production with live payments.
01 / 06
Communications
Reminders, follow-ups and surveys scheduled around the event, beside the attendee app.
Event teams run their year on a ticketing page, a spreadsheet and a WhatsApp group.
Teams that run regular events sell on Eventbrite or Luma, keep the guest list in a spreadsheet and talk to attendees in group chats, so every event starts from scratch and people drift away between them. Conferences that want more pay Bizzabo or Accelevents $27–65k a year for a web app nobody opens. I wanted one native app that attendees would actually keep on their phone, with tickets, messages and check-in built in.
Organisers build the event on the web; attendees carry it in their pocket.
An organiser describes their event, or pastes a flyer or a link, and an AI assistant drafts the programme, tickets and emails next to them. They publish, sell tickets in four currencies with deposits and discounts, and send designed emails with delivery tracking. Attendees get a QR ticket with an Apple Wallet pass, the agenda and a list of who’s going. On the day, the team scans people in at the door.
It’s a monorepo of four apps: a Next.js backend and organiser dashboard, an Expo app for attendees, a Payload CMS for event sites and emails, and an internal ops app, each with its own database and deploy. Supabase Postgres sits under the product, with row-level security doing the access control. AI coding agents wrote most of the code, so a lot of the engineering went into the system around them: plans, review, a seeded QA world and recorded evidence before anything ships.
Every pull request gets its own Vercel preview, an Expo over-the-air build on its own channel with a QR code posted to the PR, and its own Supabase database branch if it touches the schema. Merging to main ships web, CMS and mobile to staging. release-please turns conventional commits into a versioned changelog, and every two weeks I QA everything on the train, mark each issue qa-passed, and cut the tag that ships it all to production. That’s 25 GitHub Actions workflows and 97 releases from v1.1.0 to v2.73.0.
Releases made by the bot triggered nothing. Pushes made with GitHub’s default token don’t start other workflows, so release PRs hung and tags would have deployed nothing.
release-please runs as its own GitHub App, so its PRs and tags start the downstream deploy workflows like any human push.
Choosing between an over-the-air update and a new native build. Diffing git missed runtime-version bumps made mid-cycle and builds that had failed earlier.
On each tag, the pipeline asks EAS directly whether a production build exists for that runtime version. If one does, it ships an OTA update; if not, it builds and submits to the App Store. This is the third version of that logic.
A production migration that had never run on staging, or a bad web build going straight to the live domain.
Migrations are additive-only, and a parity guard checks through the Supabase Management API that every pending one is already in staging’s history. Web builds deploy with --skip-domain, get smoke-tested on that URL, and only then are promoted.
A workflow that failed by succeeding: release-please ran green for months without cutting a tag, so no failure alert ever fired.
Daily heartbeats whose absence pages my phone, alongside failure alerts on every workflow.
Every message is three independent choices: a type (what’s sent; 32 are built in), a style (the event’s brand; 7 presets) and a workflow (when: a graph of trigger → delay → send, across 16 trigger types). An instant reaction is just a workflow with no delays, so one enrolment path, one executor and one send function serve everything. Workflows run durably on Vercel Workflow, email goes out through Resend, and delivery status comes back through signed webhooks into a per-event history. The comms code alone has 1,337 tests, run on every PR and again every night.
Durable workflows replay from a log, so any side effect in the workflow body would send the same email twice.
Every read, write and send lives in its own step. Delays sleep in chunks of at most a day and re-read the event date when they wake, so moving an event corrects reminders that are already in flight.
Late sign-ups: someone who registers 20 hours before an event would get every reminder they’d missed, all at once.
Steps are skipped by comparing against the person’s fixed enrolment time, not the clock, so the decision stays the same when the workflow replays.
Delivery webhooks arriving out of order, twice, or before the send had even been logged.
Status only moves forward by rank (sent, delivered, bounced, complained). A webhook for an unknown send writes a placeholder that the real log row later merges into, and the event id is claimed in the same transaction, so a failed apply rolls back cleanly and Resend retries.
Sending the same thing twice, or flooding attendees when an organiser makes several edits in a row.
Idempotency keys at enrolment (a unique constraint) and on every Resend call, a 10-minute coalescing guard on automated event-wide notices, a per-person weekly cap and a global bounce list.
Event sites, email templates and articles are built in Payload 3, which runs as its own app with its own Neon Postgres database, Cloudflare R2 media storage and Vercel project. The product never imports Payload. It talks to it over REST through a proxy that checks permissions against Supabase and passes on the user’s token, which the CMS verifies offline against Supabase’s public keys. Publishing a page revalidates its cached route on the web app, and the public event pages carry structured data for search and generated share images. It has 9 collections, 10 page blocks with 30 layout variants, and 16 email blocks.
Multi-tenant access was open on create. Payload never checks a create rule against the incoming data, so one organiser could have created a page in another’s workspace.
Every create rule checks the incoming tenant itself, and draft versions declare their own read access instead of inheriting it.
One block schema with four consumers (the database, the TypeScript types, the AI’s output validation and the React renderer) that must never drift apart.
Types and validators are generated from the schema and checked for drift in CI. Each block is capped at 16 variants, the largest union Anthropic’s structured-output decoder accepts.
Media uploads: newer AWS SDKs add a checksum header that R2 rejects, and Vercel caps request bodies at 4.5 MB.
Checksums are set to only-when-required, and browsers upload large files straight to R2 with presigned URLs.
Ticketing runs on Stripe Connect with direct charges. Every call is made on the organiser’s own Stripe account, so they’re the merchant of record and the money lands with them. Prices, discounts and deposits are worked out on the server from a code or an id, never from a price the browser sends, in USD, EUR, GBP or SEK. Each ticket becomes an Apple Wallet pass, built from the same field-mapping code as the preview the organiser designs it in. At the door, each ticket checks in once per day, and offline scans sync within a bounded time window.
Stripe webhooks must take effect exactly once but still be safe to retry.
Every event lands in an inbox table keyed on provider and event id, and only rows marked done are skipped. Changes go through idempotent database functions, and each webhook is checked against the stored order’s account, currency and amount before it can mark anything paid.
Moving the platform to a new Stripe account without breaking organisers’ connected accounts.
Each merchant row is stamped with the platform account it belongs to, so a swap is just a key rotation: rows from the old account stop being used on their own.
The organiser’s assistant runs on the Vercel AI SDK through AI Gateway with one tool registry. The user and the event are bound on the server and never appear in a tool’s inputs, and every write tool pauses for an approval card in the chat. AI-first event creation runs the setup steps and a chat side by side over one draft, and can crawl the organiser’s own website (respecting robots.txt) to prefill it. I also built an MCP server with its own OAuth 2.1 so Claude, ChatGPT or Cursor can reach an organiser’s events. It has 19 read tools and 23 that only ever propose changes for the organiser to approve inside Confexio.
MCP tools had to respect Postgres row-level security without a browser session to borrow it from.
The server mints a five-minute token for that person on every call, flagged as MCP, and it never leaves the server. The public API rejects any token carrying the flag, and no admin database client sits on a tool’s data path.
An unanswered approval card broke every later turn in the same conversation.
Approval state is stripped from every message except the latest before the history goes back to the model.
A feature starts as a Linear ticket. An exec plan breaks it into milestones and names the flag that switches it on. Each milestone then runs build (failing test first), adversarial review, fix and verify, and the last step films a Playwright walkthrough as evidence. Agents test against a seeded QA world of 16 personas and 14 events, and nothing ships until I’ve watched the evidence. That’s 157 exec plans so far. An internal ops app holds a cockpit for running these agents and an AI sales pipeline with an eight-check compliance gate before anything is sent.
Several agents working in one checkout kept tangling each other’s changes into the same commits.
One branch and one PR per piece of work, with each agent’s commits restricted to the file paths it owns.
Realistic test data without real people’s data.
A deterministic sanitiser replaces names and photos on scraped real conferences but keeps the programmes. To reproduce a bug, a pseudonymised slice of production is pulled into the local stack.
The cockpit spawns Claude Code with write access to the repo, which would be remote code execution if it were ever deployed.
Every cockpit route returns 404 unless it’s running locally, outside production, next to a real .git folder, and a test fails the build if any handler skips that check.
Under offer. Live since June 2026.
Production went live in June 2026, and Stripe has taken real payments since August. The first customer, A Players Club, ran events on it in Stockholm, Amsterdam, Marbella and London before moving on. Since then I’ve renamed it from OBOX to Confexio and refocused it on teams that run events all year: conferences, regular programmes and, next, exhibitions. It has since received a buyout offer for the codebase.
Shipping to a real event in 24 days taught me more than any amount of planning would have. But building around one customer made the product narrower than the market. Next time, I’d put it in front of many teams early, before one customer’s requests fill the roadmap.
The site’s assistant has read this case study. Ask it anything, or start with one of these.