Reconciliation — redirect vs. webhook
Von Payments sends two signals for every completed payment:
| Signal | Type | Delivered | Authority |
|---|---|---|---|
| Return redirect | Synchronous redirect | The moment the buyer returns to your successUrl | A prompt, not a fact. It tells you the buyer came back, not that they paid — look the session up with your API key to learn the outcome. See Handle the return. |
charge.succeeded webhook | Durable async POST | Usually within a few seconds; can be delayed during outages | Signed with your endpoint's whsec_* secret — cryptographically authoritative once you verify it |
Only the webhook is authoritative on its own. They exist for different failure modes:
- The redirect fails if the buyer closes their browser before returning.
- The webhook fails if your endpoint is unreachable, slow, or returns non-2xx.
Signature verification belongs on the webhook. You verify a webhook with that endpoint's
whsec_*signing secret — not with your API key (vp_sk_*/vp_pk_*). Nothing on the return URL should be verified and acted on as proof of payment; confirm the redirect by looking the session up server-side instead.
This page covers what to do when one arrives and the other doesn't.
The pattern
The charge.succeeded webhook is your source of truth for settlement. The return redirect is a faster, synchronous signal — use it to advance the buyer's UX immediately — but it is not proof of anything on its own: it can be reloaded, shared, or arrive after a failed payment (see Handle the return). Before any irreversible fulfilment (shipping, digital delivery, granting access) you must confirm payment status server-side and guard idempotency. Remember the sessionId; when the second signal arrives, treat it as a no-op.
A closed tab, a browser hiccup, or a network error on the return can happen after the charge already succeeded. Never mark an order failed/unpaid on a client-side or redirect error alone — reconcile against the charge.succeeded webhook (or a server-side sessions.get) before treating a payment as failed.
Redirect arrived but webhook hasn't
The most common case. The buyer returned to your successUrl — look the session up before you show anything final, and before fulfilling anything irreversible:
GET /v1/sessions/{sessionId}
Authorization: Bearer vp_sk_live_…
This endpoint requires a secret key; a publishable key (vp_pk_*) is rejected with 403 auth_key_type_forbidden. A 200 with status: "succeeded" confirms settlement fresh from the server — then idempotency-guard (below) so a replayed redirect can't fulfil the same order twice. The webhook arriving later (or never) doesn't change the outcome.
Webhook arrived but redirect didn't
The buyer closed their browser before returning to successUrl. You only learn about the payment from the charge.succeeded webhook. That's fine — the webhook is authoritative. Fulfil the order; email the buyer separately.
Both arrived (the happy path)
Make your handler idempotent by deduping on the webhook envelope's top-level id (the vp_evt_* field). That id is the dedupe key by design: a redelivery carries the same id, so a single unique constraint on it collapses duplicates safely.
Do not dedupe on data.session_id. It is a correlation key, not a dedupe key — it is nullable, scoped to one session rather than unique per event, and a single payment movement can legitimately fan out into two distinct events (for example a charge.* and a payment_intent.* event) that share one session_id but carry different id values. Deduping on session_id would wrongly collapse those distinct events.
// Pattern A — unique constraint on the envelope id in your orders table
await db.query(
`INSERT INTO orders (event_id, session_id, ...) VALUES ($1, $2, ...) ON CONFLICT (event_id) DO NOTHING`,
[event.id, event.data.session_id, ...]
);
// Pattern B — check-then-act, keyed on the envelope id
const existing = await db.orders.findOne({ event_id: event.id });
if (existing) return res.status(200).json({ received: true });
// ... create order, fulfil, etc.
Neither arrived (the rare failure)
The signed redirect didn't fire AND the webhook didn't fire within your tolerance window. The buyer may or may not have paid. Don't fulfil. Instead:
- Poll
GET /v1/sessions/{sessionId}to retrieve server-side state. - If
status: "succeeded"— both signals failed in delivery but the payment cleared. Fulfil and log the dual-failure for monitoring. - If
status: "pending"— the buyer hasn't completed. Don't fulfil. - If
status: "expired"or"failed"— the buyer didn't complete. Don't fulfil.
A reasonable tolerance window is 5–15 minutes depending on how time-sensitive your fulfilment is. Past that window, the payment is either succeeded (and the signals were lost) or never happened.
What NOT to do
- Don't fulfil from the redirect alone. It is not authoritative — it can be reloaded, shared, or arrive after a payment that never completed. Read the status from
GET /v1/sessions/{id}, and let the webhook be what authorises fulfilment. - Don't read the outcome from a query parameter. Nothing on the return URL is a fact about money; it all arrives through the buyer's browser. See Handle the return.
- Don't dedupe on
transaction_idalone if you also receivepayment_intent.*events. A single session can produce a sequence of related events, each with its owntransaction_id; dedupe by the envelope's top-levelidfield (vp_evt_*, the documented dedupe key), or — if you need a per-event-type key — bydata.session_id+type.
A
5xx(or timeout) from your handler causes Von Payments to redeliver the same eventid, so your handler must be idempotent. A2xx(including200) acknowledges delivery and stops retries — return200for an already-processed or duplicate event.
Related
- Handle the return — confirming the payment when the buyer comes back
- Webhooks — signature verification for the webhook
- Retry behavior — when retries kick in