Skip to content

Retries & reliability

The result callback is sent once, on a best-effort basis, when a transaction reaches a terminal status.

  1. Return 2xx fast. Acknowledge the callback, then do fulfilment, emails, and invoicing asynchronously. A handler that takes seconds is a handler that times out.

  2. Reconcile on a schedule. Run a job that finds every order still awaiting payment after a reasonable window — a few minutes is usually enough — and calls Check Payment Status for each one. This is what catches the transactions whose callback never landed.

  3. Stop only on a terminal status. Keep checking until transactionStatus is terminal. PENDING, PROCESSING, and PARTIALLY_PAID all mean the payment is still in flight.

  4. Age out abandoned attempts. Customers walk away from checkouts. Give an unpaid transaction a sensible expiry in your own system rather than polling it forever.

  5. Keep the handler idempotent. Your callback handler and your reconciliation job will sometimes process the same result. Key both on referenceNumber — see Verifying callbacks.

Because there’s no retry, a callback that arrives mid-deploy is simply gone. Two things make that a non-event: reconciliation (step 2), and never treating “no callback” as “no payment”. Anything you’d have done from the callback should be reachable from the reconciliation path too.

Run this on a schedule. It calls the same settle function as your callback handler, so a payment settles exactly once whichever path finds it first.

jobs/reconcile.js
// Every 5 minutes.
async function reconcile() {
const stale = await orders.findUnpaidOlderThan({ minutes: 5 });
for (const order of stale) {
const result = await pesepay.checkPayment(order.referenceNumber);
if (!result.success) continue; // transient — try again next run
if (result.paid) {
await orders.markPaid(order.id);
await fulfil(order);
} else if (order.createdAt < hoursAgo(24)) {
await orders.expire(order.id); // the customer never came back
}
}
}