---
title: Confirming payments
description: Every way to confirm a ZevPay Checkout payment (webhook, verify, force-verify, redirect, dashboard), when to use each, and how idempotency and retries keep fulfilment exactly once.
---

# 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](/webhooks/events#charge-success).** It arrives even if the customer closes the page, and we retry it if your server is down.
2. **Call [verify](/api/verify-session) 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](/guide/webhook-recovery)).

## Your options

| Option | How it reaches you | Use it for | Proof of payment? |
|--------|-------------------|------------|-------------------|
| [`charge.success` webhook](/webhooks/events#charge-success) | We call your server | Fulfilment. The default. | Yes, once the signature is verified |
| [Verify](/api/verify-session) `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](https://dashboard.zevpaycheckout.com) | 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.

::: code-group

```bash [cURL]
curl https://api.zevpaycheckout.com/v1/checkout/session/order_12345/verify \
  -H "x-api-key: sk_live_your_secret_key"
```

```js [Node.js SDK]
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](/api/initialize-session) 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](/guide/webhook-recovery) 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)

| 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](/guide/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 `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

## Related

- [Verify Session](/api/verify-session)
- [Webhook events](/webhooks/events) and [Verifying signatures](/webhooks/signatures)
- [Recovering a missed webhook](/guide/webhook-recovery)
- [Rate Limiting](/guide/rate-limiting)
- [Building with AI agents](/guide/ai-agents)
