saascode

Venuo: event ticketing you run yourself, from public pages to QR check-in

builds · aug 09, 2026 · 10 min read · product: Venuo

An event organizer scanning a guest's QR ticket at the door of a small workshop with Venuo's check-in screen open on her tablet

Three database functions — inventory reservation, its release, the expiry sweep — answered HTTP 200 to a probe carrying nothing but the public browser key. Two causes, one reasoning error. reserve_tickets was granted to the anonymous role explicitly, on the premise that the buyer's browser calls it; every caller is server-side. The other two were granted only to the trusted server role — but a grant only ever adds. Postgres' default execute-to-public was never revoked, and the anonymous role is a member of public, so the narrowing grant narrowed nothing. All three functions run with the owner's privileges. A caller with only the public key could move another tenant's sold counter — the one invariant the entire product rests on.

Venuo is a self-hosted event-ticketing platform: public event pages, tiered ticket types, webhook-confirmed issuance, and browser QR check-in at the door. Its core architectural commitment is that exactly one database function controls every movement of the sold counter, and nothing else touches it. The finding is closed — revoked, independently re-probed, the full ticketing suite 19 of 19.

Starting point

The scope was fixed before any schema existed: self-serve ticketing for recurring small-to-mid organizers who manage their own audience. Create an event with a public page, sell tickets by type, check attendees in at the door with a browser QR scan. Not a consumer discovery marketplace; not numbered seating; not streaming.

The commercial shape was fixed at the same time. Venuo takes no percentage of ticket revenue and charges no per-ticket fee. The organizer collects into their own payment account; the platform never handles the money. Revenue is the subscription alone: three tiers at $29, $49, and $99 a month, with per-plan limits on active events and admissions issued. That limit is a soft overage — a plan cap may never hard-block a scan at the door. Three rules were written into the schema: each ticket is the admission unit; the sold counter has one increment path; and the organizer's payment credentials and the platform's billing credentials live under namespaces that cannot resolve each other.

Reading the market

Eventbrite's published US fee schedule charges 3.7% plus $1.79 per ticket, then adds 2.9% processing on the full order — including the service fee and tax, not just face value. On a $25 ticket the buyer-visible add is $2.72; absorbing it costs the organizer $3.44. On $5,000 of sales across 200 tickets that resolves to roughly 14% of gross; on $50,000 across 1,000 tickets, roughly 10%. The load falls as ticket price rises because $1.79 of it is fixed — the corollary being that cheap tickets are treated worst.

Four counterweights belong in the argument and none is optional. Card processing does not disappear, and it dwarfs the subscription: Stripe's cut represents roughly 88% of what a Venuo organizer actually pays on 200 tickets at $25, and roughly 97% on 1,000 tickets at $50. Venuo removes the platform's second cut, not the payment cost. On Eventbrite, fees are buyer-paid by default, so the organizer's out-of-pocket is often zero; the argument lives in the buyer-visible surcharge and its effect on conversion for a recurring organizer whose audience pays it every time. Break-even against Ticket Tailor pay-as-you-go is roughly 35 paid tickets a month; against their prepaid credits — in GBP, their verified currency — roughly 97. A marketplace's cut genuinely buys discovery. Venuo does not provide discovery, and the qualifying question is whether the organizer's audience already knows who they are.

At 300 tickets and $25 face value, the monthly platform saving against Tito is roughly $176; against Eventbrite at the same volume, roughly $699; against Ticket Tailor prepaid, roughly $41. Each figure describes a different part of the landscape. The honest version names the comparator.

The decisions that shaped it

The admission unit is the ticket, not the payment. A two-level model admits one person per payment — a four-ticket purchase sends three attendees away at the door. This was stated before the schema existed, and its consequences hold everywhere downstream: one QR token per ticket, the sold counter advances by quantity, and attendee surfaces enumerate tickets rather than grouping by buyer email. Measured end to end: a quantity-four purchase produced four ticket rows, four distinct QR tokens, one order row, and advanced the sold counter by exactly four. Each QR admitted independently; each replay returned 409; an unknown token returned 404. 19 of 19, holding through a later privilege migration.

One increment path, no triggers. Read-modify-write races and duplicated trigger increments are the canonical oversell bugs in ticketing; the design removes the possibility rather than tests around it. The counter moves inside a single database function, by quantity, with a capacity check. Measured on the seeded fixture: the counter equals the actual ticket count for every ticket type, and zero rows violate the capacity constraint.

The webhook is the fulfillment authority; pending is a first-class state. Card providers document at-least-once, unordered delivery — so no ticket is issued synchronously at checkout, and a quantity hold carries a time-to-live so abandoned checkouts release inventory. Measured: a pending order issues zero tickets; a wrong-signature webhook returns 401; a replay of the same event ID returns 200 with zero new tickets; a paid order found missing its tickets is repaired rather than stranded. The expiry sweep ran end to end with sold counters read from the live database before and after to prove the no-op.

Live door status on the database's own realtime channel. A prior product's long-lived streaming connections held serverless functions open and degraded under load. The rule was written as a decree. What it required: the check-in table added to the replication publication, replica identity set to full, the browser client subscribing directly, and an explicit database re-fetch on every reconnect so a dropped socket cannot leave a stale count on a door screen. No measurement of concurrent door load exists.

Credential namespaces that cannot resolve each other. Measured at the storage layer: the organizer namespace is hard-coded in the credential accessor, there are zero environment-variable reads for organizer payment secrets in the source, and zero namespace crossover exists in either direction.

