Kalor

The live ticketing platform, rebuilt on Flutter over a .NET 10 and PostgreSQL backend.

Period
Jan 2026 – present
Role
Co-founder and CTO
Status
shipped
Paid events run
20+
Organizers
10+
Accounts created
8,000+
API endpoints
119

Founded with two partners. The only developer present through every stage of the product. The backend is 98% mine, including the data layer, payments, permissions and invoicing. The Flutter client is 88% mine, with one developer contributing the design system and a set of screens over the first five months.

Closed source FlutterRiverpodDart.NET 10C#EF CorePostgreSQLStripeDockerSvelteKit

Highlights

  • Rebuilt the platform off Firestore onto PostgreSQL and .NET once query limits made the analytics we had planned impossible. Large guest lists went from over a minute to instant.
  • Payments are destination charges: the money lands with us and only the ticket price transfers on to the organizer, so the service fee is simply the part we do not transfer. That is what makes partial refunds and matching credit notes possible.
  • QR check-in validates against a per-ticket scan window, so a ticket cannot be used before doors open or after the event has finished. The largest event run through it admitted over 2,000 attendees.
  • Overselling is prevented by one conditional update that increments only if the result stays within the cap. Per-option promoter caps were layered in front of it rather than replacing it, so older links kept working without a migration.
  • Staff, guests, promoters, discounts and announcements are one invite abstraction rather than five features: a type, an optional token, a usage cap and a typed payload.
  • Authorization is an endpoint filter rather than something each of the 119 handlers remembers to do, and notifications dispatch after commit so a rolled-back transaction can never tell a guest they have a ticket.

A shipped commercial product, so there is no repository and no code on this page. Architecture and decisions instead.

Started as guest-list management for my co-founders' own events; now a general ticketing platform used by other organizers.

The backend suite of 594 tests runs on every pull request, but it was generated rather than designed, so it detects change rather than encoding intent. The Flutter client has four.

The problem

Kalor started as a simple guest list management tool. My two co-founders ran their own events at the time and had one major pain point: entry. Paying on the spot, finding a name on a printed list, arguing with guests about who is allowed in. These made the whole process frustrating and took away the fun of hosting an event.

The first version of Kalor did one thing: hosts could create an event on the platform, share a link with their guests, guests would apply for entry, host would approve those they knew, and approved guests could buy a ticket. As more hosts adopted the platform, we continued adding features based on their needs, turning Kalor into a fully-featured general ticketing and event-promoting platform.

Today, Kalor has hosted over 20 paid events across more than 10 organizers, and more than 8,000 accounts have been created since January 2026.

Why I rebuilt it

Version 1 was written in under a month against a hard deadline; we had to be live for summer, or wait a year just to prove our concept. This means we had to make some decisions which would cost us down the line, but which allowed us to get the product out quickly.

The lack of established coding style meant that each system of our app was written in its own way with its own assumptions. Database ownership spread across dozens of files and cloud functions, each assuming an exact Firestore document shape, with nothing enforcing it. Each developer could understand their own code, but reasoning about the app as a whole was next to impossible.

The breaking point came when we had to ship a fix for a host hours before their event. The guest list was displaying some guests twice, and the host was relying on this feature to advertise their event at the door. What should have been a quick fix ended up breaking every feature touching the event document on Firestore.

By then, a bug as small as event banners failing to upload took a month to fix. Collaborating on the code had become impossible; any change could break something invisible in a completely unrelated system.

As the lead on this project I had to find a solution to this problem. I spent two weeks seriously considering my options and planning out how each could occur without causing too many delays in development. The main alternative I considered was sticking to Firestore and routing every request through Cloud Functions so there was a single source of truth in front of the database. I ended up rejecting this alternative because Firestore could not query or join large data fast enough for the analytics-related features we had planned. Bolting a search or analytics service onto Firestore would've solved this issue but would only add to the tech debt we were trying to pay off. Firestore's document model just wasn't the correct choice for our app.

In the end, switching to PostgreSQL with a custom .NET 10 backend was the obvious correct choice. Large guest lists now load instantly and correctly, down from a minute or more on the old Firestore-backed app, and both guests and hosts have told us they notice.

Why Flutter

Considering the issues we had with our React Native frontend and TypeScript itself, Flutter was an appealing alternative. Dart's stricter types and compiled nature lets us enforce stricter rules in the code than version 1 managed. Flutter's custom engine guarantees visual and functional parity across platforms. The Flutter SDK came as a more complete package to base the rewrite on, as opposed to React Native's heavy reliance on external tooling to be effective.

Why .NET

I chose .NET because it was similar to languages I had already worked in and I knew that due to being widely used, hiring for it would be trivial compared with something like Go. (Though we never actually hired for this role.)

I had concluded from v1 that a TypeScript backend was a mistake as it would lead to errors a stricter language like C# could catch at compile time. On top of that, EF Core makes the data layers easy to work with, and it allowed me to avoid writing SQL, which I was not familiar with at the time.

