The customer paid, but the order still says unpaid

Payment webhook reconciliation matters when a customer has paid but your order still says unpaid. They may close the browser before the return page loads, or a webhook may arrive while your worker is unavailable. Asking them to pay again can make the situation worse.

The browser redirect is a convenience for the customer. It shouldn’t be the only evidence your application uses. Let’s make the order update depend on a verified payment record instead.

Record the payment attempt before sending the customer away

Create a local payment-attempt row with the order ID, expected amount, currency and provider reference. Keep a stable operation ID for creating the provider object so a lost response doesn’t create unrelated attempts without a record.

The order and payment attempt have different lifecycles. An order can remain awaiting payment while a provider processes a method that doesn’t complete immediately. Don’t assume every successful API call means funds are confirmed.

Keep all references scoped to the correct business account. Looking up a payment under the wrong account can produce a misleading missing result.

Accept verified events durably

Verify the provider’s webhook signature using the correct raw payload and current integration method. Save the event before returning success, then let a worker apply it to the order.

Use a provider event ID to make repeated delivery safe where available. Store unrecognised events for investigation instead of attaching them to an order by a loose amount match.

Stripe documents duplicate delivery and ordering considerations for webhooks. Those are useful reasons to make the application transition repeatable rather than depending on one arrival sequence. Webhook reference.

Reconcile from the original payment

When an event is delayed or missing, a scheduled reconciliation job can query the provider reference already stored on the attempt. It should apply the same verification rules as the webhook worker.

Check the provider account, payment reference, amount, currency and relevant business association. Never mark an order paid merely because a client supplied a plausible payment ID.

Build the repeatable transition

The example models a lost browser redirect, followed by a provider lookup. Applying the same verified success twice produces one fulfilment request. A mismatched amount is rejected.

import assert from 'node:assert/strict';
const order={id:'o1',amount:2500,currency:'NGN',state:'awaiting_payment'};
const attempt={providerId:'p1',orderId:'o1'};
const outbox=new Set();
function applyVerified(payment) {
  if(payment.id!==attempt.providerId||payment.orderId!==order.id||payment.amount!==order.amount||payment.currency!==order.currency)throw new Error('Mismatch');
  if(payment.state!=='succeeded')return false;
  if(order.state==='paid')return false;
  order.state='paid';outbox.add(`fulfil:${order.id}`);return true;
}
const providerRecord={id:'p1',orderId:'o1',amount:2500,currency:'NGN',state:'succeeded'};
assert.equal(order.state,'awaiting_payment');
assert.equal(applyVerified(providerRecord),true);
assert.equal(applyVerified(providerRecord),false);
assert.equal(outbox.size,1);
assert.throws(()=>applyVerified({...providerRecord,amount:1}));
console.log('PASS: original payment reconciliation, amount checks and single fulfilment intent');

No live provider is called. The test establishes the order transition and its deduplication boundary. Your real integration still needs signature verification, authenticated lookups and durable transactions.

Commit fulfilment intent with the paid transition

Updating the order and sending a fulfilment message are two separate operations if they go to different systems. A crash after marking paid but before publishing can leave the order stuck.

Save a fulfilment outbox row in the same transaction as the paid transition. A relay can publish it later, and the fulfilment consumer should safely handle repeat publication.

Don’t mark an event handled in one transaction and update the order in another. A crash between them can make the event be skipped for good even though its business effect never happened.

Handle competing and late attempts

A customer may have more than one payment attempt for the same order. Decide which attempts are still payable and what to do if more than one succeeds. The order should not be fulfilled twice.

Store extra successful payments as exceptions requiring an explicit resolution policy. Don’t discard them because the order is already paid. Your accounting and customer-support views need to explain the additional funds.

For delayed payment methods, the order may expire before success arrives. That is another business decision: reserve stock longer, attempt a fresh allocation or refund. A paid event alone shouldn’t bypass inventory and fulfilment constraints.

Make unresolved payments visible

Give support a view of order state, payment attempt state and the last reconciliation result. A generic unpaid label hides the difference between “no attempt exists”, “provider still processing” and “success not yet applied”.

Record the next check time and bounded retry policy. If provider access is failing, alert on the age of unresolved attempts rather than repeatedly hammering the provider.

Keep sensitive payment credentials out of logs and model prompts. The agent needs a structured status and a customer-safe explanation, not the entire provider object.

Test missing pieces independently

Drop the redirect, delay the webhook, deliver it twice and restart after the paid update. Confirm that reconciliation reaches the same state and that fulfilment is requested once per order transition.

Then test a wrong amount, another currency, a provider-pending state and an extra successful payment after the order was already paid. These are different outcomes and should remain distinguishable.

Start by storing the provider reference before redirecting the customer. Once you can look up the original attempt without relying on the browser, you have a path to recover an order instead of asking the customer to repeat payment.

Run the example and adapt it

Save the code as 16-payment-reconciliation.mjs and run it with Node.js 24 or later:

node 16-payment-reconciliation.mjs

The code above includes assertions you can run locally. It tests the local decision logic with controlled inputs; it doesn’t connect to a live provider or establish production performance. Keep that boundary when adapting it to your application.

Technical references