DEV Community

Abhishek
Abhishek

Posted on

A Successful Payment That Never Became a Booking: Building a Fault-Tolerant Payment Pipeline

A Successful Payment That Never Became a Booking

A customer completed their payment.

Razorpay showed Payment Successful.

The webhook returned HTTP 200.

The payment was captured.

And the booking sat there spinning on:

Still confirming...

Nothing had errored.

No exception.

No failed request.

No alert.

By every individual metric, the system was healthy.

Yet the one thing that mattered hadn't happened.

The customer had paid.

The creator had no booking.

This is the story of that bug, the investigation behind it, and the architecture we built so it can never happen again.

The lesson underneath all of it is deceptively simple:

Accepting money is easy. Guaranteeing that every successful payment becomes a confirmed booking is the actual problem.


What CreatorOS Is, and Why This Matters

CreatorOS is a booking and payments platform for independent creators, coaches, tutors, consultants, and freelancers.

A typical flow looks like this:

Client
   ↓
Select Slot
   ↓
Pay via UPI
   ↓
Booking Confirmed
Enter fullscreen mode Exit fullscreen mode

Money moves through Razorpay.

Bookings live in our database.

When payments are part of the product, "mostly works" is not good enough.

A payment that vanishes into a stuck booking isn't just a bug.

It's:

  • A customer who paid and received nothing
  • A creator who appears unreliable
  • A loss of trust

And trust is the entire business.

The goal was never:

Process payments.

The real goal was:

Every captured payment must eventually become a confirmed booking.


The Naive Architecture (What Most Tutorials Teach)

Most payment tutorials teach something like this:

flowchart TD
    A[Razorpay]
    B[Webhook]
    C[Update Booking]

    A --> B
    B --> C
Enter fullscreen mode Exit fullscreen mode

The webhook receives an event and immediately updates the booking.

Simple.

Clean.

Dangerous.

This architecture quietly carries every failure mode that matters:

  • Webhook retries
  • Duplicate deliveries
  • Partial failures
  • Race conditions
  • No recovery mechanism

The moment business logic lives inside a webhook handler, correctness becomes dependent on delivery success.

That is a fragile system.


Moving To Event Sourcing

The first major architectural decision was:

Webhooks should record events, not perform business logic.

Instead of updating bookings directly, we introduced an event ledger.

flowchart TD
    A[Razorpay Webhook]
    B[payment_events]
    C[Processor]
    D[Bookings]

    A --> B
    B --> C
    C --> D
Enter fullscreen mode Exit fullscreen mode

Three tables became the foundation of the system:

Table Purpose
payment_orders Provider truth
payment_events Immutable event ledger
bookings Business truth

This design gives us:

Durability

Events are safely stored before processing.

Auditability

Every state transition can be traced.

Replayability

Reprocessing means replaying events.

Idempotency

Duplicate deliveries become harmless.

The webhook now has one responsibility:

Verify Signature
      ↓
Store Event
      ↓
Return 200
Enter fullscreen mode Exit fullscreen mode

Nothing else.


Two State Machines, Kept Separate

One subtle but critical decision:

Payment state and booking state are not the same thing.

Treating them as identical creates hidden bugs.

Payment State Machine

This reflects provider truth.

stateDiagram-v2
    [*] --> Created
    Created --> Authorized
    Authorized --> Captured
    Authorized --> Failed
    Created --> Failed
Enter fullscreen mode Exit fullscreen mode

Booking State Machine

This reflects business truth.

stateDiagram-v2
    [*] --> Pending

    Pending --> PaymentPending
    PaymentPending --> Confirmed
    PaymentPending --> Cancelled
Enter fullscreen mode Exit fullscreen mode

A payment being captured is merely an input into a booking becoming confirmed.

They are related.

They are not identical.

Keeping them separate makes reconciliation possible.


The Incident

Then it happened.

A customer paid.

Razorpay showed success.

The webhook arrived.

The event existed in the database.

And the booking remained:

payment_pending
Enter fullscreen mode Exit fullscreen mode

The first query told the story:

SELECT event_type, processed
FROM payment_events;
Enter fullscreen mode Exit fullscreen mode

Output:

payment.captured    false
order.paid          false
Enter fullscreen mode Exit fullscreen mode

