In a marketplace, a repeated webhook is not a rare case: it is how gateways normally behave, resending until they get a success response. If every resend becomes a credit, the seller gets paid twice; if it becomes a refund, they lose twice. On PokerNetic's marketplace I wrote most of the backend and work on the payments service. Here is how payment confirmation became idempotent and what comes next.
sequenceDiagram
participant B as Buyer
participant M as marketplace
participant Q as RabbitMQ
participant P as payment
participant G as Gateway
participant DB as PostgreSQL
B->>M: checkout
M->>Q: payment link request
Q->>P: creates the gateway session
P-->>M: payment link (RPC reply)
M-->>B: payment link
B->>G: pays
G->>P: signed webhook
P->>DB: records the event (gateway, id) if it does not exist yet
alt repeated event
DB-->>P: already recorded, nothing to do
else first time
P->>Q: order paid, on the right service queue
Q->>M: confirms the order
P->>DB: seller credit in the ledger (internal gateway)
end
The order and the payment link
Checkout recomputes prices and coupons, keeps subscriptions apart from one-off products, generates the order number in a locked transaction and computes the platform fee and the store's net amount. It then asks the payment service for a payment link over a RabbitMQ RPC. Payment creates the gateway session and records the transaction with the gateway id as a unique key.
An unpaid order expires on its own: a job queue schedules the expiration with one id per order, and scheduling again cancels the previous job for the same order.
Gateways behind an abstraction
Every gateway implements the same interface: check the status, refund, create the link and map gateway statuses to platform statuses, with a safe default for unknown ones. The service picks the implementation by name, and registering the same name twice is an error.
- Stripe Connect: direct charges on the seller's account, with the platform fee taken in the charge itself.
- Internal gateway: the platform as merchant of record: the whole amount lands in the platform account, the split travels in the session metadata and is settled in the seller's ledger.
- Mercado Pago and a mock: implemented behind the same interface and currently disabled: the active paths are Stripe Connect and the internal gateway.
Idempotent webhooks
Each webhook signature is validated over the raw body, with one secret per integration. Idempotency is a claim in the database: before processing, the service tries to insert the gateway and event id pair into a table with a unique index. If the row already exists, the event is a resend and nothing happens. If processing fails after the claim, the claim is released and the service returns an error, so the gateway resends.
This came from a real problem: webhook retries credited a seller twice, and a refund once debited twice.
The seller's money
The seller's balance lives in a ledger per seller and currency, with pending and available balances. Credits and debits run in a transaction with a pessimistic lock, and every entry records the balance before and after, plus the date the amount becomes available after the holding period. Payouts follow the frequency the seller configured, with a minimum amount, and commission invoices are generated per period.
Internal coins that never go negative
Paying with the platform's coins follows reserve, approve or release: the order reserves the amount over RPC, approval turns the reservation into spending and a rejection gives it back. Every balance mutation runs in a transaction with a pessimistic lock, and a database constraint keeping the balance at or above zero is the last line of defense.
The queue that delivered to the wrong place
One bug taught a lot: a single transactions queue was consumed by four different services. RabbitMQ distributes messages round-robin without looking at the type, so payment confirmations landed on the wrong service and were silently dropped. The fix was one transactions queue per service, routed by order type, and a legacy queue that warns when a type has no consumer.
Along the same line, the RabbitMQ client got an error listener on the channel, because an error without a listener during reconnection took the process down, and now uses one shared connection per process, with heartbeat and reconnection.
Tests
The tests cover the webhook idempotency service, the Stripe and internal gateway handlers, Stripe Connect, payment transactions, the seller ledger, payouts and the order, checkout and discount rules, plus the coin balance.
The next step
The strongest guarantee is at the webhook boundary: an event is never processed twice. The next step is to carry the same guarantee end to end into the order itself.
