RevoTickets, for Revolution Stage · 2026 · 2 of 3

money out, no ticket

You know the FPX screen. It spins, the money leaves your account, and then nothing. RevoTickets is built on the assumption that its payment gateway will go quiet at the worst possible moment, or tell it something that isn't true yet. This is how it decides whether you actually paid, without ever trusting the message that says you did.

FPX bank
− RM 90.00 debited
status: (no message)
so, did they pay?
ask the gateway,
don't trust the callback
The question every checkout ends on
Responsibilities

Architecture, backend, build

Technology stack

Ruby on Rails 8, ToyyibPay (FPX), Solid Queue, PostgreSQL, Sentry

Year

Status

Pre-launch, GA later in 2026

Summary

RevoTickets takes payment through ToyyibPay, which fronts FPX, the way most Malaysians pay for things online: you pick your bank, you approve the transfer, money moves. It works well almost all of the time. This post is about the rest of the time, the slice where the money has moved but the software has no reliable message telling it so. That gap is where a customer ends up charged with nothing to show for it, and it is the single part of the system I trusted the least, on purpose.

The short version of the whole design: the payment gateway is not allowed to be the source of truth about whether you paid. It is only allowed to wake the system up and tell it to go check.

The callback you can't trust

When a payment finishes, ToyyibPay calls a URL on our side to say what happened. Two things about that callback shape everything else.

First, it isn't signed. There is no shared secret, no signature to verify the message really came from the gateway and wasn't tampered with on the way. Anything that can reach the URL can post to it. So the contents of a callback are, at best, a rumour.

Second, and this is the part that unsettled me, an FPX gateway can take the money before it reports a final status. The debit and the "here is what happened" message are not one atomic thing. The customer's account is lighter, and the callback might say pending, or say nothing, or never arrive because their phone dropped off the network at the wrong second. The gateway's own guidance is blunt about it: treat the callback as an untrusted wake-up, then confirm the real status by asking the API.

So that is exactly what RevoTickets does. The callback is a doorbell, not a receipt.

One function decides, and everything else asks it

There is exactly one method that is allowed to settle a booking, and it works by re-deriving the truth from the gateway's transaction records rather than believing anything it was handed. Four different things call it: the webhook when the callback lands, the success page when the customer gets redirected back, a background job that sweeps for stragglers, and a rake task ops can run by hand. All four ask the same question the same way, and it is safe to call over and over. Ask it twice and the second answer matches the first.

            flowchart LR
              A["Webhook
(callback lands)"] --> R B["Success page
(customer returns)"] --> R C["Background job
(sweeps stragglers)"] --> R D["Rake task
(ops, by hand)"] --> R R["reconcile_booking!
the single source of truth"] --> G[("ToyyibPay
getBillTransactions")] R --> S{"Settle"} S -->|"one success that
belongs to us"| P["paid"] S -->|"every attempt failed"| F["failed"] S -->|"nothing yet"| W["pending, left untouched"] classDef src fill:#eef2ff,stroke:#6366f1 classDef truth fill:#e7f6f0,stroke:#2e7d68,stroke-width:2px class A,B,C,D src class R truth
Every path to settling a booking funnels through one idempotent method. It reads the gateway's own records, then decides.

The decision itself is deliberately narrow. Pull the bill's transactions from the gateway. If one of them succeeded and genuinely belongs to this booking, it is paid. If there are attempts and every one of them failed, it is failed, and the seats go back on sale. Anything else, no transactions yet or still in flight, is left completely untouched so a later check can settle it properly. The system would rather say "I don't know yet" than guess.

Trust the payment, verify the paperwork

Because the gateway is treated as untrusted, a couple of extra checks sit around the moment of crediting. When we create a bill, we stamp our own booking id onto it as an external reference. The gateway echoes that back on the transaction, so before crediting anyone we confirm the transaction actually points at this booking. If the field is present and points somewhere else, we refuse to credit it and raise the alarm.

The interesting part is what happens when the numbers almost match. If the settled amount comes back different from what the booking should have cost, the system does not quietly hold the payment hostage. It credits the customer who genuinely paid, and logs the mismatch loudly for a human to look at. This is a judgement call, and it is the right one: the worse failure, by a wide margin, is leaving a real customer charged and stranded while an amount rounding quirk gets argued about. Take care of the person first, reconcile the paperwork after.

One more small discipline runs through all of it. None of these log lines or alerts carry the payer's name, email, phone, or the bill code. Just the booking id and what happened. When you are logging money problems, the logs themselves become a place personal data can leak, so they only ever carry scalars.

When the money arrives after the seat is gone

There is one case that money made genuinely ugly, and it is where this post hands over to the next one. A customer's booking can be cancelled while their payment is still clearing at the bank. Their seats have already been released back to everyone else. Then the money lands.

You cannot pretend it didn't. The payment is real, the customer's account is lighter, and the record has to say so. But you also cannot ship a clean, completed booking, because it no longer has any seats attached to it. So RevoTickets does the honest, slightly uncomfortable thing: it records the payment as paid and completed, flags the booking for a human with a reason of "paid after supersede", and deliberately does not send the usual confirmation email, because that email lists seats this booking no longer has. Re-attaching seats is a support action, not something the code does automatically, because automatically grabbing seats here is exactly how you double-book someone else.

Choosing to flag a case for a human instead of "solving" it in code felt like a failure the first time I wrote it. It is the opposite. Some situations genuinely need a person, and pretending otherwise just launders a hard problem into a silent wrong answer. Why a booking gets superseded in the first place, and the chain of locks meant to stop it, is the whole of the next post.

What I took from it