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.
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 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 transaction_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.*` is not subscribable; 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.transaction_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:
- 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.
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.
Related
- Handle the return — confirming the payment when the buyer comes back
- Webhooks — signature verification for the webhook
- Retry behavior — when retries kick in