The checkout screen refuses to show a number it cannot compute. The design specified a processing-fee line on the buyer's order summary. Rendering an invented charge on a payment screen is a fabricated figure; it was not rendered. The discount row shows "at payment" rather than an amount because validation is server-side only. Every buyer-visible figure is labelled estimated. The charged total is computed server-side at one call site. No incident was recorded; this is a divergence documented in code.

What fought back

The endpoint that unmade the organization it existed to create. The workspace-creation endpoint inserted a new organization unconditionally and re-pointed the caller's organization at it. One repeat call as the demo organizer silently detached them from their own workspace — three events, three ticket types, ten tickets, four orders, and their payment credentials — left an empty duplicate behind. The same line rewrote the caller's role, so an ordinary member posting to it could promote themselves to organizer. Fixed by branching on existing membership: fresh signup unchanged; owner renames; anyone else gets a conflict response with no role change.

The only organization that could use the paid features was the seeded one. Six paid-tier capabilities — multiple ticket types, discount codes, the live door dashboard, recurring dates, offline door mode, and a reserved waitlist — were seeded as rows belonging to the demo organization and never written into the global catalogue every new organization inherits. Entitlement is per-organization and default-deny, so a real paying customer on the middle or top tier would have been refused every feature they had paid for. Every check was green throughout, because the demo organization was the only organization that existed. Fixed by upserting the six into the global catalogue with a conflict-do-nothing re-provisioning pass.

The class-name instruction that destroyed what the next step needed. The first two waves went out with an instruction to translate the design's bespoke class names into generic equivalents. The styling pass that follows can only apply values to names still present — a renamed class is unrecoverable. A restoration pass repaired them across 37 files and roughly 1,050 class names.

Translation in the seed file, not the database. The build merged translation work into the canonical seed — 4,529 keys, zero missing — and never re-applied it to the live database. The two validators comparing code against the seed both passed. The one gate reading the live database found 1,539 keys missing and three holding stale values; every screen would have rendered placeholder text in production. Fixed by an idempotent re-push; the live count moved to 4,503 rows.

Verification tools that were wrong about the product. A guard-coverage checker counted code comments as call sites, inverting one finding into a phantom dead guard. A design-fidelity checker's login helper was blocked by a loading overlay, concluded a valid login did not complete, skipped 22 of 29 routes, and still wrote a report; coverage moved from 24% to 69% once the helper waited for the overlay to clear. The runtime privilege battery's self-test came back invalid because its positive control asserts that a tenant can mint an admin-scoped key — a capability this database had already closed. The battery ran clean; the verdict was recorded as unverified.

The limit no architecture closes. When two door devices are both offline simultaneously, double-admission of a cloned QR cannot be fully prevented. This was confirmed against comparable prior work and written into the spec as an honest limit. The shipped posture: online-first scanning, a pre-downloaded attendee manifest, a local scan queue while offline, and first-scan-wins on reconnect enforced by a uniqueness constraint, with the losing device shown a retroactive conflict warning. Device partitioning is offered as an organizer choice. On iOS the queue drains only while the door page is in the foreground, and the staff note saying so is part of the interface.

Honest limits at ship. All of the following are recorded; none is disguised:

  • No email provider is configured; ticket confirmation emails resolve to no provider. Fulfillment is unaffected — audit row written, webhook returns 200 — but the buyer's QR arrives via the order link only.
  • The rate-limit backing store is unprovisioned; anonymous checkout, scan, and QR limits run on an in-process fallback that resets on a cold start.
  • A buyer cannot resend their own order email: the endpoint is organizer-guarded, so the owner of the order link always receives a 403. The control is wired and fails honestly.
  • A platform administrator refunding another organization's order receives a 404; the control works same-organization and the cross-organization case is documented.
  • Door staff cannot search attendees by name without the offline flag; ticket-code entry is live for every role, and the name input ships visibly disabled with the reason stated.
  • No per-date sold counts, so the design's per-occurrence progress bar could not be built; that cell renders the real time window instead.
  • Recurrence editing ships disabled: dates are stored as individual rows and no recurrence rule exists in the schema.
  • No hosted payment-connect authorization path: the connected-account field is read and displayed but nothing writes it. A second provider was left unwired rather than shipped as a placeholder.
  • No wallet-pass pipeline and no cover-image upload endpoint. The wallet button was dropped; a print-shaped ticket page took the slot.
  • The overlay specimen ships three of its six contracted shapes; the other three were derived from the nearest equivalent surfaces.
  • The password-reset screen was designed for a set-a-new-password flow while the wired control requests a reset link by email. The wired contract won.
  • 239 of 407 interface controls were not executed in the automated pass. They are named as not examined and explicitly not blessed; of the 168 that ran, every failure traces to one of five named non-defect causes.
  • 18 high-severity dependency advisories, zero critical. The highest-priority advisory is a framework middleware bypass matching this application's exact shape, with a non-major upgrade available.

What shipped

Venuo ships the three-level model end to end: anonymous checkout with no account required, webhook-confirmed ticket issuance, one QR per ticket as a hosted image URL, and an order page that polls while pending and cannot fabricate success. Door check-in runs on a browser QR scan with a native fast path and a decoder fallback, a full-screen admitted-or-already-scanned verdict, manual code lookup, live counts over the database's realtime channel, and an offline scan queue with first-scan-wins settlement. The organizer surface covers the full lifecycle — events with public pages, ticket types, discount codes, recurring dates, attendee management, sales reports, and a payment settings page — across three database-driven subscription tiers and per-organization isolation. End-to-end verification scenarios ran against the live database with the fixture restored after every probe.

See it

See Venuo →

end