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.

The pattern​

Use the redirect to advance the buyer's UX immediately; before any irreversible fulfilment, confirm payment status server-side and guard idempotency, and treat the second signal as a no-op when it arrives.

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.

Two keys, and mixing them up double-ships: dedupe events on the envelope id, and key the order on a correlation id (an orders table keyed on the event id gets two rows for one payment). The order key must never be null — session_id, payment_intent_id and vp_tx_id are each nullable depending on how the payment was taken, and a UNIQUE column does not constrain NULLs — so take the first non-null of the three. And only the success family may claim an order: a refund carries no session_id, so a handler that claims on every event derives a fresh key for charge.refunded and ships the order again on a refund. Gate on event.type first:

// 1. Does this event fulfil anything? ONLY the success family claims an order.
// ⛔ Without this gate the handler also claims on `charge.refunded` and
// `dispute.created`. Those carry a DIFFERENT correlation id from the charge
// — a refund has no `session_id` at all — so step 3 sees a key it has never
// stored, inserts a second row, and you ship the order again on a refund.
// (`session.succeeded` carries no vp_tx_id; fulfil on `charge.succeeded`.)
const FULFILS = new Set(["charge.succeeded", "payment_intent.succeeded"]);
if (!FULFILS.has(event.type)) return res.status(200).json({ received: true });

// 2. Which id identifies the PAYMENT? All three are nullable, and WHICH one is
// null depends on the integration path, so take the first non-null.
// ⛔ A UNIQUE constraint does NOT constrain NULLs — SQL treats every NULL as
// distinct, so a null key inserts every time and the lock silently does
// nothing.
const orderKey =
event.data.session_id ??
event.data.payment_intent_id ??
event.data.vp_tx_id;
if (!orderKey) throw new Error("no correlation id on " + event.type + " " + event.id);

// 3. Have I already processed this DELIVERY? Keyed on the envelope id.
// ⚠ Everything that can throw belongs ABOVE this line. Once this row is
// committed, a later 5xx gets the event redelivered, matched here as a
// duplicate and acknowledged — the work never runs and nothing alerts.
const seen = await db.query(
`INSERT INTO processed_events (event_id) VALUES ($1) ON CONFLICT DO NOTHING RETURNING 1`,
[event.id],
);
if (seen.rowCount === 0) return res.status(200).json({ received: true }); // redelivery

// 4. Have I already fulfilled this PAYMENT?
try {
await db.orders.insert({ order_key: orderKey, state: "claimed" });
} catch (err) {
if (!isUniqueViolation(err)) throw err;
const existing = await db.orders.findOne({ order_key: orderKey });
if (existing?.state === "fulfilled") return res.status(200).json({ received: true });
// Claimed but never finished — fall through and complete it.
}

// ... fulfil, then mark the order `fulfilled`.

Handle the return shows the same two-phase claim, callable from the webhook and the return page alike.

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.

One more dedupe trap: data.session_id + type is not a key either — a charge refunded in two parts emits charge.refunded twice with the same session and type, and that key drops the second refund from your ledger. A 5xx or timeout from your handler redelivers the same event id; return 200 for an already-processed event.