The events existed.

They simply had not been processed.

Everything looked healthy:

  • Razorpay worked
  • Webhook worked
  • Database worked

Yet bookings did not.


The Investigation

We traced a single booking through the system.

Using its correlation ID we followed the trail across:

  • payment_events
  • payment_orders
  • bookings

Every row looked correct.

Except for one field:

processed = false
Enter fullscreen mode Exit fullscreen mode

Repeated everywhere.

So we manually invoked the processor:

curl \
-H "Authorization: Bearer xxx" \
https://creator-os.vercel.app/api/cron/process-events
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "processed": 15
}
Enter fullscreen mode Exit fullscreen mode

Immediately:

  • Events became processed
  • Orders became captured
  • Bookings became confirmed

The processor wasn't broken.

It worked perfectly.

The moment it ran.


Root Cause: The Scheduler That Didn't Exist

The investigation ultimately revealed something surprisingly simple.

Webhook
   ↓
Event Stored
   ↓
Processor Exists
   ↓
Never Runs
Enter fullscreen mode Exit fullscreen mode

Nobody was invoking:

processPendingEvents()
Enter fullscreen mode Exit fullscreen mode

The processor was healthy.

The webhook was healthy.

The database was healthy.

The scheduler was missing.

This is the hidden cost of event-driven architecture.

Decoupling ingestion from processing is the right design.

But it creates a new dependency:

Something must reliably trigger processing.

Without that trigger, events accumulate forever.


Building Recovery Mechanisms

Adding a cron job wasn't enough.

The goal was:

Even if a webhook fails, the system must eventually recover.

Scheduled Processing

A scheduler drives several independent jobs.

*/5 * * * *
Enter fullscreen mode Exit fullscreen mode

process-events

Processes payment events.

reconcile

Queries provider truth and recovers missed webhooks.

integrity

Validates system invariants.


Idempotency Everywhere

Retries should never create incorrect state.

The processor claims work using:

SELECT *
FROM payment_events
WHERE processed = false
ORDER BY created_at
FOR UPDATE SKIP LOCKED;
Enter fullscreen mode Exit fullscreen mode

This guarantees:

  • Multiple workers are safe
  • Retries are safe
  • Duplicate deliveries are safe

Whether one processor runs or one hundred, results remain correct.


Proving It With CI

A payment system you cannot test is a payment system you cannot trust.

We built CI that spins up a real Postgres instance.

flowchart TD

    A[GitHub Actions]
    B[Postgres Container]
    C[Test Suite]
    D[117 Passing Tests]

    A --> B
    B --> C
    C --> D
Enter fullscreen mode Exit fullscreen mode

The tests verify:

  • Duplicate webhook deliveries
  • Event replay
  • Concurrent processors
  • Reconciliation recovery
  • Booking confirmation invariants

Not just the happy path.

The guarantees.


Final Production Architecture

The architecture follows a simple principle:

  • Webhooks record
  • Events persist
  • Processors transform
  • Reconciliation guarantees
  • Schedulers trigger
  • Idempotency protects

What It Taught Us

A Successful Payment Is Not The Same Thing As A Successful Booking

They are different facts.

The gap between them is where customers get hurt.


Webhooks Should Record Events, Not Perform Business Logic

Business logic belongs in processors.

Webhooks should be fast, durable, and boring.


Every Critical Workflow Needs A Recovery Path

If your system only works when every webhook arrives on time:

It doesn't actually work.

It just hasn't failed yet.


Schedulers Are Production Infrastructure

A queue that nobody drains is just a place where work waits forever.

The trigger is as important as the processor itself.


Reliability Is A Feature

Customers never notice reliability when it exists.

They immediately notice when it doesn't.


Closing Thoughts

The hardest part of payments isn't collecting money.

Stripe, Razorpay, PayPal, and countless providers already solved that problem.

The hard part is guaranteeing that successful payments eventually become business outcomes.

That means:

  • Durable events
  • Idempotent processing
  • Reconciliation
  • Recovery paths
  • Operational visibility

The day we stopped thinking about payments as API calls and started thinking about them as distributed systems was the day the architecture became reliable.

And that reliability is ultimately what customers pay for.

Top comments (0)