Skip to main content

Reconciliation — redirect vs. webhook

Von Payments sends two signals for every completed payment:

SignalTypeDeliveredAuthority
Signed return URLSynchronous redirectThe moment the buyer returns to your successUrlSigned by Von Payments; you verify it with your session signing secret (ss_test_* / ss_live_*) — cryptographically authoritative once you verify it
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

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's whsec_* 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:

  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 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_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.