Skip to main content

Reconciliation — redirect vs. webhook

Von Payments sends two signals for every completed payment:

SignalTypeDeliveredAuthority
Return redirectSynchronous redirectThe moment the buyer returns to your successUrlA 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 webhookDurable async POSTUsually within a few seconds; can be delayed during outagesSigned 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 client or redirect error does NOT mean the payment failed

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:

  1. Poll GET /v1/sessions/{sessionId} to retrieve server-side state.
  2. If status: "succeeded" — both signals failed in delivery but the payment cleared. Fulfil and log the dual-failure for monitoring.
  3. If status: "pending" — the buyer hasn't completed. Don't fulfil.
  4. 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_id alone if you also receive payment_intent.* events. A single session can produce a sequence of related events, each with its own transaction_id; dedupe by the envelope's top-level id field (vp_evt_*, the documented dedupe key), or — if you need a per-event-type key — by data.session_id + type.

A 5xx (or timeout) from your handler causes Von Payments to redeliver the same event id, so your handler must be idempotent. A 2xx (including 200) acknowledges delivery and stops retries — return 200 for an already-processed or duplicate event.