Skip to content

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 ​

  1. Fulfil from the charge.success webhook. It arrives even if the customer closes the page, and we retry it if your server is down.
  2. Call verify when you need an answer now, for example when the customer lands back on your site.
  3. Make fulfilment idempotent on the payment reference, so the webhook, the verify call and any retry can all run without double-delivering.
  4. Reconcile once a day for anything a webhook might have missed (guide).

Your options ​

OptionHow it reaches youUse it forProof of payment?
charge.success webhookWe call your serverFulfilment. The default.Yes, once the signature is verified
Verify GET /v1/checkout/session/{id}/verifyYou ask usShowing the customer a result; confirming before fulfilment; reconciliationYes, when status is completed
Force-verify POST /v1/checkout/session/{id}/force-verifyYou ask us; we ask the bank firstA customer saying "I've paid" before we have been notifiedYes, when status is completed
Redirect / callback (callback_url, inline onSuccess)The customer's browserShowing a result pageNo: anyone can type the URL
DashboardYou lookSupport and manual checksYes

One ID, any of three ​

Verify and force-verify accept whichever identifier you have in hand:

IdentifierWhere 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 IDThe 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.

bash
curl https://api.zevpaycheckout.com/v1/checkout/session/order_12345/verify \
  -H "x-api-key: sk_live_your_secret_key"
js
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, same amount, while the session is still active: you get the same session back (same checkout_url). A timed-out request can be retried without creating a second payment.
  • Same reference, different amount: 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:

sql
-- 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()
);
js
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 200 straight away, and do slow work (emails, provisioning) afterwards.
  • Return 2xx for events you do not handle, so we do not retry them.
  • Return 401 only for a bad signature.

When you retry (API calls) ​

ResponseRetry?How
Network error or timeoutYesRetry initialize with the same reference and amount; verify is always safe to repeat
429 Too Many RequestsYesBack off exponentially (1s, 2s, 4s...). See Rate Limiting
5xxYesBack off, a few attempts at most
Other 4xx (validation, auth, TEST_MODE_BOUNDARY)NoFix 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 fulfillOnce keyed on our reference, used everywhere
  • [ ] Amount and currency checked against the order
  • [ ] livemode checked in production
  • [ ] Your own reference sent on every initialize, reused on retry
  • [ ] Webhook handler answers fast and returns 2xx for unhandled events
  • [ ] A daily reconciliation pass using verify

ZevPay Checkout Developer Documentation