Skip to content

Recovering from a missed webhook

Webhooks fail. Not often, but the ways they fail are ordinary: a deploy restarts your receiver mid-request, a bug in your handler throws before you commit, a firewall rule changes, a queue backs up and a retry window closes. None of these are exotic, and all of them end the same way. Your records say a payment did not happen. Ours say it did.

The fix is the same on every rail we offer, and it is deliberately boring: ask us. Every payment type has an endpoint that returns the current truth, and that endpoint is authoritative. Your webhook handler is an optimisation on top of it, not the record itself.

The one-line version

Treat webhooks as a latency improvement and a scheduled fetch as your source of truth. Systems built that way do not have webhook incidents, they have a slightly slower Tuesday.

Why not just retry harder

We do retry. Deliveries are attempted repeatedly with backoff, and you can replay them from your dashboard. But retries cannot help with the failure that matters most: a handler that returned 200 and then failed to write the row. From our side that delivery succeeded. Only your side knows it did not, and only a fetch will tell you.

The pattern

  1. Find the things that should have resolved by now: sessions you created but never marked paid, invoices with a balance, accounts you expect money on.
  2. Fetch each one.
  3. Compare what we return against your ledger and write what is missing.
  4. Match on a reference, never on an amount.

That last point is the one that bites. Two customers can pay the same amount for the same product in the same minute, and one customer can pay an instalment invoice twice for the same figure. Amounts are not identities. References are.

By payment type

Checkout sessions

For a one-off payment created with initialize:

bash
curl https://api.zevpaycheckout.com/v1/checkout/session/{session_id}/verify \
  -H "x-api-key: sk_live_your_secret_key"

Returns the session's status, reference, amount, payment_method and paid_at. A session that reads completed was paid, whatever your records say.

If you need an answer that is fresher than our own records, use force-verify instead:

bash
curl -X POST https://api.zevpaycheckout.com/v1/checkout/session/{session_id}/force-verify \
  -H "x-api-key: sk_live_your_secret_key"

That one asks the bank directly before answering, so it catches a payment that has landed at the provider but whose notification has not reached us yet. It is rate limited because each call is an outbound request to a partner. Use it when a specific customer is waiting, not in a loop over yesterday's sessions.

See Verify Session.

Invoices

bash
curl https://api.zevpaycheckout.com/v1/checkout/invoice/{public_id} \
  -H "x-api-key: sk_live_your_secret_key"

Returns status, amount_paid, amount_outstanding, and every payment made against it. On an instalment invoice each payment carries its own payment_id and the reference of the transaction it settled as.

Anything in payments[] that is not in your ledger is what you missed. See Recovering from a missed webhook in the invoice reference for the full shape.

Static NGN accounts

A permanent account collects indefinitely, so the recovery unit is a list rather than a single object:

bash
curl "https://api.zevpaycheckout.com/v1/checkout/static-account/{id}/payments?page=1" \
  -H "x-api-key: sk_live_your_secret_key"

Every inflow received on that account, paginated. Because these accounts are assigned to one of your customers, this doubles as that customer's statement.

See Static Account API.

Virtual accounts

bash
curl https://api.zevpaycheckout.com/v1/checkout/virtual-account/{public_id} \
  -H "x-api-key: sk_live_your_secret_key"

Returns the account's status and completed_at. A completed virtual account was paid.

ZevPay IDs

Both static and dynamic PayIDs expose the same shape:

bash
curl https://api.zevpaycheckout.com/v1/checkout/static-payid/{id} \
  -H "x-api-key: sk_live_your_secret_key"

A reconciliation pass worth copying

Run this nightly. It is cheap, and it means a webhook outage becomes something you notice in a report rather than in a customer complaint.

javascript
// Everything you believe is unpaid but might not be.
const open = await db.invoices.find({
  status: { $in: ["sent", "partial", "overdue"] },
});

for (const local of open) {
  const res = await fetch(
    `https://api.zevpaycheckout.com/v1/checkout/invoice/${local.zevpayPublicId}`,
    { headers: { "x-api-key": process.env.ZEVPAY_SECRET_KEY } },
  );
  const { data } = await res.json();

  // Reconcile payment by payment, matching on reference.
  for (const payment of data.payments) {
    const ref = payment.transaction?.reference;
    if (!ref) continue;
    const known = await db.payments.findOne({ reference: ref });
    if (known) continue;

    // We were paid and never recorded it. Do now what the webhook
    // handler would have done.
    await recordPayment({
      reference: ref,
      invoiceId: local.id,
      amount: payment.amount,
      paidAt: payment.paid_at,
    });
    log.warn(`Recovered missed payment ${ref} on invoice ${local.id}`);
  }

  if (data.status === "paid" && local.status !== "paid") {
    await markInvoicePaid(local.id);
  }
}

The log.warn matters as much as the recovery. A pass that silently fixes things hides a delivery problem that will keep happening.

Make your handler idempotent first

Recovery only works if replaying a payment is safe. Whether a payment reaches you by webhook, by this fetch, or by both a second apart, your handler should reach the same state.

Key on the payment's reference and write once:

javascript
async function recordPayment(p) {
  // Unique index on reference. The second write is a no-op, not a duplicate.
  await db.payments.updateOne(
    { reference: p.reference },
    { $setOnInsert: p },
    { upsert: true },
  );
}

Without that, adding a reconciliation pass turns one missed payment into two recorded ones, which is a worse problem than the one you started with.

What not to do

Do not poll every few seconds. Payment confirmation is push-first; the fetch is a safety net. Polling open sessions aggressively will hit rate limits and delay the customers who are actually waiting.

Do not match on amount. Covered above, and worth repeating because it looks like it works right up until the day two customers pay the same figure.

Do not treat a webhook you cannot verify as proof. If a payload arrives from an unexpected source, or you are unsure whether a retry is genuine, verify it against the endpoints here before you fulfil. Every event we send has a signature, and every payment we report can be confirmed independently.

ZevPay Checkout Developer Documentation