All writings
June 11, 20262 min read

Stop Blocking on Payment Webhooks: Async Fulfillment with RabbitMQ

Distributed SystemsRabbitMQBackend

The tempting way to handle payments is a straight line: verify the payment, then create the order, then notify the kitchen — all inside one request handler. It demos perfectly. It also couples your order pipeline to a third-party gateway's latency and retries, and a single slow step blocks the HTTP thread that the gateway is waiting on.

In ByteBites I split that line with a durable message queue.

The failure mode I was avoiding

Payment gateways send webhooks, and webhooks retry. If your handler verifies the payment and then does real work — mark order paid, increment coupon usage, emit a socket event to the restaurant — every one of those steps is now inside the window the gateway is timing. A slow database write can make the gateway think the webhook failed and retry it, and now you're processing the same payment twice. Synchronous fulfillment turns every downstream hiccup into a payment bug.

A queue between money and orders

The Utils service owns payment verification. The moment a payment verifies, it does exactly one thing: publish a durable PAYMENT_SUCCESS message to a payment_event queue on RabbitMQ, then respond to the gateway immediately.

ts
1$// Utils service — respond fast, fulfil later
2$await channel.assertQueue("payment_event", { durable: true });
3$channel.sendToQueue(
4$ "payment_event",
5$ Buffer.from(JSON.stringify({ orderId, paymentId, gateway })),
6$ { persistent: true }
7$);
8$return res.status(200).json({ received: true });

The Restaurant service consumes that queue at its own pace — marking the order paid, recording coupon usage, and emitting order:new to the kitchen. The gateway's job ends in milliseconds; fulfillment happens behind the queue.

Durability and ordering are the point

Two properties make this safe. The queue is durable and messages are persistent, so a Restaurant-service restart doesn't drop a paid order — the message waits. And because a single consumer drains the queue, related events for an order are processed in order instead of racing across parallel request handlers. Consumers should still be idempotent (dedupe on paymentId) so a redelivered message can't double-apply, but the queue does the heavy lifting of decoupling.

The trade-off you're accepting

Async fulfillment isn't free: the order becomes placed a beat after payment, not in the same request, so the UI has to reflect a short pending state. That's a fair price. In exchange, the payment path stays fast and gateway-friendly, downstream slowness can't corrupt payments, and each service scales and restarts independently. For anything money-adjacent, a durable queue between verification and fulfillment is the version I'd reach for first.

From the project

ByteBites

Microservices + Full Stack

Read case study