Reconciliation — redirect vs. webhook
Von Payments sends two signals for every completed payment:
| Signal | Type | Delivered | Authority |
|---|---|---|---|
| Signed return URL | Synchronous redirect | The moment the buyer returns to your successUrl | Signed by Von Payments; you verify it with your session signing secret (ss_test_* / ss_live_*) — cryptographically authoritative once you verify it |
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 |
Both are signed. Both are sufficient on their 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 two secrets are distinct. You verify the redirect with your session signing secret (
ss_test_*/ss_live_*) — see Handle the return. You verify the webhook with your endpoint'swhsec_*signing secret. Neither is your API key (vp_sk_*/vp_pk_*).
This page covers what to do when one arrives and the other doesn't.
The pattern
Treat whichever signed signal arrives first as authoritative. Mark the order paid, fulfil, and remember the sessionId. When the second signal arrives, look it up and treat it as a no-op.
Redirect arrived but webhook hasn't
The most common case. The buyer returned to your successUrl, your signature verifier returned valid, and you marked the order paid. Then nothing — minutes pass without a charge.succeeded POST.
Action: none required. The signed redirect is authoritative.
Optional belt-and-braces check: if your fulfilment is high-value or irreversible (digital goods, shipping label printed), confirm server-side state by calling:
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" is the same authority as the redirect, fetched fresh from the server. 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 require BOTH signals before fulfilling. Either one is authoritative on its own. Waiting for the second adds latency and increases the rare-failure surface for no security benefit.
- Don't trust the redirect without verifying the signature. The
sig=query param is what makes the redirect authoritative. An unverified redirect is just a URL anyone could fabricate. - 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 — signature verification for the redirect
- Webhooks — signature verification for the webhook
- Retry behavior — when retries kick in