events & ticketing
BashBop
Event ticketing with payouts and money movement in one place, so organisers are not stuck on WhatsApp and spreadsheets. Works in any country Stripe or Paystack covers. Its assistant answers visitor questions from live event data: retrieval over pgvector, a deterministic fact check, and evals to measure it.
- NestJS
- Prisma
- PostgreSQL
- Redis
- Next.js
- Expo / React Native
- Stripe Connect
- Paystack
- pgvector
- Cloudflare Workers AI
- Socket.IO
- Playwright
Problem
Selling a ticket is the easy part. The hard part is the money. An organiser in Lagos and an organiser in London need different processors, different fee rules, and different payout rails, and each of them expects the figure on the dashboard to be the figure that lands in their bank. Without that, they are back on WhatsApp and a spreadsheet.
Constraints
- Two processors with different models: Stripe Connect where Stripe operates, Paystack for Nigeria, Ghana, and Kenya.
- The processor pays the organiser. BashBop takes its fee and does not run payouts by hand.
- Every figure an organiser sees has to match what reaches their bank, to the minor unit.
- A webhook can arrive twice or late, and the booking must still be confirmed exactly once.
- Anything the assistant tells a visitor has to come from an event record. A wrong answer is worse than no answer.
- One API serves the web app and the mobile app, and the web app ships in five locales, Nigerian Pidgin included.
Architecture
One NestJS API behind two clients. Two parts are worth drawing. The first is the payment path: a booking goes down through one payment interface to whichever processor the organiser's country needs, and comes back through webhooks. The second, further down, is the event assistant.
Clients
- Web appNext.js and React, for organisers and buyers, in five locales.
- Mobile appExpo and React Native on the same API, with a scanner for tickets at the door.
API
- NestJS modulesEvents, bookings, payments, vendors, and notifications in one modular service.
- WebSocket gatewaysPresence and notifications pushed over Socket.IO.
Payment path
- Payment serviceOne interface for every payment operation, whatever the processor.
- Processor factoryPicks the processor from the organiser's country, or from the connected account it already holds.
- Stripe processorDestination charges through Stripe Connect, with the platform fee taken at charge time.
- Paystack processorSplit payments to the organiser's subaccount, settled by Paystack straight to their bank.
After the payment
- Webhook handlersVerify the processor's signature, then confirm the booking once, however often the event arrives.
- Fee true-upCompares the fee Stripe actually billed with the estimate taken at checkout, and corrects the difference.
- Settlement recordStores the processor's own split on the booking, so payout figures are read and never re-derived.
Data
- PostgreSQLBookings, payments, and audit history, through Prisma.
- RedisCache and sessions.
On every pull request
- CIType check, lint, build, and tests against real Postgres and Redis containers.
- Migration checkA separate safety check on every schema migration.
- bouncer and gitleaksCompliance controls and secret scanning, each as a check of its own.
Event assistant: retrieval-augmented answers
Bash, the mascot, answers visitor questions about public events from BashBop's own records, and shows the events it used as cards. Every step below is allowed to decline.
Index
- Event documentsOne text document per public event: title, description, dates, venue, pricing, host, and series.
- Chunk and embedChunks embedded with bge-m3 on Cloudflare Workers AI.
- pgvectorAn HNSW index in the existing Postgres, next to the columns the filters use.
Retrieve
- Question filtersNarrows by the city and dates named in the question, or by the event page the visitor is on.
- Filtered searchCosine top-k, always limited to public, live, upcoming events.
- AbstainA weak best score skips the model and declines in about a tenth of a second.
- FAQ and feesGeneral questions search the FAQ by meaning. Fee answers are read from the fee configuration, not from prose.
Answer
- Grounded generationThe model answers from numbered records only and returns the ids of the events it used.
- Fact checkDeterministic: every amount, time, and date in the answer must appear in the records it cites.
- Event cardsDates, prices, and links come from the database by id, never from model text.
In the chat
- Mascot pop-upStreams the answer, shows the cards, and offers one next step.
- Visitor languageAnswers in French or Nigerian Pidgin when the visitor's locale asks for it, held to the same fact check.
Key decisions
One payment interface, one processor per region
Instead of Stripe calls wired straight into the booking code
The first version was Stripe-only and the booking code called it directly. Adding Nigeria that way meant a second copy of every payment path. Behind one interface the booking code asks for a payment and a factory picks the processor from the organiser's country.
Paystack for Nigeria, Ghana, and Kenya
Instead of Flutterwave
Both support the marketplace model, with a subaccount per organiser and automatic splits. Paystack came out ahead on fees for small local transactions, and I expected most ticket sales to be local.
Letting the processor pay the organiser
Instead of collecting everything and running payouts myself
On both processors the organiser's share moves to their own account as part of the charge. There is no payout run to operate, and no balance of other people's ticket money building up on the platform.
Reading the payout from the processor's own record
Instead of recomputing it from fee constants
The only exact record of what an organiser was paid is the processor's split on the transaction. Anything derived from fee estimates is a second opinion, and it drifts the first time a fee rule changes.
Idempotency in the database
Instead of trusting a webhook to arrive once
A booking reference is unique in the database and each fee adjustment is recorded per charge. A webhook that arrives twice finds the work already done and changes nothing.
Retrieval from live event records
Instead of fine-tuning a model on events
Event data changes minute to minute. Retrieval is fresher and cheaper, and every answer can point at the records it came from.
pgvector in the Postgres I already run
Instead of a dedicated vector database
A second system to operate for a modest corpus was not worth it. pgvector in the existing database is enough, and it keeps the public, live, and upcoming filters in SQL beside the vectors.
Declining instead of guessing
Instead of always producing an answer
A wrong answer is worse than no answer. A weak retrieval score skips the model, the server rejects any event the model cites that retrieval did not return, and a deterministic check holds every amount, time, and date to the cited records.
What broke, and the fix
Organisers on Paystack saw a payout balance of zero.
- Cause
- Earnings summed a field Paystack never returns, and the payout breakdown estimated fees with constants written for Stripe.
- Fix
- A booking now records Paystack's own split when it is confirmed, and every organiser-facing figure is read from it. Payments too small to split show as held by BashBop instead of paid out.
Premium, European, and international cards cost the platform money.
- Cause
- Checkout charged a flat estimated processing fee, but Stripe bills by card origin, and on a destination charge the real fee comes out of the platform balance.
- Fix
- After each successful payment the fee Stripe billed is compared with the estimate and any shortfall is reversed from the organiser's transfer. The result is stored per charge and shown in their payout breakdown.
A paid order could crash at confirmation.
- Cause
- Stock was updated from the item list in the checkout session's metadata, which could serialise to an empty list. The update then ran with no item id, after the payment had already succeeded.
- Fix
- Confirmation reads the purchased items from the booking's own stored rows, which are the authoritative record.
The assistant said there were three events in London. There were four.
- Cause
- The model was given three records and described them as the whole listing. The retrieval metrics looked fine; reading the answers is what found it.
- Fix
- The prompt forbids claims about the whole listing, and the fact check drops any sentence that makes one.
The abstain threshold tuned on test data was wrong for real events.
- Cause
- On a synthetic corpus off-topic questions scored up to 0.41 and real matches started at 0.44. On real event descriptions off-topic questions reached 0.49.
- Fix
- The threshold is set per environment, and a question that slips past retrieval is still declined by the answer step, at the cost of one model call.
Outcome
- Live at bashbop.com, taking payments through Stripe and Paystack, with web and mobile clients on one API.
- The API is at v2.121 and the web app at v23.19, both released from CI.
- Seven critical journeys, paid checkout and organiser refund among them, run as Playwright tests in a real browser.
- The mascot chat is shown to half of production visitors. On 35 hand-written questions over real public events, retrieval found the right event 27 times out of 27, the answer path declined all 8 off-topic questions, and no answer carried an amount, time, or date missing from its sources. The median answer took about a second. Thirty-five questions is a small sample.