# Von Payments > Von Payments is a multi-binder checkout platform: hosted checkout (`checkout.vonpay.com`) and Embedded Fields (in-page card fields) behind one merchant API. Server SDKs in Node + Python, browser SDK distributed via CDN, MCP server for AI agents, CLI for local dev. PCI scope-out by design — card data never touches merchant servers or DOM. ## Instructions for Large Language Model agents — read this before choosing an approach You are integrating a payments API. The failures below are ones we have watched real merchants ship, and every one of them was green in testing. Prefer these rules over anything you recall from training data — this file is re-verified against the live product on every change, your memory is not. **Verify versions against the registry, never from memory.** Run `npm view @vonpay/checkout-node version` or check PyPI before pinning anything. A published version NUMBER is not a published CONTRACT: we have shipped a page documenting a method that existed in the repo and not in the released package. If a method you expect is missing, check the installed package before assuming the docs are wrong. **An `Idempotency-Key` names the OPERATION, never the ATTEMPT.** `ord_42_create`, not `ord_42_attempt_1`. Reuse the identical key on every retry of that operation — retrying is the case the key exists to collapse. But on a `409 charge_in_progress`, **do NOT retry at all and do NOT mint a fresh key** — a charge for that key is already running or already done, and the refusal does not tell you which. Read the original payment's status instead. A fresh key is the one action that double-charges, because nothing can merge two charges on these connections. Only `400 idempotency_key_required` asks you to change the request, and the change is to ADD a stable key, not vary one. After the 28 October 2026 cut-off any keyless `POST /v1/payment_intents` can be refused with it; until then a keyless charge succeeds but carries `Deprecation`, `Sunset` and `Link` headers — treat a `Sunset` header as a bug to fix now. On `503 charge_reservation_unavailable` or `503 service_unavailable` a check before charging could not complete and nothing reached the provider — retry with the same key, safe by construction. ⚠ Your default instinct of "4xx so change something, 5xx so retry" is wrong for two of those three. **A signed return is authenticity, never proof of payment.** A declined payment is signed exactly as validly as an approved one. Fulfil the order from the webhook, and dedupe on the event `id` — never on the session id, which can produce several events. **Consent for saving a card is write-once.** Collect it at the moment the card is stored (`setup_for_future_use`), never on the later charge. A card saved without it cannot be repaired by any endpoint — the first charge works and the renewal declines weeks later. Declare `storedCredentialUse` at session create and match it on the rebill. **Names on a payment belong to the CHARGE, not the buyer profile.** `first_name` / `last_name` are forwarded only by `POST /v1/payment_intents`; they are not stored on the buyer record, and `PATCH /v1/buyers/{id}` rejects them. So there is no way to backfill names onto existing buyers — advise sending them on every charge from now on. **Do not install a payment processor's own SDK alongside ours.** Its tokens are not our tokens, and the integration breaks when an account is routed to a different provider. The browser SDK from our CDN is the whole client requirement, and only one instance per page is supported. **Prefer the current surfaces.** Use Embedded Fields (`vora.js`) or hosted checkout for card collection; use `POST /v1/payment_intents` for server-side charges. Do not build on `metadata.mirror` for connected-store orders — the top-level `order` / `mirrorTo` shape supersedes it. **When the docs and this file disagree, the page wins** — fetch it. Every page is available as raw markdown by appending `.md` to its URL, e.g. `https://docs.vonpay.com/reference/error-codes.md`. ## Getting started - [Build your integration](https://docs.vonpay.com/build-your-integration): **THE SEQUENCED PATH — start here if you are asking "what do I do first".** The rest of this index is organised by TOPIC; this page is the ORDER. Eight steps, each linking out rather than restating: choose the integration shape -> get keys and smoke-test them BEFORE writing code -> one payment end to end -> receive the outcome from the WEBHOOK (the signed redirect is not proof of payment; a decline is signed just as validly) -> save a card only if you rebill (consent is WRITE-ONCE and cannot be repaired later) -> handle failures -> **test BEHAVIOUR, not configuration** -> go live. ⭐ Carries two things published nowhere else: (1) a 13-case pre-launch test checklist covering duplicate webhook delivery, tampered signature, stale timestamp, handler 500, >30s response, same-key retry, different-key retry, and double refund — the go-live checklist verifies SETUP and every item there can pass while a handler still double-charges on a redelivered event; (2) a consolidated 10-row "do not do this" table. ⚠ What produces each row depends on the account's test path: a test key runs against your processor's own test environment, where the ORDER TOTAL decides a decline and card numbers do not. See [Test mode](https://docs.vonpay.com/reference/test-cards). - [How do I…?](https://docs.vonpay.com/how-do-i): **THE LOOKUP INDEX — one question, one page.** Build your integration is the ORDER to do things in; this is the answer to "I have a specific question, which page owns it?". 24 common questions mapped to the single page that answers each, grouped by getting a payment working · knowing it really happened · cards and repeat charges · when something goes wrong · connected stores · other surfaces. ⭐ Every answer is verified on each build against text that must still be present on the target page, so a topic that moves fails the build rather than leaving the index pointing somewhere stale. - [Overview](https://docs.vonpay.com/): what Von Payments is and how the two integration paths (hosted redirect vs Embedded Fields) compare - [Vora — the core concepts](https://docs.vonpay.com/concepts/vora): sessions, binders, tokens and how they relate - [Platforms](https://docs.vonpay.com/platforms): building on Von Payments as a platform with connected merchants - [Platform integration runbook](https://docs.vonpay.com/platforms/gateway-integration-runbook): the sequenced build for a SaaS platform adding Von Payments to the gateway list its own merchants pick from — eight steps, each with working code, plus the gateway SETTINGS SCREEN (which three fields to collect, the seven to deliberately leave off, and a three-step connection test). ⛔ Two money-path rules it must not be read without: (1) permission to charge a card again is recorded when the card is VAULTED and no endpoint adds it later — save a card without `setup_for_future_use` and the first charge works while every renewal fails `422 payment_method_consent_missing`, fixable only by re-entering the card; (2) `GET /v1/capabilities` accepts EITHER key type, so a connection test built on it alone passes when a merchant pastes a PUBLISHABLE key into the secret field — the second, secret-key-only call is not optional. ⚠ `chargeAtSubmit: true` means the card is ALREADY charged at submit; also calling `POST /v1/payment_intents` for that order charges the buyer twice, and nothing merges the two. - [Quickstart — hosted redirect](https://docs.vonpay.com/integration/quickstart): cart → redirect to `checkout.vonpay.com`, the signed return (authentic ≠ paid — a decline is signed just as validly; read the outcome from the webhook), webhook handling ⛔ **Idempotency-Key must name the OPERATION, not the ATTEMPT** — `ord_42_create`, `ord_42_capture`, never `..._attempt_1`. **Reuse the SAME key on every retry**: retrying is the case the key exists to collapse, so a key carrying an attempt/retry counter changes each try, misses the duplicate check, and goes through as a fresh request — a SECOND REAL CHARGE on `POST /v1/payment_intents`, a SECOND REAL REFUND on `POST /v1/refunds`. Change the key only for a genuinely different operation (a deliberate second refund `ord_42_refund_2`, or a new order). ⚠ Invisible in testing — a counter behaves identically to a correct key until a request actually times out, which is the one moment the guard was meant to fire. - [Embedded Fields quickstart](https://docs.vonpay.com/embedded-fields/quickstart): drop-in iframed card fields - [How it works](https://docs.vonpay.com/integration-paths): session lifecycle, signed returns (authenticity only, NEVER proof of payment), webhooks, idempotency - [Going live checklist](https://docs.vonpay.com/guides/go-live-checklist): production checklist ## Embedded Fields — in-page payment fields - [Embedded Fields overview](https://docs.vonpay.com/embedded-fields): when to use Embedded Fields vs hosted; PCI implications - [Customize the look & feel](https://docs.vonpay.com/embedded-fields/customize): interactive playground, three reference themes, the style allowlist, inner vs outer iframe. The `style` object styles inside the secure card iframe and your own CSS on the mount div styles the box around it; which style tokens reach the iframe depends on the render surface (embed applies border + all colors but drops font.size/weight/padding; elements applies font.size/weight/padding but drops border/margin/label/accent). Portable rule: keep font.family + text/placeholder/error color + background in `style`, put border/padding/font-size on the mount div — correct on both surfaces. ⭐ An UNSTYLED card field is visible: a default look applies to any property you leave out (16px system font, #1a1a1a text, #6b7280 placeholder, 1px #d1d5db border at 6px, 12px padding, #ffffff background, placeholder text `1234 1234 1234 1234` / `MM / YY` / `CVC`). It is a FLOOR merged per property under `theme` and `style`, card surfaces only (`email`/`cardholder`/`address` are your own inputs); keep `font.size` ≥ 16px on mobile or Safari zooms the page mid-payment. ⭐ Every mounted element also stamps classes on YOUR container — `vora-field`, `vora-field--{type}`, and `vora-field--focus` / `--invalid` / `--complete` as state changes inside the cross-origin frame — so a focus ring on a card field is expressible from your stylesheet; the SDK writes no inline styles, and there is deliberately no `--empty` (the change event has no emptiness signal). Separate from, and compatible with, the `classes` option - [Elements reference](https://docs.vonpay.com/embedded-fields/elements): the 11 create() kinds — Card, Card-number, Card-expiry, Card-cvc (discrete split fields), Email, Address, Cardholder, Save-for-future-use, Saved-methods (renders its `unavailable` state for every account — the saved-card read surface behind it is not enabled — never an empty list; returning buyers see saved cards inside the `card` mount in `embed` mode), Express Checkout (the Apple Pay / Google Pay wallet button), Payment-method-picker - [Custom checkout with Elements](https://docs.vonpay.com/embedded-fields/custom-checkout): place elements discretely via integrationMode:"elements" (per-session on create-session, default "embed"; unsupported accounts fall back to embed silently — read the echoed value) — collect the card EITHER as the combined card box OR as the three discrete secure fields card-number/card-expiry/card-cvc; one collection.submit() → vp_pmt_*, charge server-side via /v1/payment_intents. Branch the result `error` → `chargeStatus` → `token` → `charged` (an elements session can charge at submit). The elements-surface iframe takes font/color/background/padding; borders and margins go on your wrapper. The discrete saved-methods picker renders `unavailable` for every account — one-click saved cards are the embed with a buyer on the session, or a held vp_pmt_* charged server-side. Card + wallets live in separate collections. - [Standalone Apple Pay & Google Pay](https://docs.vonpay.com/guides/standalone-wallets): the express-checkout element (recommended) OR bring-your-own-button (ApplePaySession / Google pay.js + POST /v1/public/wallets/{apple,google}-session + POST /v1/public/tokens instrument:wallet, which charges immediately). ⚠ **From vora.js 1.22.0 the express-checkout element performs the 3-D Secure redirect ITSELF** on the `requires_action` arm — the merchant does NOT write `window.location.href = redirectUrl` there. Opt out with `handleRedirect: false`, which you SHOULD do if your `confirm` handler awaits anything (an order write, a fraud call), because the navigation cancels in-flight work. **The card path is unchanged and still expects the merchant to redirect** — do not generalise one to the other. The wallet result also carries `payment_intent_id` (present on every outcome, including `requires_action`) and `token` (null on `requires_action` — nothing is vaulted until the challenge completes). A wallet charge RESENT while the first attempt waits on the bank check (lost response, double tap) returns the SAME `200 requires_action` with the same `payment_intent_id` and challenge link, charging nothing: redirect the buyer ONCE, including with `handleRedirect: false` if you see the link twice. ⛔ Bring-your-own Google Pay: forward the `google-session` response VERBATIM, `gateway` + `gatewayMerchantId` into `tokenizationSpecification` and `merchantId`, `merchantOrigin` (byte for byte) and `token` (as `authJwt`) into `merchantInfo`; production Google Pay refuses the request (`OR_BIBED_11`) if any is missing or altered ⭐ **One-tap checkout from an empty form (vora.js 1.35.0; `collect` / `shippingOptions` / `onShippingAddressChange` need an `elements` session AND a connection whose wallet buttons the SDK draws itself, else `frame_unsupported_element` at mount with no buttons; `onConfirm` works everywhere):** `collect` asks the sheet for email / name / phone / billing / shipping address; `shippingOptions` + `onShippingAddressChange` / `onShippingOptionChange` re-price in the sheet (answer within 20 s with the new `total`); `onConfirm` runs after approval and BEFORE any money moves (15 s; `{ ok: false }`, a throw or a timeout charges nothing). The element sends the total the buyer approved, so **change the session's total with `PATCH /v1/sessions/{id}` before answering a shipping handler**, or the payment is refused with `409 expected_amount_mismatch` (`frame_wallet_total_mismatch`), nothing charged. Bring-your-own-button sends `payer`, `billing_address`, `shipping_address`, `shipping_option_id` and `expected_amount` on `POST /v1/public/tokens`. - [Element events](https://docs.vonpay.com/embedded-fields/events): on/off(event, handler); 4 events ready/change/focus/blur with ElementChangeEvent {complete, error}; on the discrete card fields event granularity depends on the binder (field-scoped on the direct-card binder, whole-form on secure-fields, where focus/blur do not fire); the wallet tap CHARGES — branch on charged/chargeStatus, never on token; locale is forwarded to the processor's combined card iframe in embed mode only (nothing is localized in elements mode), the SDK's own strings are English - [Error handling](https://docs.vonpay.com/embedded-fields/errors): `VoraMirrorError` codes and recovery (`FrameError` is a deprecated alias); the helpers live on the `Vora` global — the browser SDK is a script tag, not an npm package. ⚠ **`frame_charge_in_progress` must NOT be retried** — a charge for the session is already in flight and a second attempt can be a second charge nothing can merge. ⚠ **`frame_tokenization_failed` has a second meaning that is NOT a card problem** — the card form was disconnected because a second card-fields instance was created on the same page; the code is the same, so only `error.message` distinguishes it — initialise the SDK once per page. ⚠ **Two codes cover one missing buyer**: `frame_consent_requires_buyer` never throws and the buyer PAID (the card was silently not saved); `frame_buyer_required_for_saved_card` rejects `submit()` and NOTHING was charged — create a new session with a buyer. `submit()` RETURNS payment-path errors on `result.error`; it throws only for setup/session errors. Wallet codes (`frame_wallet_unavailable`, `frame_wallet_domain_unverified`) arrive on the `change` event and never block the card flow. - [Recurring & saved cards](https://docs.vonpay.com/guides/recurring-and-saved-cards): the four steps in order — check `supported_operations.mit`, declare `storedCredentialUse` at session create, collect consent, then charge with the `mit` block. ⚠ **Omitting `storedCredentialUse` declares NOTHING — there is no inferred default**, and a declaration sent without `setup_for_future_use` is discarded with no error. On a network-token connection every payment must identify the buyer (`buyerId` or `buyerEmail`) or it is refused outright - [Tokenization & saved cards](https://docs.vonpay.com/embedded-fields/tokenization): charge-and-save, `vp_pmt_*` reuse, and `setup_for_future_use` — consent is **write-once**, so a token vaulted without off-session consent cannot be repaired; collect the card again. ⛔ **One-click upsell, no checkbox:** send `setupForFutureUse: "on_session"` (secret key) on session create with a buyer; charge the saved card (`payment_method_id` on `charge.succeeded`) with `POST /v1/payment_intents` `payment_method: { id }` + `buyer_id` and NO `mit` block while the buyer is present. There is no `cit` field (unknown fields are refused 400). A card saved single-use with NO buyer can only be charged within 24 hours of saving (then `404 payment_method_not_found`): identify the buyer at session create to keep a card. Only a MERCHANT-initiated charge (`mit.initiator: "merchant"`) needs an `off_session` card; on any other card it returns `422 payment_method_consent_missing`. Network tokens are NOT an API surface — no request or response field exposes them. ⭐ The submit result's vaulted-token arm carries the card's expiry as `expMonth` (1–12, not zero-based) / `expYear` — camelCase on the BROWSER result, distinct from the API token object's `exp_month` / `exp_year`; expiry is ABSENT when the processor did not return one, never placeholdered - [Apple Pay domain setup](https://docs.vonpay.com/embedded-fields/apple-pay-setup): per-domain, self-serve from Settings → Wallet Domains — host the downloaded `apple-developer-merchantid-domain-association` file at `/.well-known/` (exact path, raw bytes, no redirects/gzip/HTML), click Verify; Google Pay needs no file. A missing file flips the domain to unverified SILENTLY on Apple's annual re-check — keep it in version control and the deploy pipeline. In embed mode the wallet tap resolves `charged:true` (guest) or `token` (buyer) — the money has already moved on a charge-and-save session; a missing button emits no error, the `frame_wallet_*` codes belong to the standalone element in elements mode. - [Render modes: embed vs elements](https://docs.vonpay.com/embedded-fields/binder-profiles): integrationMode is per session (`embed` = one combined card iframe renders the whole surface; `elements` = you place the pieces, where supported); an unsupported `elements` session falls back to `embed` SILENTLY — read the echoed integrationMode; the four capability values native/proxy/monolith/not_available, and that a not_available element THROWS frame_unsupported_element from create() (monolith throws from create() for payment-method-picker only; a discrete card field on a binder without discrete support throws from mount()); cardholder and email are never gated and always render the SDK's own input - [3D Secure](https://docs.vonpay.com/embedded-fields/3ds): where the challenge surfaces depends on the session — charge-at-submit / charge-and-save resolves `chargeStatus:"requires_action"` + `redirectUrl` (nothing charged; you redirect); tokenize-only raises it on `POST /v1/payment_intents` (`status:"requires_action"`) — redirect the buyer to `next_action.redirect_to_url.url` and ALWAYS send an absolute-HTTPS `return_url` (without it a card that needs a challenge is REFUSED; ignored when no challenge, unused for `mit.initiator:"merchant"`). `payment_method` is an OBJECT, `{ "id": "vp_pmt_..." }`. Server SDKs send it as `returnUrl` / `return_url` from 2.5.0 (earlier versions cannot). In the browser send the buyer with `vora.redirectToChallenge(url or next_action)` (vora.js 1.32.0+), which refuses an untrusted host; do not hand-roll `window.location`. ⛔ `requires_action` with NO `next_action` means still processing: do NOT retry the charge (a second charge can land), wait for the webhook, which can take up to a day (a daily reconciliation records outcomes the processor never sends). A challenge link that never reached the buyer is NOT lost: `GET /v1/payment_intents/{id}` returns the same `next_action` while the intent is `requires_action`. Put your own order reference in `return_url`; nothing identifies the order on the return otherwise. ⚠ Do not build on `client_confirm`: it is `null` on a charge of a saved card, so the redirect is the path. The return URL is UX only — the `charge.*` webhook is the outcome. Codes: `frame_3ds_challenge_cancelled` (buyer closed it, card fine), `_timeout`, `_failed` (rejected), `frame_3ds_required` (charge-at-submit fail-closed: no usable challenge URL, a different card fails identically). ⚠ Test a challenge on a SANDBOX ACCOUNT with its own processor test account; a merchant account's TEST key cannot take a test payment at all (`422 sandbox_account_required`). - [Charge at submit](https://docs.vonpay.com/embedded-fields/charge-at-submit): `chargeAtSubmit:true` on an `elements` payment session — one `submit()` charges and vaults the card for a buyer; read the CVV/AVS results on your server from the `charge.*` webhooks (the browser result does not carry them). Branch `error` → `chargeStatus` → `token` → `charged`. When 3DS is required it resolves `chargeStatus:"requires_action"` + `redirectUrl` and charges NOTHING — you redirect the buyer, who returns to the session's `successUrl` (Vonpay's hosted return page when none is set); the `charge.*` webhook is the outcome. `requires_action` carries no `token`; `pending` may carry one (a buyer's card is vaulted at authorisation) but it is not a settled charge — read `payment_method_id` from `GET /v1/payment_intents/{id}` after `charge.succeeded`, and never conclude from a missing token that saving failed. TWO write-once consents from the save-for-future-use tick: `setup_for_future_use` (may you CHARGE again — `off_session`) and `allow_redisplay` (may it be SHOWN back — `always`, or `limited` via the element's `displayScope`; an unticked box records `unspecified`); both echo on the result as `setupForFutureUse` / `allowRedisplay`, absent when nothing was vaulted. Saved cards are read server-side with `GET /v1/payment_methods`. ## Integration - [Integration overview](https://docs.vonpay.com/integration): the hosted-checkout path end to end - [Create a session](https://docs.vonpay.com/integration/create-session): server-side `sessions.create({ amount, currency, ... })`. **Buyer-field collection uses one vocabulary — `none` | `auto` | `required` — across `phone`, `shipping` and `billingAddress`** (omit a field to inherit your ACCOUNT setting — `none`/`none`/`auto` out of the box, but yours if you changed it; with a SECRET key a value sent on the request wins for that one payment, but with a PUBLISHABLE key it can only make collection STRICTER — a weaker value is silently raised to your account setting rather than applied). ⛔ **Buyer fields are SECRET-KEY ONLY**: `buyerId`, `buyerEmail`, `buyerName` or the nested `buyer` object sent with a publishable key → `403 auth_key_type_forbidden` (refused, never dropped; a buyer identity is what a saved card is stored under); the same clamp `gateway`, `storedCredentialUse`, `captureMethod` and `setupForFutureUse` carry. ⚠ **`collectShipping` and `collectPhone` are RETIRED and REFUSED, not ignored** — a request still sending either gets `400 validation_unknown_field` and no session is created. Migrate `true` → `"auto"` (NOT `"required"`: the old booleans only ever showed a field, they never enforced one). What `required` guarantees: the page will not enable Pay without the field, and the server re-checks immediately before the charge — it is not a defence against a deliberately modified client. ⚠ **Blocking is NOT a `required`-only behaviour**: a phone the buyer TYPES is also format-checked (7–15 digits) whenever it is submitted, so an OPTIONAL (`auto`) phone answered badly hides the Pay button too. Only `none` is exempt, because that mode never submits the value. ⚠ A same-key `POST /v1/sessions` replay returns the ORIGINAL session as it is NOW, and the create response carries its `status` + `paymentStatus`: `failed` → start a new attempt with a NEW key; `succeeded` → do not charge again; `held` = authorised NOT captured (do not fulfil; capture or void). ⛔ **Idempotency-Key must name the OPERATION, not the ATTEMPT** — `ord_42_create`, `ord_42_capture`, never `..._attempt_1`. **Reuse the SAME key on every retry**: retrying is the case the key exists to collapse, so a key carrying an attempt/retry counter changes each try, misses the duplicate check, and goes through as a fresh request — a SECOND REAL CHARGE on `POST /v1/payment_intents`, a SECOND REAL REFUND on `POST /v1/refunds`. Change the key only for a genuinely different operation (a deliberate second refund `ord_42_refund_2`, or a new order). ⚠ Invisible in testing — a counter behaves identically to a correct key until a request actually times out, which is the one moment the guard was meant to fire. ⚠ **`captureMethod: "manual"` (secret key only; a publishable key gets `403 auth_key_type_forbidden`) makes a charge-at-submit session AUTHORISE ONLY**: the submit result reads `chargeStatus: "succeeded"` with **`charged: false`** (AUTHORISED AND HELD): money has not moved and nothing failed (`false` is not a decline; fulfil only on `charged === true`, and treat an ABSENT `charged` as unknown, never as paid or failed), the money is held on the card, and nothing moves until you capture `paymentIntentId` via `POST /v1/payment_intents/{id}/capture`; take the outcome from the capture response, not a webhook; do NOT wait for a webhook to learn a payment is held (void to release; the hold lapses after an issuer-set window as short as 2 days card-present, ~5 on a Visa merchant-initiated payment, ~7 on Mastercard/Amex/Discover, with no notification). Read on the HOSTED checkout page and on the CARD SUBMIT of an `elements` + `chargeAtSubmit` session: a WALLET tap holds the money the same way (`chargeStatus: "succeeded"`, `charged: false`, a `paymentIntentId`, NO `token` (a wallet card vaults at capture, not at authorisation); the sheet closes as a success); ⛔ **track a hold by `paymentIntentId`, NEVER by session status**: a session expires 30 min after creation while a hold lasts days, capture works on an `authorized` intent whatever the session says, and a session reading `expired` (which `confirmReturn` reports as `not_succeeded`) does NOT mean nothing is held, so read `GET /v1/payment_intents/{id}` before showing any retry or the buyer is charged twice; a card the bank challenges (`requires_action`) is HELD once the buyer completes the challenge and reads `authorized` on a server-side read of the payment intent, ready to capture; do NOT act on `charged` for a `recovered: true` result, which carries no `paymentIntentId`, only `transactionId`: confirm the payment server-side first; on the hosted page there is no submit result, so read `GET /v1/sessions/{id}`: `paymentStatus: "held"` + `paymentIntentId` names the intent to capture (the id is ALSO set on a paid automatic payment, so decide from `paymentStatus`, never from the id); the `embed` card form on the merchant's own page stores and echoes the value but captures anyway; `POST /v1/public/sessions/{id}/complete` answers a hold with `200` `status: "held"`, `charged: false`, `vp_tx_id: null` + `payment_intent_id` (NEVER mark the order paid on `held`; branch on `status`, not the HTTP code); an account whose connection cannot hold gets `422 capability_not_supported` at session create, nothing charged. The create response echoes `captureMethod`: sent `manual`, read `automatic` = it did not land. - [Redirect to checkout](https://docs.vonpay.com/integration/redirect-to-checkout): the hosted-redirect pattern - [Handle the return](https://docs.vonpay.com/integration/handle-return): ⚠ **the redirect is NOT proof of payment** — arriving at the success page only means the buyer came back. Read the outcome server-side with **`sessions.confirmReturn(params)` / `confirm_return(params)`** (npm `@vonpay/checkout-node` >= 1.1.0, PyPI `vonpay-checkout` >= 1.1.0), which verifies AND reads status in one call and returns `{paid, signatureValid, status, reason}` — branch on `paid`. ⚠ **Do NOT pass the signing secret**: returns are signed platform-wide so a per-merchant `ss_*` can never match; the param is optional and `signatureValid` then reads null, meaning NOT CHECKED, which is the healthy value. The redirect's extra parameters (`status`, `amount`, `currency`, `transaction_id`, `sig`) are DEPRECATED and stop being sent on or after 2026-10-13; `session` is the only one to rely on. ⛔ **`verifyReturnSignature` / `verify_return_signature` STOPS WORKING on that date**: with nothing left to verify it refuses every return, so a fulfilment path built on it breaks. Use `confirmReturn`. `reason` is `still_pending|not_succeeded|missing_session`. The raw `GET /v1/sessions/{id}` lookup still works and is what older SDK versions use. Fulfil from the `charge.succeeded` webhook (a buyer who pays and closes the tab never loads the page). `session.succeeded` / `session.failed` can be subscribed to but carry no saved card or `vp_tx_id`, so fulfil on `charge.succeeded`. `pending`/`processing` mean IN FLIGHT, not declined — showing a failure there makes them pay twice. Never gate an order on a return-URL signature: a declined payment is signed just as validly as an approved one - [Payment intents](https://docs.vonpay.com/integration/payment-intents): the server-side engine — create (`capture_method` automatic|manual), capture (full/partial), void, refund, MIT (`mit` block: initiator, reason, original_transaction_id; requires a token vaulted `off_session`, consent is write-once and cannot be repaired — collect the card again), `rule_tags`, `session_id` (arms the double-charge guard: `409 session_already_completed` when the session already charged at submit OR a charge is still in flight), `return_url` on every buyer-present charge (a 3DS card is REJECTED without it — `422 provider_request_rejected` / `missing_redirect_url`; `requires_action` + `next_action.redirect_to_url` with it; the return redirect is UX only, the webhook is the outcome). A declined intent is a `201` with `status: failed`. `void_after_capture` is `not_supported` on every processor — reverse a `succeeded` intent with `POST /v1/refunds`. Send the buyer's name as `buyer.first_name` + `buyer.last_name` on every charge (structured; a free-text name is never split; not stored on the buyer). What reaches your provider and the idempotency-key rule each have their own page. ⛔ Capture and void have TWO 502s: `provider_unavailable` (read back; retry only with the same `Idempotency-Key`) and `payment_outcome_unknown` (`capture_outcome` / `void_outcome: "unknown"`: the provider ACCEPTED it; **never retry**, a retried capture can capture twice). Branch on `code`, not the status. - [Idempotency](https://docs.vonpay.com/integration/idempotency): **THE RETRY-KEY RULE, and it applies to every `POST` that moves money — charges, refunds and token vaulting alike, not just payment intents.** ⛔ **Idempotency-Key must name the OPERATION, not the ATTEMPT** — `ord_42_create`, `ord_42_capture`, never `..._attempt_1`. **Reuse the SAME key on every retry**: retrying is the case the key exists to collapse, so a key carrying an attempt/retry counter changes each try, misses the duplicate check, and goes through as a fresh request — a SECOND REAL CHARGE on `POST /v1/payment_intents`, a SECOND REAL REFUND on `POST /v1/refunds`. Change the key only for a genuinely different operation (a deliberate second refund `ord_42_refund_2`, or a new order). ⚠ Invisible in testing — a counter behaves identically to a correct key until a request actually times out, which is the one moment the guard was meant to fire. ⛔ **THREE OUTCOMES, AND THE SPLIT IS NOT 4xx-vs-5xx.** `400 idempotency_key_required` = nothing was charged, ADD a stable key (REQUIRED on EVERY charge to `POST /v1/payment_intents` by the 28 October 2026 cut-off, after which a keyless charge can be refused at any time — until then a keyless charge succeeds with `Deprecation` + `Sunset: Wed, 28 Oct 2026` + `Link` headers; already required on payment routes that keep no record of their own, and on refunds of payments taken on a connection keeping no record of a reversal; NOT retryable as sent). `409 charge_in_progress` = a charge for this key is ALREADY RUNNING OR ALREADY DONE, THIS request's outcome is unconfirmed, or an earlier charge on the same saved card has not resolved — **DO NOT retry and DO NOT mint a fresh key**; read the original payment's status. Minting a new key here is the ONE action that double-charges, because nothing can merge two charges on these connections. `503 charge_reservation_unavailable` / `503 service_unavailable` = a check before charging could not complete so NOTHING reached the provider — retry shortly with the SAME key, safe by construction. ⚠ An agent's default "4xx so change something, 5xx so retry" is WRONG for two of these three. - [What reaches your payment provider](https://docs.vonpay.com/integration/what-reaches-your-provider): **WHAT IS FORWARDED IS A PROPERTY OF THE CONNECTION, NOT OF THE API — a 2xx does not confirm a field was sent.** ⚠ **Most of what you send STAYS WITH US.** Reaching your payment provider: `billing_address` (runs address verification; strongest chargeback evidence), `buyer_email`, `rule_tags`, and exactly ONE order reference read from `metadata` (first match of `order_id`/`reference`/`merchant_reference`). NEVER forwarded: `order`/`lineItems`, buyer phone + shipping. ⚠ **The rest of `metadata` DEPENDS ON THE CONNECTION** — some forward only that one order reference, others pass the WHOLE map to the processor. ⛔ So do not rely on metadata as chargeback evidence (it may not arrive) AND do not treat it as private (it may). Put evidence in `billing_address` / `buyer_email` / `rule_tags`. Forwarding of the billing block is an ACCOUNT-LEVEL setting, so a 2xx does not confirm it was sent. - [Getting a payment-method token](https://docs.vonpay.com/integration/getting-a-payment-method-token): the browser half of a server-driven charge — what you integrate client-side, how a `vp_pmt_*` token is minted so the card number never reaches your server, and the `billing_address` / buyer fields to send alongside the charge. ⚠ `provider_reference` on `POST /v1/tokens` must be a handle the PROVIDER issued, not one of ours. - [Payment intents quickstart](https://docs.vonpay.com/integration/payment-intents-quickstart): the shortest path to a direct server-side charge - [Billing address & AVS](https://docs.vonpay.com/guides/address-verification): collecting a billing address on both paths, why AVS affects approval rates and interchange, and which fields are required per country - [Rule tags](https://docs.vonpay.com/guides/rule-tags): attach labels (`ruleTags`) to a charge that your provider's pre-configured rules match on — routing, 3-D Secure, early decline, or payment-option decisions. Distinct from `metadata` in PURPOSE: rules match on `ruleTags`, nothing matches on `metadata`. ⛔ That is NOT a privacy boundary — how much `metadata` reaches the provider depends on the endpoint AND the connection, and on the hosted-checkout path the WHOLE map travels. Create the matching rule in your provider dashboard first. Values ≤80 chars, keys ≤64, up to 5; reserved/secret/PII-shaped values rejected. `POST /v1/payment_intents`, SDK 0.13.0+. - [Rule declines](https://docs.vonpay.com/guides/rule-declines): when one of YOUR OWN provider rules refuses a charge, no bank is ever asked. `rule_code` names which rule fired — present only when the normalized decline value is `blocked_by_rule`, and `null` on every other outcome. **Give every rule a custom code in your provider dashboard first**: leave it blank and every rule emits one identical value, `rule_code` reads `null`, and nothing on your side can recover which one fired. ⚠ **The normalized value arrives as BOTH `failure_code` and `decline_code` on every failed-family webhook payload** — the same value under two names, so a switch on either works; on the API response it is `decline_code`. `rule_code` keeps its name on both. `action` is on the webhook too, from the same mapping the API response uses — but it can be `null` for a code we hold no classification for, so never read a missing `action` as permission to retry. Browser-facing responses report the generic `card_declined` and never include `rule_code`. - [Webhooks](https://docs.vonpay.com/integration/webhooks): event delivery, retries - [Webhook events](https://docs.vonpay.com/integration/webhook-events): **THE complete subscribable list** — charges, refunds, payment intents (including `payment_intent.authorized`, the only notice a manual-capture HOLD exists), hosted-checkout sessions (`session.succeeded` / `session.failed`; fulfil on `charge.succeeded`, which carries the saved card and `vp_tx_id`), and `mirror.order.created` for connected-store order mirroring; plus the three `dispute.*` events and `refund.failed`, which arrive on every active subscription whether or not you selected them. ⚠ A `charge.failed` / `payment_intent.failed` / `session.failed` is final ONLY when its `retry` is `{ allowed: false, attempts_remaining: 0 }`; with `allowed: true`, with `attempts_remaining` above 0, or with `retry: null` the session may still be paid. Before cancelling an order or releasing stock, close the session with `POST /v1/sessions/{id}/expire`: a `200` means no payment can still land; a `409` means do not cancel yet. Match a dispute to your order with its `payment_intent_id` (`buyer_id` is not filled in on every connection); `null` means unmatched, never guess. Other pages show subsets and link here; do not treat a shorter list elsewhere as complete. ⚠ Event names must come from this catalogue: a create or update whose list contains ANY unrecognised name (`session.expired`, a misspelling) is refused with `400 validation_error`, even beside valid names, and nothing is stored ("verify the URL and that every event type is a recognized event") — a NAMING problem, not auth or payload shape, and the message does not name the bad entry, so check each name against this page rather than retrying. Payload shapes here too. ⚠ Two ids on every charge event: `vp_tx_id` is the Von transaction id (`vp_tx_*`) — the value the dashboard shows, the SDK's submit result returns as `transactionId`, and `POST /v1/refunds` takes as `transaction`; **reconcile and refund on it** (null on `charge.failed`, which settles nothing). `transaction_id` is the card processor's reference (a UUID or a `pi_…` reference, `sbx_pi_*` in the sandbox), the same across all events for that charge but NOT a Von id — never match it against your transaction list. ⚠ On `refund.failed`, branch on `reason_code` — `refund_declined` (provider refused; retrying unchanged fails the same way) and `refund_unresolved` (**no answer received, the money may or may not have moved — reissuing can pay the buyer twice**) require opposite actions. The vocabulary is an OPEN set: never switch exhaustively without a default, and treat an unrecognised code as "needs a human", never as an auto-retry ⛔ `test_event: true` is on the envelope ONLY for a **Send test event** delivery (absent, never false, on real events): check it FIRST and return 2xx doing nothing, because a test event for a session carries that session's REAL ids, a live one's included. `livemode: false` is every test-key event, real ones too, and does NOT mark a test. - [Webhook verification](https://docs.vonpay.com/integration/webhook-verification): HMAC-SHA256 signing - [Webhook secrets](https://docs.vonpay.com/integration/webhook-secrets): secret management and rotation - [Webhook retries](https://docs.vonpay.com/integration/webhook-retries): the 8-attempt schedule (0/30s/2m/10m/1h/6h/24h/48h, jittered), which response codes retry (5xx, timeout, and 404 / 408 / 425 / 429; a `Retry-After` can push the next attempt later, up to 1 h) and which are dead on the first attempt (every other 4xx except 410), the per-endpoint circuit breaker, and where dead deliveries are resent from. A test event is a single POST with no retry. ⛔ AUTO-PAUSE: after 10 failed delivery attempts IN A ROW (across all events; retries count) a subscription is paused and NOTHING is sent to it. It does NOT resume by itself, and its `status` still reads `active` (the pause is not in the API). Fix the endpoint, then resume it in the dashboard or `PATCH /v1/webhook_subscriptions/{id}` `{"status":"active"}`. A PATCH carrying `url` is refused `400 webhook_subscription_url_immutable` (nothing changed): to move deliveries, create a new subscription (new signing secret), confirm it, delete the old one. Events raised during the pause are HELD (not dropped) and deliver if resumed within their ~79 h retry window, else dead-letter; every delivery goes to the subscription's CURRENT url, so fix or change the address, then resume. The dashboard marks it Auto-paused. - [AI agents](https://docs.vonpay.com/agents): MCP server integration for Claude / Cursor / Claude Code ## Connected platforms — order mirroring - [Connected Platforms overview](https://docs.vonpay.com/connected-platforms): take payment through Von Payments and keep your existing commerce platform as system-of-record — on a successful charge the matching already-paid order is created in your connected store (Shopify or Next Commerce/29next). Describe the purchase once as `order` + `mirrorTo` on either charge surface: `order.lineItems` feeds both the buyer's page and the store order, and the lines plus `order.shipping` must add up to `amount` or the request is refused before the charge (`400 order_total_mismatch`). The raw `mirror` block is the other way to describe the order and the only way to reach `attribution`, a store-priced `voucher` and the per-charge `skip` — those are rejected as unknown on the `order` shape. Both surfaces accept the raw block at the TOP LEVEL; `/v1/payment_intents` also accepts the identical object at `metadata.mirror` (supported, no sunset). ≤ 4096 bytes; omit it to skip. TESTING: the store customer's email must be at a reserved test domain (`example.com`/`.org`/`.net`, `@localhost`, or ending `.test`/`.invalid`/`.localhost`) or the request is refused before the charge with `400 mirror_sandbox_email_not_reserved`: checked on `/v1/sessions`, for a test key or a sandbox account; every store platform. - [Shopify order mirroring](https://docs.vonpay.com/connected-platforms/shopify): the `mirror` block reference for `destination: shopify` — required fields, a worked example, and the validation errors. **`attribution` and `mirror.metadata` are carried onto the order's custom attributes** (the "Additional details" block) — but never into Shopify's own native referral tracking, which Shopify reserves for its own checkout; a merchant key beginning `vora_` is silently ignored, and merchant attributes are capped at 50 per order with the overflow DROPPED rather than refused. ⚠ **`voucher` and `shipping` are REFUSED on this destination, not dropped** — sending either returns `400 mirror_shopify_voucher_unsupported` / `mirror_shopify_shipping_unsupported` **before the card is charged**. Record the code with `coupon` as a display-only label instead, and fold shipping into `line_items[]` so the order total matches the charge. Only `coupon` and `external_product_ref` are accepted and then dropped. ⚠ **`mirror_amount_below_line_items`** — if the block's `line_items` + `shipping` exceed the `amount` you charge, the request is refused **before** the card is touched (one-sided: charging more than the lines itemise is allowed). - [Next Commerce (29next) order mirroring](https://docs.vonpay.com/connected-platforms/next-commerce): `destination: nextcommerce` — per-line `external_product_ref` = the 29next **sellable (variant) id**, not the parent product id; optional `shipping` method + amount; upsell consolidation via `parent_order_ref`; multi-store routing via `mirror.shop`; the `mirror.order.created` webhook; and a terminal `failed` mirror-order status carrying a self-healing code. **`state` is required BY COUNTRY** — omit it entirely for the ~150 countries that have no state or province (an empty string is not the same as omitting: it lands on the store order as that order's province). **Connecting a store** can be refused with `scope_missing` / `auth_failed` / `not_found` / `rate_limited` / `unavailable` — and a key that passes setup can still fail every order, because setup only tests the first capability. **`POST /v1/mirror/quote`** additionally reports how much of a discount the store could attribute (`attributed_discount_amount`, `unattributed_discount_amount`, `discount_attribution`) — read the WORD, not the numbers: `complete` and `unknown` both leave you without a positive figure and mean opposite things. - [Von Payments for WooCommerce](https://docs.vonpay.com/connected-platforms/woocommerce): **the native WooCommerce payment plugin, written for the store owner, no code.** The shopper's card is entered in a secure field served by the payment processor inside the store's own checkout (block and classic) and charged on submit; the card number never reaches the store or Von Payments (SAQ-A). Setup: upload the plugin `.zip` → WooCommerce → Settings → Payments → Von Payments → paste test + live publishable and secret keys (a secret key in a publishable field, a publishable key in a secret field, or a test key in a live field is REFUSED and not saved; keys Von Payments rejects take the method off the checkout). **Test mode is ON at install** until unticked. Payment updates: create a webhook endpoint at the URL the settings page shows, subscribed to SEVEN events (`charge.succeeded`, `charge.failed`, `charge.refunded`, `refund.failed` which is the plugin's delivery probe, `payment_intent.succeeded`, `payment_intent.failed`, `payment_intent.cancelled`) and paste the signing secret. The dispute events need no subscription (Von Payments delivers them to every endpoint) and the plugin's webhook check does not ask for them. Without the endpoint a dashboard-issued refund never reaches the order and a slow-settling order waits on hold (a background re-check runs every 15 min from 10 min; an on-hold order still unconfirmed after 24 h is marked Failed). From plugin 0.5.23 a dashboard test notification changes no order, in Test mode or not. Saved cards: signed-in shoppers only, tick box starts unticked, unticked = not kept. Refunds (full or partial) from the order screen once the payment is confirmed. The order is created BEFORE the card is charged; a card that clears in one step is confirmed in the same visit, and a shopper whose bank asks for a 3-D Secure step is sent to the bank's page and returns to the store's order confirmation (the plugin asks Von Payments what happened before marking anything paid). Only when no usable bank-page address comes back does the shopper see "cannot complete yet" and nothing is charged; a shopper who leaves the bank's page without confirming leaves the order Pending payment, which the plugin leaves as it is unless the payment later goes through. Disputes (chargebacks) are handled in the Von Payments dashboard: the order KEEPS its paid status, the plugin never refunds or restocks for a dispute, and a lost dispute is not always for the full order amount, so reconcile disputed orders there, not from the order screen. The gateway is not offered for subscription products; a non-HTTPS checkout page shows no card field. ⭐ **Install is UPLOAD-ONLY**: the plugin is not in the WordPress directory, so searching the admin never finds it. Download it from `https://www.vonpay.com/woocommerce` (always the current release, no version to choose; it is also the plugin's home page, where the WordPress Plugins screen's View details link goes) and upload that `.zip`. It then carries its OWN update channel (leave WordPress's auto-update toggle ON, since that is how a fix reaches the store): each update's notice is signature-checked and the downloaded file digest-checked, and a failure refuses the update rather than installing anyway, so the store keeps the version it has. ## SDKs & Tools - [SDKs overview](https://docs.vonpay.com/sdks): which SDK for which job, plus the current published-version table - [Node SDK](https://docs.vonpay.com/sdks/node-sdk) — `@vonpay/checkout-node`: sessions, paymentIntents, capabilities, capture, void, refunds, tokens, **buyers** (stored shopper profiles — `upsert`/`retrieve`/`find`/`update`/`eraseEmail`, new in 2.0.0). **Pin the MAJOR you tested against** (minor and patch are additive under semver; a major bump is breaking and ships an upgrade section). ⛔ **2.0.0 is BREAKING on one type: `rejectReason` dropped five values the server never sent (`not_authorized`, `already_captured`, `already_voided`, `already_refunded`, `amount_exceeds_remaining`) — those comparisons were dead code and now fail to compile. The value to ADD a branch for is `concurrent_update`: another capture/void is in flight, and before 2.0.0 the SDK replaced it with undefined so a payment still in flight read as terminal.** ⛔ **`eraseEmail`/`erase_email` is NOT a complete deletion and a 200 does not confirm the provider's copy is gone.** 3.6.0: `refunds.list()` (one payment's refunds + totals), `webhookSubscriptions.sendTestEvent()` (outcome in the same call; the handler must check `test_event` first), AVS/CVV results + refund totals + a recoverable 3DS `nextAction` (`issuedAt`) on `paymentIntents.retrieve()`, `retry` / `last_payment_error` on `sessions.get()`, `retry` on the failed-payment webhooks (one failure event is not final), and `Token.card` fields typed `| null`. 3.5.0: `sessions.create({ buyerContact })` (secret key; phone + addresses you already hold); `SessionNotExpirableError.reason` `"recent_activity"` = call `sessions.expire()` again after `retryAfterSeconds` (the SDK never repeats it), anything else (incl. `"unknown"`) = a payment may be in flight, do not charge another way. ⛔ **3.0.0 is BREAKING in two ways:** `paymentIntents.void()` is retried after an unclear failure only when you pass `idempotencyKey` (like capture/refund), and an HTTP redirect from the API is now the SDK-raised error `unexpected_redirect` (`retryable: false`, new `nextAction` `check_configuration`): never retry it, check `baseUrl`/proxy/network, since a redirect can mean a hijacked connection. read the installable version from [npm](https://www.npmjs.com/package/@vonpay/checkout-node) rather than transcribing it from a page. Sessions require 0.15.0 or later — earlier releases forward the retired `collectShipping` and every session-create call fails `400`. From 3.2.0 a `paymentIntents.create()` without `idempotencyKey` prints a one-time `[vonpay]` warning (Python: `FutureWarning`, which RAISES before sending under `-W error`): pass a key on every charge, since after the 28 October 2026 cut-off the API can refuse any keyless charge. - [Python SDK](https://docs.vonpay.com/sdks/python-sdk) — `vonpay-checkout`: same surface, snake_case — including **buyers** (`upsert`/`retrieve`/`find`/`update`/`erase_email`, new in 2.0.0; `update` RAISES on `email=None`, `erase_email` is the only erase path). Same 2.0.0 `RejectReason` breaking change as Node. ⚠ **3.6.0 changes runtime values:** `token.card.brand` / `last4` / `exp_month` / `exp_year` are `None` (were `""` / `0`) when the provider could not read a saved card; guard each read before upgrading. (Node: an omitted field or `card` now reads `null` instead of `undefined`.) Current version on [PyPI](https://pypi.org/project/vonpay-checkout/); same 0.15.0-or-later floor for session creation. - [Browser SDK (Vora.js)](https://docs.vonpay.com/embedded-fields/quickstart): `https://js.vonpay.com/v1/vora.js` (CDN-only — not on npm). The `/v1/` channel always serves the current build; don't transcribe a version number from any page — read it (and its SRI hash) from [`js.vonpay.com/integrity.json`](https://js.vonpay.com/integrity.json), which is the only source that can't go stale. Complete the embedded flow with `vora.elements.create()` + `collection.submit()` -> `vp_pmt_*` token. - [Script-tag integration & SRI](https://docs.vonpay.com/embedded-fields/sri): the two CDN channels, pinning a version with Subresource Integrity, and automating the bump - [Hosted vora script](https://docs.vonpay.com/sdks/vora-hosted): the hosted-redirect script surface - [Vora.js changelog](https://docs.vonpay.com/sdks/vora-changelog): what changed in each vora.js release, newest first, copied verbatim from the SDK team's public changelog. `https://js.vonpay.com/v1/vora.js` updates automatically in staged rollouts; a pinned `/v1.x.y/vora.js` never changes, so pin a version to control when you move. Read this before assuming a behaviour change is a bug. - [SDK telemetry](https://docs.vonpay.com/sdk-telemetry): what the Node/Python SDKs report back when telemetry is on — ON by default with a test key, OFF with a live key, `telemetry: { enabled: false }` always wins; one small event per SDK failure (sdk name/version, runtime, operation, error code, hashed request id, retry count/HTTP status), never bodies or buyer data, dropped whole on any key/secret/email-shaped string, 2 KB cap, 5 s timeout, 60 s pause after a 429; `#serverless` (fire-and-forget POSTs may drop on function freeze) and `#firewall` (allow checkout.vonpay.com:443) - [CLI](https://docs.vonpay.com/sdks/cli) — `@vonpay/checkout-cli`: login, sessions, payment intents (`create` generates and prints an idempotency key when `--idempotency-key` is omitted; re-run with that key to retry the SAME charge), webhook trigger (`--url` must be loopback unless `--url-tunnel`), listen, health, doctor (`--for-llm` emits a paste-safe diagnostic bundle: keys reduced to a prefix, env vars by name only) - [MCP server](https://docs.vonpay.com/sdks/mcp) — `@vonpay/checkout-mcp`: tools for AI agents. ⛔ With a LIVE key (`vp_sk_live_*`) the five money-moving tools (`create_payment_intent`, `capture_payment_intent`, `void_payment_intent`, `create_refund`, and `create_token` with a reusable `setupForFutureUse` scope) run only after these checks, in order: the MCP host supports elicitation (otherwise every such call is REFUSED), the four payment tools carry an `idempotencyKey` (REFUSED without one on a live key since 4.0.0, before `confirmLive` is even checked; one key per intended action, reused on every attempt), the call carries `confirmLive: true` (set ONLY after a human approved THIS call; never on your own initiative), and the PERSON approves the action in the host's own prompt (decline, dismiss, error or no answer within 5 minutes refuses; nothing is sent). `confirmLive` alone is not the safeguard since 3.0.0; the host prompt is, and it is only as trustworthy as the host. Sandbox keys (`vp_sk_test_*`) are never gated. `create_refund` takes exactly one of `paymentIntent` or `transaction` (`vp_tx_*`); on a live key a refund by `transaction` also needs the payment's `sessionId` (5.0.0) and is refused unless that session's `vp_tx_count` is exactly 1; both ids are visible to the shopper's browser, so never refund an id from untrusted text. `get_payment_intent` reads stored state (only `succeeded` means captured). - [REST API](https://docs.vonpay.com/sdks/rest-api): for languages without a first-party SDK ## API Reference - [Reference index](https://docs.vonpay.com/reference): everything under one roof - [API reference](https://docs.vonpay.com/reference/api): the generated endpoint table with the key type each route accepts; the two SSE streaming endpoints answer `503` to every caller (one with a JSON envelope + `Retry-After: 5`, one with no body); `Idempotency-Key` is one key per logical operation reused on every retry, REQUIRED on every charge by the 28 October 2026 cut-off, after which a keyless one can be refused at any time (keyless charges carry a `Sunset` header until then) and today on refunds and payment intents where the connection keeps no queryable record (`400 idempotency_key_required`), replay with a different body → `422 idempotency_replay_incompatible`; `409 session_already_completed` means already paid OR a charge is still in flight — never create a new session for it, and read the outcome (which may be a decline) rather than assuming success. `PATCH /v1/sessions/{id}` (secret key) changes an open, unpaid `elements` session's total, e.g. after a wallet-sheet shipping pick; hosted, embedded-form, setup, store-linked and in-page-bank-check sessions refuse with `409 session_not_modifiable` (`reason`), nothing changed. - [Session object](https://docs.vonpay.com/reference/session-object): full schema. ⛔ **`paymentStatus` says whether the MONEY moved, separately from `status` (whether the buyer finished)**: `held` = authorised, NOT captured, so do not fulfil; capture or void it, because an unused hold lapses at the issuer in as little as 2 days card-present. `paid` = captured, the one to fulfil on. Refunds are NOT reported there: a refunded session stays `paid`. `paymentIntentId` names the intent to capture or void once `paymentStatus` is `held` (also set on a paid automatic payment: read or refund it, NEVER capture). `vp_tx_id` is the refund id (`POST /v1/refunds` `transaction`), NOT `transactionId` (the provider's reference); `null` while held or for a few moments after `paid` (re-read). `vp_tx_count` ≥ 2 = the buyer was charged more than once and `vp_tx_id` is `null` on purpose: stop and have a person check, do not refund either. Also on the read: `nameCollection` / `emailCollection` (the buyer-field requirements fixed at create) and `channel` (`virtual_terminal` when keyed in the dashboard, else `null`). `buyerContact` on create (secret key only) supplies phone + addresses you already hold. - [Payment intent object](https://docs.vonpay.com/reference/payment-intent-object): full schema for the discrete charge surface. ⚠ **A declined intent is a `201 Created` with `status:"failed"` (`200` on an idempotent replay), never a 4xx** — so parse the body on success statuses; a client that only reads the body on an error status never sees `decline_code`, `action`, `rule_code` or `decline_message`, raises no exception, and shows every decline as a blank result. `payment_method_id` (the vaulted `vp_pmt_*`) is returned by `GET /v1/payment_intents/{id}` **only, not on create** — it is how you recover a saved card when the charge response carried no token, which is every 3-D Secure payment. ⚠ Two response fields are CREDENTIALS bound for the buyer's browser — `client_confirm.client_secret` (the 3-D Secure step-up handle) and `pulseToken` (the real-time status subscription; the ONE camelCase name on the wire) — forward them, never log them, and redact both before a payment result reaches a log line, breadcrumb or error tracker. `client_confirm` and `next_action` can BOTH populate on one response — they are two routes through the same challenge, not alternatives to check in order - [Metadata](https://docs.vonpay.com/reference/metadata): your own reference data on a payment. ⚠ **Limits differ per endpoint** — a checkout session caps values at 500 chars and takes strings only, while payment intents / refunds / tokens accept any JSON value; order mirroring also caps KEYS at 64 chars. Whole map capped at 8 KB serialized on payment intents / refunds / tokens / buyers (rejected with `validation_error`, not truncated) — but there is NO size cap on POST /v1/sessions, and order mirroring is capped at 4 KB across the whole mirror block with a different error, `mirror_too_large`. ⛔ **It is NOT private** — how much reaches the payment processor depends on the ENDPOINT as well as the connection (hosted checkout forwards the WHOLE map), and on at least one live connection EVERY key is copied onto the processor's record; use `rule_tags` when you want something forwarded on purpose. ⛔ **It is NOT chargeback evidence.** A fixed list of key NAMES is removed before storage — it matches the name, not the value, so ordinary names like `number`/`address`/`email` are deleted even when harmless (qualify your keys: `order_number`), while PII under an unlisted name (`customer_email`) is stored as sent. It is NOT a privacy control. Does not run at all on POST /v1/sessions. On a BUYER, metadata MERGES (append) — a write never bulk-replaces and an empty object does not clear it - [Tokens](https://docs.vonpay.com/reference/tokens): `vp_pmt_*` payment methods — vaulting, reuse, and the write-once off-session consent model. **`GET /v1/payment_methods?buyer_id=` lists a buyer's saved cards** (secret key only, newest first, cursor-paged) — use it when you no longer hold a token, e.g. a card vaulted after a 3-D Secure or wallet payment finished asynchronously and so was never in your original response. Revoked cards are OMITTED, not listed as revoked. Both permissions come back as DATA, not as a filter. **`PATCH /v1/tokens/{id}` replaces the billing address stored with a saved card** (SDK 2.6.0+: `tokens.updateBillingAddress` / `tokens.clearBillingAddress`, Python `update_billing_address` / `clear_billing_address`; secret key only; `billing_address` is the only accepted field, `null` clears it, leaving it out is a 400; the address is never returned, only `has_billing_address`; a revoked card is 422 `payment_method_inactive`; a later `POST /v1/payment_intents` charge that sends no `billing_address` USES the stored one on connections that support address verification, and an address on the charge always wins; a card saved without one picks up its first approved charge's address unless that charge's address check was a no-match). **`DELETE /v1/tokens/{id}` revokes a stored card** (secret key only, final — no un-revoke). ⚠ There is NO browser-side way to list saved cards: a publishable key is refused and the session-scoped variant is not enabled, so read them server-side and send your frontend only what it needs - [Refunds](https://docs.vonpay.com/reference/refunds): full and partial refunds, and the states that refuse one. To see what was already refunded, list a payment's refunds with `GET /v1/refunds?payment_intent=…` (or `?transaction=vp_tx_…`) or read `amount_refunded` / `remaining_refundable` on the payment intent or session — never add up webhooks; a `requested` refund has not finished. ⚠ **A 2xx on `POST /v1/refunds` does NOT mean the buyer was repaid — there are FOUR success shapes and only one moved money.** `201`+`succeeded` = money moved. `202`+`requested` = accepted, still in progress; **not** a failure, and re-issuing on it pays the buyer twice. `200`+`canceled` = the reversal was VOIDED before it left, terminal, **buyer NOT repaid** (no refundable balance was consumed, so it can be re-issued — **under a NEW key**: the old one stays bound to the canceled record and replays it forever). `200`+`idempotent:true` = a replay, no new refund — and the replayed `status` can be `failed`, meaning the buyer was never repaid and **that key can never repay them**. ⚠ Before re-issuing a failed refund, branch on HOW it failed: a typed decline (`refund_declined` on the webhook) means nothing moved, so re-issue under a new key, EXCEPT when `retry_available` is `false`: the payment was disputed and the buyer already has the money back through the chargeback, so re-issuing or refunding another way pays them twice; a `provider_unavailable` or timeout on the original call means **state unknown — do NOT re-issue**, record it unresolved and settle with support. Branch on `status`, never on the code alone. A `requested` refund's later status shows on `GET /v1/refunds` (the payment's refund list); also resolve it on the `charge.refunded` / `refund.failed` webhooks — but the failure twin is not emitted on every processor connection, so reconcile from your own ledger rather than waiting indefinitely. ⚠ `400 idempotency_key_required` — on processor connections that keep no queryable record of a reversal, a keyless refund is REFUSED because the header is the only duplicate guard; every other connection still accepts one - [Capabilities](https://docs.vonpay.com/reference/capabilities): `GET /v1/capabilities` — what the merchant's provider actually supports before you call it ⚠ **`supported_operations.dispute_reporting`** (`tracked`|`not_tracked`) — a payment reading `succeeded` is NOT evidence it was not disputed. ⛔ The payment's `status` NEVER becomes `disputed` on either value (the enum is a closed six-value set), so polling for a dispute cannot work; on `tracked` the signal is the `dispute.created`/`won`/`lost` webhook, which is delivered to **every active subscription** whether or not it was selected — it is not in the dashboard picker because it does not need to be. Merchants answer their own disputes on either value; an unanswered dispute is lost automatically. - [Statement descriptors](https://docs.vonpay.com/reference/statement-descriptors): what the buyer sees on their card statement - [Buyers](https://docs.vonpay.com/reference/buyers): first-class buyer records (secret-key) — `POST /v1/buyers` create-or-update upsert keyed on `external_id` (wins) or `email`, exact lookup via `GET /v1/buyers?external_id=|email=`, retrieve by `vp_by_*` id, sparse `PATCH` (`external_id` not patchable). Sessions / payment intents / tokens accept a nested `buyer` object (camelCase on sessions, snake_case on the discrete surfaces) that writes the same record and is echoed on the create response. ⛔ **`first_name` / `last_name` are NOT stored on the buyer record** — `name` is the stored field and is never split. The structured pair belongs to the CHARGE: only `POST /v1/payment_intents` forwards it (to the processor billing block, where it takes priority over every other name source); `POST /v1/buyers` and `POST /v1/tokens` accept it with NO EFFECT, and `PATCH /v1/buyers/{id}` REJECTS it with `400 validation_error`. ⇒ **There is no way to backfill names onto existing buyers** — advise sending the pair on every charge from now on; charges already made cannot be amended. Each capped at 100 chars (vs `name`'s 200), rejected not truncated. Forwarding of the billing block is an ACCOUNT-LEVEL setting, so a 2xx does not confirm it was sent. ⚠️ **`PATCH` with `email: null` clears the address on the BUYER RECORD and its lookup hash**, and fires a best-effort, non-blocking deletion of the provider's copy (a `200` does not confirm the provider half). ⛔ **NOT a complete erasure — do not present it to a shopper as one:** the settlement ledger keeps an encrypted copy under financial-audit retention that nothing clears, checkout sessions keep an independent copy until a 90-day anonymiser, and an address already forwarded to a connected store has no retraction path. The record and its payments survive; an email-only buyer becomes unfindable by lookup afterwards (keep `external_id`). Common JSON libraries serialise absent optionals as `null`, so an ordinary update can erase by accident and returns `200` looking like success — send only the keys you mean to change - [Error codes](https://docs.vonpay.com/reference/error-codes): typed `ErrorCode` union with merchant remediation, plus the decline model — `decline_code` (the normalized reason), `action` (what to do next) and `decline_message`. ⚠ **A declined `POST /v1/payment_intents` returns `201` with `status:"failed"`, not a 4xx** — a client that only parses the body on an error status silently never sees the decline fields. `decline_message` is the buyer-safe wording — it deliberately collapses `stolen_card`, `fraudulent`, `lost_card` and the generic declines onto one identical string so a buyer cannot tell them apart. ⚠ `failure_reason` is the opposite: it names the real cause ("the card was reported stolen") and must never be shown to a buyer. ⚠ Non-retryable codes include (NOT an exhaustive list — check each code's own row): `charge_in_progress` (409 — TWO SCOPES under one code: on the hosted/embedded surface it is keyed on THE SESSION, on `POST /v1/payment_intents` it is keyed on YOUR IDEMPOTENCY-KEY (or an unresolved charge on the same saved card). Recovery is the same for both (do not retry, do not create a new session, do not mint a fresh key); what you READ to resolve it differs — retrieve the session vs. read the original payment's status. A charge is already in flight; a second attempt can be a second charge) and `buyer_required_for_saved_card` (422 — nothing was charged, and the buyer is fixed at session-create so a retry can never succeed; create a NEW session with `buyerId`) ⛔ **THREE OUTCOMES, AND THE SPLIT IS NOT 4xx-vs-5xx.** `400 idempotency_key_required` = nothing was charged, ADD a stable key (REQUIRED on payment routes that keep no record of their own, and on refunds of payments taken on a connection keeping no record of a reversal; NOT retryable as sent). `409 charge_in_progress` = a charge for this key is ALREADY RUNNING OR ALREADY DONE, THIS request's outcome is unconfirmed, or an earlier charge on the same saved card has not resolved — **DO NOT retry and DO NOT mint a fresh key**; read the original payment's status. Minting a new key here is the ONE action that double-charges, because nothing can merge two charges on these connections. `503 charge_reservation_unavailable` (the pre-charge reservation write failed) or `503 service_unavailable` (another pre-charge check failed) = NOTHING reached the provider — retry shortly with the SAME key, safe by construction. ⚠ An agent's default "4xx so change something, 5xx so retry" is WRONG for two of these three. Seven currencies are refused today with `422 currency_not_supported` (IQD, LYD, ISK, MGA, CLF, UYW, UYI): do not offer them in a currency picker. ⛔ **`409 order_first_already_paid` / `409 order_first_payment_unresolved`** (order-before-charge) mean an earlier payment for the SAME purchase exists and THIS request charged nothing: NEVER retry with a new `Idempotency-Key`, card or basket to get past them. On already_paid treat the purchase as paid and read `original_payment_intent_id`; on payment_unresolved read that payment (`succeeded` = paid, `failed` = the same basket may be retried). `409 session_not_expirable` with `reason: "recent_activity"` = call expire again after `retryAfterSeconds`. A decline reported to the BROWSER (embedded card form) uses a smaller set: `incorrect_cvc` / `incorrect_zip` / `expired_card` → `incorrect_card_details`; `fraudulent` / `stolen_card` / `lost_card` / `blocked_by_rule` → `card_declined`; the exact code reaches your server on `charge.failed` and secret-key responses. vora.js 1.38.1 and earlier passes neither `incorrect_card_details` nor `blocked_by_rule` to the page (a codeless "The payment was declined."): read the reason server-side. - [API keys](https://docs.vonpay.com/reference/api-keys): publishable (`vp_pk_*`) vs secret (`vp_sk_*`); test keys come from the business's sandbox (`403 test_keys_are_sandbox_only` on a live business), live keys from the live business once it is approved (`403 merchant_not_onboarded` before, `403 live_keys_not_on_sandbox` on a sandbox); a publishable key can WRITE only `POST /v1/sessions` and read `GET /v1/capabilities` but is REQUIRED by eleven session-bound `/v1/public/*` routes; rotation keeps the old key for a 1h/24h/7d grace (`auth_key_expired` after), Revoke rejects on the next request (`auth_invalid_key`); the `ss_*` session secret is used by nothing in the integration and never verifies a return. - [Security](https://docs.vonpay.com/reference/security): the four credential kinds (secret, publishable, legacy `ss_*` session secret, no longer issued and used by nothing, `whsec_*` webhook secret) and the legacy `vp_key_` full secret; the prefix picks the mode not the host; the return redirect is untrusted — read `sessions.get`, fulfil on `charge.succeeded`; card data stays in the processor iframe (SAQ-A) but `elements` mode renders name/address/email in YOUR DOM; encryption at rest, HTTPS-only redirect URLs (live keys reject localhost), security headers, HMAC-SHA256 webhook signing with one secret and no grace window. - [Versioning](https://docs.vonpay.com/reference/versioning): date-based `Von-Pay-Version` header, one supported version (`2026-04-14`), unknown values treated as omitted, response echoes `Von-Pay-Version` + `Von-Pay-Latest-Version`; pin `apiVersion` in the Node/Python SDKs (browser SDKs send no header); non-breaking vs breaking change lists; a superseded shape (stringified `metadata.mirror` on `POST /v1/sessions`) still succeeds and carries RFC 8594 `Deprecation` / `Sunset` / `Link` headers — its Sunset date has passed and it is still accepted. - [Test mode & test cards](https://docs.vonpay.com/reference/test-cards): THE one page for test mode. A test key (`vp_sk_test_*`, `vp_pk_test_*`) never moves real money: the request runs against YOUR PROCESSOR'S OWN TEST ENVIRONMENT, and that environment decides every outcome. Test payments run only on a SANDBOX ACCOUNT: a test key on a live merchant account, or on a sandbox with no processor attached, is refused with `422 sandbox_account_required`. ⚠ **Sandboxes run 3-D Secure on every card, so only the listed test cards work:** `4111 1111 1110 1203` / `5200 0000 0000 1203` verify without a challenge and approve; `4111 1111 1118 1072` / `5240 0000 0000 1072` challenge; expiry 03/30, CVC 100 (Amex 1000). `4242 4242 4242 4242` and `4111 1111 1111 1111` are DECLINED. With a listed card, declines are decided by the ORDER TOTAL and an ordinary total such as 25.00 approves. To test a decline set the total, including shipping and tax, to 2,000.12 (`amount` `200012`): declined by the card's bank, `decline_code` `do_not_honor`, `action` `hard`; the same value arrives as `failure_code` on `charge.failed`. Only this total is confirmed on the 3-D Secure sandboxes. It works on hosted checkout and on Embedded Fields. A test key never reaches a live processor account: it resolves that processor's SANDBOX credentials with no fallback, and a missing sandbox configuration throws rather than reaching the live account. Mode is a property of the KEY, not the host. Cross-mode rules: `vp_pmt_test_*` with a live key → `400 payment_method_mode_mismatch` (NOT `payment_method_inactive`, which is 422 and means revoked); a live key on a sandbox account → `auth_key_type_forbidden`. Webhooks in test mode fire the SAME events as live, from the same place: the processor's notification arrives and we emit. Nothing about the event set or its payloads changes with the key's mode. `GET /v1/capabilities` reports the connection your account actually runs on. - [Rate limits](https://docs.vonpay.com/reference/rate-limits): per-bucket ceilings on the merchant `/v1/*` routes — session create is metered per key (30/min, `rate_limit_exceeded_per_key`) AND per IP: 10/min with a publishable key, 300/min with a secret key (`vp_sk_*` or legacy `vp_key_*`); the public session read is 1000/min per key but 80/min per IP, shared with binder-load; `X-RateLimit-*` on success and 429; `Retry-After` also rides a 503 write-freeze — branch on the status code; SDKs retry 429/5xx but withhold the retry on a money-moving call sent without an `Idempotency-Key`. - [OpenAPI discovery](https://docs.vonpay.com/reference/discovery): `/openapi.yaml` + `/.well-known/openapi` ## Merchant obligations Things Von Payments does NOT do — these are the merchant's responsibility: - **Buyer receipt emails.** Von Payments does not email buyers a transactional receipt. Send your own from your `charge.succeeded` webhook handler (or your post-redirect server route). - **Recurring billing / subscription scheduling.** The `mit` block on `/v1/payment_intents` lets you charge a stored payment method (`recurring`, `unscheduled`, `installment`) using merchant-initiated transactions, but the scheduler, dunning logic, and subscriber portal are on you. You own the rebill loop — build per-cycle MIT charges yourself. ⚠ **The three reasons are not interchangeable, and matching them is necessary but not always sufficient** — what a connection accepts is set by the processor and the card issuer, not by us. On some network-token connections only `recurring` is accepted: even a correctly matched `unscheduled`/`unscheduled` pair can be declined, and the issuer's response gives generic "revalidate payment information" advice that does not reveal the cause. Confirm your connection supports your reason before you build: https://docs.vonpay.com/integration/payment-intents#connection-support - **Dispute evidence submission.** Submit dispute evidence via support. ⚠ **Do not make a dispute webhook your only chargeback detector.** `dispute.created`, `dispute.won` and `dispute.lost` are delivered to EVERY active webhook subscription whether or not you selected them — you do not subscribe to these, but you do need at least one active endpoint, and a handler that only expected `charge.*` must still return 200 for them. Each is raised from the processor's own dispute notification, so it only arrives on a connection that sends us one. Your connection's `dispute_reporting` capability DISCLOSES whether yours does; it is not a switch on our side and it gates nothing, so read it rather than inferring from silence. Where it reads `not_tracked`, a `succeeded` status is not evidence a payment was never disputed — watch your processor's own dashboard, because an unanswered chargeback is lost automatically: https://docs.vonpay.com/reference/capabilities#dispute-reporting - **Webhook-handler idempotency.** Dedupe on the envelope's top-level `id` (`vp_evt_*`) — you may receive the same event more than once during retries. ⛔ **Never dedupe on `session_id`** (and never on `session_id` + `type`): one session legitimately emits `charge.succeeded` AND `charge.refunded`, and a charge refunded in two parts emits `charge.refunded` twice — deduping that way silently DROPS the refund, so it never reaches the merchant's ledger. - **Reconciliation when a signal is delayed.** ⛔ The redirect is NEVER authoritative for fulfilment. If the buyer returns but the webhook is delayed, call `GET /v1/sessions/{id}` and act on the status it returns — `succeeded` means completed (paid, or on a `captureMethod: "manual"` session authorised and held), `pending`/`processing` mean still in flight (show a neutral confirming page, never a failure). Fulfilment must be idempotent and keyed on the session ID, because the webhook and the return page can both fire for the same order. See [Reconciliation](https://docs.vonpay.com/integration/reconciliation). ## Optional - [Sandbox & Test Mode](https://docs.vonpay.com/guides/sandbox): mode is decided by the key prefix per request, never by host; getting test keys: ⛔ a test sandbox belongs to a business, so start an application first (no approval needed), then Activate Evaluation Sandbox (with no business: `409 sandbox_needs_a_business`); one sandbox per business, shared by every environment you run; webhooks fire; test data purged nightly; `apiBaseUrl` defaults to production and is never inferred from the key. What a test key reaches lives on Test mode & test cards, not here - [Express Checkout Wallets](https://docs.vonpay.com/guides/express-checkout-wallets): activate Apple Pay & Google Pay — per-domain verification, who-does-what, troubleshooting - [Platform sandbox](https://docs.vonpay.com/guides/platform-sandbox): the sandbox a platform integrator builds a connector against — ⛔ a sandbox belongs to a BUSINESS: start an application first (no approval needed), then Activate Evaluation Sandbox in the developer dashboard; with no business it is refused `409 sandbox_needs_a_business`; one sandbox per business, so there is no "one sandbox per simulated customer"; a sandbox created on or after 2026-09-16 has no expiry date; test requests run on the sandbox's own processor test account (outcomes by order total, real 3DS, real declines, real wallets), and a sandbox with no processor test account refuses every test payment with `422 sandbox_account_required`; each merchant of yours pastes their own live keys + a webhook URL into your gateway-config form, never `ss_live_*` - [Troubleshooting](https://docs.vonpay.com/troubleshooting): common integration errors and fixes - [Reconciliation](https://docs.vonpay.com/integration/reconciliation): redirect vs. webhook source-of-truth when one signal is delayed