How it works

Every request in the app takes the same path, and keeping that path predictable was the whole point after version 1. A request authenticates at the edge, hits a thin handler, and is handed straight to a service. Handlers read the request, call one service method and shape the response; none of them contain rules of their own. Every rule lives in a service instead.

Authorization is applied as an endpoint filter rather than being something each handler has to remember to do. This means an endpoint cannot ship unprotected by accident, which was a genuine risk in version 1 where every cloud function checked permissions in its own way. There are 119 endpoints.

Behind the services is EF Core over PostgreSQL, 16 entities and 23 migrations so far. The one thing that deliberately does not happen inside the request is notifications, which are queued while the transaction runs and only sent once it has committed.

Decisions

One invite abstraction for every context

Adding a staff member, inviting a guest, handing a promoter a tracking link, giving out a discounted ticket and sending an announcement all look like separate features, and in version 1 they would have been built separately. I built them as one thing instead: an invite with a type, an optional custom token, a usage cap and a typed payload. What changes between contexts is what the payload means and what happens when someone redeems it. How an invite gets created, shared, tracked and revoked stays the same everywhere.

We recently needed announcement invites, which get sent out by email or SMS but can't be redeemed at all. Adding them took two days, because everything apart from the redemption behaviour already existed.

Extending the oversell guard instead of replacing it

Selling more tickets than actually exist is the one bug this system genuinely cannot have. The protection for this was already in place: a single conditional update which increments the used count only if the result stays within the cap. If it affects zero rows, someone else got there first and the request is rejected. There are no locks involved and no read-then-write race to lose.

When we added per-ticket-option caps to promoter links, the alternative was to replace that update with something more expressive. The total cap is set to the sum of the per-option limits instead, so the original atomic guard still holds exactly as it did, with the per-option validation layered in front of it. Links created before the feature existed carried on working without a migration or any special-casing.

Per-option links also write one redemption row per unit rather than one per redemption. This matters for refunds: releasing a single ticket from an order releases exactly that unit, rather than unwinding the whole order and putting everything back.

Notifications dispatch after the transaction commits

Sending an email from inside a request handler works right up until the transaction rolls back, at which point you have told a guest they have a ticket which doesn't exist. Messages are queued while the request runs and are only sent by a hook which fires after the save has actually committed. Email is preferred with SMS as a fallback, and each send is isolated so that one bad address can't stop the rest from going out.

Permissions resolve rather than being stored

Access is four tiers crossed with four domains, which would be sixteen rows per member if I stored every combination. I resolve it instead. The owner has no stored permission row at all and resolves to full access on every domain by virtue of owning the event, and a staff member with nothing granted resolves to the lowest tier rather than to an error. The effect is that adding someone to an event is one row, and removing the owner's permissions is not a state the system can be put into.

Keeping Firebase Auth while replacing Firestore

The rewrite replaced Firestore and Cloud Functions, but it did not replace Firebase Auth. Authentication was the one part of the old stack which was never causing us problems, and migrating live accounts would have been a risk with nothing to gain from it. The API verifies Firebase ID tokens and everything behind that boundary is ours. The rewrite replaced Firestore, not Firebase.

Moving the money through our account rather than around it

Version 1 created each charge on the organizer's connected Stripe account and pulled our cut out as an application fee, so the money never touched us. The rewrite inherited that, and in May 2026 we moved to destination charges instead. The charge is now created on our account and only the ticket price is transferred on to the organizer, which means our fee is not a Stripe object at all. It is the part we do not transfer.

What that bought is control over the edges. A refund reverses the transfer in the same call, and because the fee never left us we can decide per case whether it comes back: a guest cancelling their own ticket does not get the service fee returned, an organizer cancelling the whole event does. Under the old arrangement each of those was a separate reconciliation against an account we do not own.

Invoicing is denormalized onto the payment

Portuguese e-invoicing needs two documents per sale, one for the ticket and one for our service fee, because they are legally different things sold by different parties. Both are written onto the payment row rather than being looked up through the event and the organizer at refund time. Organizers change their billing details, and a credit note has to match the invoice that was actually issued, not the organizer's details today.

Check-in at the door

Tickets are scanned at the door against a per-ticket scan window, so a ticket cannot be used before doors open or after the event has finished. The largest event run through it so far admitted over 2,000 attendees.

This is the part of the product with no tolerance for being slow or wrong. There is a queue outside, the person scanning is not technical, and a rejected ticket is an argument at the door rather than an error message.

Testing

The Flutter client has one test file: four widget tests covering the QR scanner re-arming after a scan, a failed scan and a failed camera restart, against roughly 36,000 lines of Dart.

The backend has a suite of 594 tests which runs on every pull request. It was generated rather than designed, so it works as a change detector, reporting when behaviour has shifted rather than whether that behaviour was right in the first place.