Confirming payments
There are several ways to learn that a customer has paid. Use more than one: they back each other up. This page lists each option, when to use it, and how to make sure an order is fulfilled exactly once, however many of them fire.
The short version
- Fulfil from the
charge.successwebhook. It arrives even if the customer closes the page, and we retry it if your server is down. - Call verify when you need an answer now, for example when the customer lands back on your site.
- Make fulfilment idempotent on the payment reference, so the webhook, the verify call and any retry can all run without double-delivering.
- Reconcile once a day for anything a webhook might have missed (guide).
Your options
| Option | How it reaches you | Use it for | Proof of payment? |
|---|---|---|---|
charge.success webhook | We call your server | Fulfilment. The default. | Yes, once the signature is verified |
Verify GET /v1/checkout/session/{id}/verify | You ask us | Showing the customer a result; confirming before fulfilment; reconciliation | Yes, when status is completed |
Force-verify POST /v1/checkout/session/{id}/force-verify | You ask us; we ask the bank first | A customer saying "I've paid" before we have been notified | Yes, when status is completed |
Redirect / callback (callback_url, inline onSuccess) | The customer's browser | Showing a result page | No: anyone can type the URL |
| Dashboard | You look | Support and manual checks | Yes |
One ID, any of three
Verify and force-verify accept whichever identifier you have in hand:
| Identifier | Where you get it |
|---|---|
Your own reference (for example order_12345) | You chose it at initialize; returned as merchant_reference and in inline onSuccess |
Our reference (ZVP-...) | Callbacks, webhooks, the dashboard |
| Session ID | The initialize response |
Your own reference is usually the easiest: it is already stored against the order. Lookups by reference only ever see your sessions, in the mode of the key you call with. If one reference was used for several sessions (you re-initialized with a new amount), you get the completed one if there is one, otherwise the newest. URL-encode references that contain special characters.
curl https://api.zevpaycheckout.com/v1/checkout/session/order_12345/verify \
-H "x-api-key: sk_live_your_secret_key"import ZevPay from '@zevpay/node';
const zevpay = new ZevPay(process.env.ZEVPAY_SECRET_KEY);
const payment = await zevpay.checkout.verify('order_12345');
if (payment.status === 'completed') {
await fulfillOnce(payment.reference);
}Force-verify: when a customer is waiting
Verify answers from our records. Force-verify first asks the bank whether a transfer has landed, so it catches a payment whose notification has not reached us yet. It is deliberately limited to a few calls per 10 seconds per key: use it for one waiting customer (a "Check now" button), never in a loop over many sessions.
Idempotency: everything can safely happen twice
Networks fail, customers double-click, and webhooks can arrive more than once. Each step below is designed so that repeating it is harmless.
Creating the payment
Initialize is idempotent on your reference and aware of the amount:
- Same
reference, sameamount, while the session is still active: you get the same session back (samecheckout_url). A timed-out request can be retried without creating a second payment. - Same
reference, differentamount: the old session is expired and a fresh one is created, so a customer holding the old link can never pay an outdated amount.
Use one stable reference per order, or per period for recurring fees (for example student_123_2026-11).
Fulfilling the order
Fulfil once per payment, whichever signal arrives first:
-- One row per confirmed payment. The unique key makes a repeat a no-op.
create table zevpay_payments (
reference text primary key, -- our reference, data.reference
merchant_reference text not null, -- your order or payment ID
amount bigint not null, -- kobo
paid_at timestamptz not null default now()
);async function fulfillOnce(payment) {
const inserted = await db.query(
`insert into zevpay_payments (reference, merchant_reference, amount)
values ($1, $2, $3) on conflict (reference) do nothing returning reference`,
[payment.reference, payment.merchant_reference, payment.amount],
);
if (inserted.rowCount === 0) return; // already fulfilled: webhook, verify or a retry got here first
await grantAccess(payment.merchant_reference);
}Call the same fulfillOnce from the webhook handler, the return page and your daily reconciliation. Whichever runs second does nothing.
Also check the amount: compare amount (kobo) and currency with what the order expects before granting access.
Receiving webhooks
We will not deliver the same event for the same reference twice once your endpoint has accepted it, but a delivery can still repeat if your server times out after processing it. Treat every webhook as possibly seen before: that is what fulfillOnce is for.
Retries
When we retry (webhooks)
If your endpoint does not answer 2xx within 30 seconds, we retry up to 5 times with growing gaps (about 4, 9, 16, 25 and 36 minutes, roughly an hour and a half in all). After that the delivery is marked failed; you can resend it from the dashboard, and a verify call or your daily reconciliation will still find the payment.
To keep retries rare:
- Verify the signature, record the event, answer
200straight away, and do slow work (emails, provisioning) afterwards. - Return
2xxfor events you do not handle, so we do not retry them. - Return
401only for a bad signature.
When you retry (API calls)
| Response | Retry? | How |
|---|---|---|
| Network error or timeout | Yes | Retry initialize with the same reference and amount; verify is always safe to repeat |
429 Too Many Requests | Yes | Back off exponentially (1s, 2s, 4s...). See Rate Limiting |
5xx | Yes | Back off, a few attempts at most |
Other 4xx (validation, auth, TEST_MODE_BOUNDARY) | No | Fix the request; repeating it will fail the same way |
Checklist
- [ ] Fulfil from
charge.success, with the signature verified over the raw body - [ ] Verify on return before showing "paid"; the callback alone is never proof
- [ ] One
fulfillOncekeyed on ourreference, used everywhere - [ ] Amount and currency checked against the order
- [ ]
livemodechecked in production - [ ] Your own
referencesent on every initialize, reused on retry - [ ] Webhook handler answers fast and returns
2xxfor unhandled events - [ ] A daily reconciliation pass using verify