# Von Payments — Full Documentation (llms-full.txt) > Full-text concatenation of every page on docs.vonpay.com, generated from source. > The curated index is at https://docs.vonpay.com/llms.txt. The machine-readable > API contract is the OpenAPI spec at https://checkout.vonpay.com/openapi.yaml. --- ## AI Agents Source: https://docs.vonpay.com/agents # Build with AI agents Von Payments treats AI agents as first-class developer participants. Every part of the integration — SDKs, MCP server, CLI, errors, discovery endpoints — is designed for an autonomous agent to use the same way a human developer would, without hand-holding. This isn't a wrapper layer. It's how the product was built. ## What makes Von Payments agent-friendly Every error response includes a `code`, a one-line `fix`, and a link to the docs page that explains it. Agents read the `code` to branch, the `fix` to act, and don't need to escalate to a human unless the fix tells them to. ```json { "error": "Currency 'USDD' is not supported.", "code": "validation_error", "fix": "Check the request body against the API reference", "docs": "https://docs.vonpay.com/integration/create-session" } ``` The SDK wraps this in a typed envelope (`VonPayError.llmHint` + `VonPayError.nextAction`) so agents using `@vonpay/checkout-node` get the same signal without parsing JSON. Every `/.well-known/` and `llms.txt` endpoint is unauthenticated. An agent can self-orient against any Von Payments domain by fetching two URLs. ## The four agent surfaces | Surface | Use when | |---|---| | **[MCP server](../sdks/mcp.md)** (`@vonpay/checkout-mcp`) | Agent runs in Claude Desktop, Cursor, Claude Code, or any MCP client. Native tool calling. | | **[CLI](../sdks/cli.md)** (`@vonpay/checkout-cli`) | Agent shells out to commands. `--json` everywhere; `doctor --for-llm` for env self-diagnosis. | | **[Node SDK](../sdks/node-sdk.md)** + **[Python SDK](../sdks/python-sdk.md)** | Agent writes code that calls the SDK directly. Typed errors with self-heal envelope. | | **[REST API](../sdks/rest-api.md)** | Agent generates HTTP calls. Discovery via `/.well-known/vonpay.json` + `/llms.txt`. | ## Quickstart for agents ### 1. Discover the API (zero auth) ```bash curl https://checkout.vonpay.com/.well-known/vonpay.json curl https://docs.vonpay.com/llms.txt ``` The discovery endpoint returns the current API version, SDK package names, MCP package, docs URLs. `llms.txt` returns a concise LLM-readable reference of every public endpoint. ### 2. Pick a surface and authenticate For MCP — install `@vonpay/checkout-mcp` in your client config and set `VON_PAY_SECRET_KEY` to a `vp_sk_test_*` key. Restart the client; tools appear. For CLI — `npx @vonpay/checkout-cli checkout login ` writes config to `~/.vonpay/config.json`. For SDK — pass the key to the client constructor. ### 3. Create a session, handle the error envelope If anything fails, the error has a `code`, a `fix`, and a `docs` URL. Read `fix`. Retry or escalate based on what it says. ## Self-diagnose without a human The CLI ships with a `doctor` command designed for agents: ```bash vonpay checkout doctor --for-llm ``` Returns markdown-formatted diagnostics — API key validity, network reachability, API version, suggested next steps. Pipe the output to your agent's context when something goes wrong. ## CLAUDE.md snippet for your project Drop this into the `CLAUDE.md` at the root of your project so Claude Code understands your Von Payments integration without explanation: ```markdown ## Von Payments - API base: https://checkout.vonpay.com - Auth: Bearer token with `vp_sk_test_*` (test) or `vp_sk_live_*` (live) - SDKs: `@vonpay/checkout-node` (npm), `vonpay-checkout` (PyPI) — pin the major you tested against and read the current version from the registry, never from a docs page - Hosted checkout: load `https://js.vonpay.com/v1/vora-hosted.js` (browser SDK, redirect drop-in) - Embedded fields: load `https://js.vonpay.com/v1/vora.js` (browser SDK) - MCP: `@vonpay/checkout-mcp` for native tool calling - Discovery: `GET /.well-known/vonpay.json` + `GET /llms.txt` - Errors: every error has `code` + `fix` fields — read `fix` to self-correct - Docs: https://docs.vonpay.com ``` ## System prompt templates ### E-commerce agent ``` You are a checkout assistant for an online store powered by Von Payments. When a customer is ready to pay: 1. Create a checkout session via POST /v1/sessions with the cart total, currency, and country 2. Return the checkout URL for the customer to complete payment 3. If an error occurs, read the `fix` field and retry Auth: Use the VON_PAY_SECRET_KEY environment variable as a Bearer token. API reference: https://docs.vonpay.com/llms-full.txt ``` ### Platform / operations agent ``` You are a payments operations agent for a platform using Von Payments. Capabilities: - Create checkout sessions for merchants - Check session status and payment outcomes - Capture authorized payment intents - Issue refunds - Monitor API health Before making API calls, fetch https://checkout.vonpay.com/.well-known/vonpay.json to discover the current API version and endpoints. When an API call fails, read the error `code` and `fix` fields to diagnose and retry automatically. Do not ask the user for help unless the fix field says to. ``` ## Anti-patterns to avoid Whether you're an agent acting autonomously or a developer pairing with one, these are the failure modes that cause real payment incidents: - **Don't fulfill on the client-side result.** A `submit()` result or a redirect return is a UX signal, not proof of settlement. Confirm the `payment_intent.succeeded` / `charge.succeeded` [webhook](../integration/webhooks.md) server-side before shipping goods or granting access. - **Don't trust a client-supplied amount.** The amount is frozen on the session at creation, server-side. Never recompute or accept a price from the browser at charge time. - **Verify the webhook signature before acting on the payload.** Treat every byte as attacker-controlled until your HMAC compare passes — see [Webhook verification](../integration/webhook-verification.md). - **Don't re-charge a charge-only result.** A guest `charged: true` submit already moved money and minted no token; charging it again double-charges. Branch `error` → `token` → `charged`, and on the manual wallet path never also call `/v1/payment_intents`. - **Don't hardcode a payment processor.** Routing is decided server-side — your integration never names or selects a gateway. Read the capability map; don't import a processor's own SDK. - **Handle unknown webhook types gracefully.** New event types ship without an SDK bump — return `200` and log; never raise on a `type` you don't recognize. - **Use a stable idempotency key on writes.** Key it to the checkout attempt (e.g. the cart id), not a fresh value per retry, so a retried charge never duplicates. ## What's next - **[MCP server setup →](../sdks/mcp.md)** - **[CLI commands →](../sdks/cli.md)** (note: `doctor --for-llm` is the agent's friend) - **[Error codes reference →](../reference/error-codes.md)** (every code has remediation + retryable flag) - **[API discovery →](../reference/discovery.md)** --- ## Build your integration Source: https://docs.vonpay.com/build-your-integration # Build your integration > **Not starting from scratch?** [How do I…?](./how-do-i.md) maps a specific question to the one page that answers it. **A sequence, not a topic list.** Every page linked below is worth reading on its own; read in the order they appear in the sidebar, they assume each other. This page puts them in the order you actually need them, says what breaks if you skip a step, and tells you how to prove each one landed. Follow it start to finish and you will have taken a real payment, saved a card if you need one, received the outcome reliably, and tested the failure paths before a buyer finds them. :::note What this page is not It does not restate the reference. Each step is a short "why, and what to watch for", then a link to the page that covers it properly. If a step already looks familiar, skip it; nothing here is a prerequisite for reading the next thing. ::: --- ## Step 0 — Decide what you are building **Do this before writing anything.** The two integration shapes differ in where the card fields live, who handles 3-D Secure, and how much PCI scope you take on. Choosing wrong is a rewrite, not a refactor. → **[Compare the integration paths](./integration-paths.md)**: a capability matrix, a decision tree, and a per-path 3-D Secure table. **How to know you're done:** you can name which path you're on, and why. --- ## Step 1 — Get keys, and prove they work before you write code Activate a sandbox from the dashboard. You get a secret key (`vp_sk_test_*`), a publishable key (`vp_pk_test_*`) and a webhook signing secret (`whsec_*`). **Then spend five minutes proving the key works**, before any integration code exists. A key problem found now is five minutes; found in Step 3 it looks like a bug in your card form. ```bash # 1. The key authenticates, and tells you what your account can do. curl https://checkout.vonpay.com/v1/capabilities \ -H "Authorization: Bearer vp_sk_test_YOUR_KEY" # 2. You can create a session. curl https://checkout.vonpay.com/v1/sessions \ -H "Authorization: Bearer vp_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: smoke_test_session" \ -d '{"amount":1499,"currency":"USD","successUrl":"https://example.com/ok","cancelUrl":"https://example.com/no"}' # 3. You can read it back. curl https://checkout.vonpay.com/v1/sessions/SESSION_ID \ -H "Authorization: Bearer vp_sk_test_YOUR_KEY" ``` **How to know you're done:** three `2xx` responses. A `401` on any of them means the key is wrong or mistyped: check you copied the whole value, and that its mode matches the environment you are calling. A `403` narrows it to the third call. Reading a session back is a secret-key endpoint and rejects a publishable key with `auth_key_type_forbidden`; the first two are not: capabilities is account metadata either key type may read, and session creation accepts both by design. So a `403` here means step 3 got a publishable key, not that your key is broken. See [API key types](./reference/api-keys.md) for the full split. → [Sandbox setup](./guides/sandbox.md) · [API keys](./reference/api-keys.md) · [Capabilities](./reference/capabilities.md) --- ## Step 2 — Take one payment, end to end Get a single successful payment working before you add anything else. Not saved cards, not wallets, not 3-D Secure handling: one charge, one outcome. Pick the quickstart for the path you chose in Step 0: - **[Hosted checkout](./integration/quickstart.md)**: you redirect, we host the page. - **[Embedded fields](./embedded-fields/quickstart.md)**: the card fields render inside your page. - **[Payments API](./integration/payment-intents-quickstart.md)**: you already hold a saved card or are charging server-side. :::warning The trap that costs a day On embedded fields, the SDK must be pointed at the same environment your keys belong to. A test key with a production `apiBaseUrl` fails in a way that looks like a broken card form rather than a configuration error. ::: **How to know you're done:** one payment reaches `succeeded`, and you saw it in the dashboard, not just in your own logs. --- ## Step 3 — Receive the outcome somewhere you can trust This is the step most integrations get wrong, and the failure is silent: orders that were paid for never get fulfilled. **The redirect back to your site is not proof of payment.** It is signed, so you know it came from us, but a *declined* payment is signed exactly as validly as an approved one. Fulfil from the webhook. → **[Webhooks](./integration/webhooks.md)**: subscribe, verify, respond. → **[Verifying a signature](./integration/webhook-verification.md)**: constant-time compare, 5-minute replay window. → **[Which events exist](./integration/webhook-events.md)**. **How to know you're done:** you can point at the line of your code that marks an order paid, and it is inside a webhook handler. --- ## Step 4 — Save a card, only if you need one Skip this entirely if you take one-off payments. If you bill again later (subscriptions, instalments, cards on file), the requirements are strict, ordered, and fail *silently weeks later* if you miss one. → **[Recurring and saved cards](./guides/recurring-and-saved-cards.md)**: the four steps in order, including the consent declaration that cannot be added afterwards. :::danger Consent is write-once A card saved without the right consent cannot be repaired later. There is no endpoint that adds it after the fact; you have to collect the card again. The first charge works; the renewal is what declines. ::: --- ## Step 5 — Handle what goes wrong Everything above is the happy path. This is where a production integration is actually made or lost. → **[Reconciliation](./integration/reconciliation.md)**: what to do about an event you missed, a request that timed out, a response you never received. → **[Troubleshooting](./troubleshooting.md)**: every error code, what it means, what to do, and whether it is safe to retry. **How to know you're done:** you know what your code does when a charge request times out. "Retry it" is the wrong answer unless you sent an [`Idempotency-Key`](./integration/idempotency.md). --- ## Step 6 — Test the behaviour, not just the configuration **This is the step nobody does, and it is the one that catches real bugs.** The [go-live checklist](./guides/go-live-checklist.md) verifies your *setup*: keys, HTTPS, secret storage, replay windows. Every item there can pass while your handler still double-charges a buyer on a redelivered event. These cases test what your code *does*. Run every one of these in sandbox before you go live. ### Payment outcomes | # | Test | Why it matters | Done when | |---|---|---|---| | 1 | A successful payment | The happy path | Order marked paid, exactly once | | 2 | A declined payment | Most integrations only ever test success | Buyer sees a useful message; no order created | | 3 | A 3-D Secure challenge that **succeeds** | The challenge redirects away from your page and back | Order paid after the return | | 4 | A 3-D Secure challenge that **fails** | The buyer returns to your site having *not* paid | No order created; buyer can retry | | 5 | An expired / insufficient-funds card | These carry distinct failure codes worth surfacing differently | Your UI distinguishes "try another card" from "try again" | Which card or amount produces each row depends on your account's test path: [Test mode](./reference/test-cards.md). ### Webhook handling | # | Test | Why it matters | Done when | |---|---|---|---| | 6 | **Send the same event twice** | We retry on failure, so duplicates are normal, not exceptional | The order is fulfilled **once**. This is the single highest-value test on this page | | 7 | Send an event with a **tampered signature** | An unverified endpoint will accept anything anyone posts to it | Rejected, nothing written | | 8 | Send an event with a **timestamp older than 5 minutes** | Replay protection | Rejected | | 9 | Make your handler **return a 500** | Ours will retry; yours must survive that | Retry arrives and is handled cleanly | | 10 | Take **longer than 10 seconds** to respond | Slow handlers get retried, causing duplicates | Heavy work moved out of the request | ### Retries and duplicates | # | Test | Why it matters | Done when | |---|---|---|---| | 11 | **Send the same charge twice with the same `Idempotency-Key`** | This is the retry your own code will do after a timeout | One charge. The second returns the original result | | 12 | Send the same charge with a **different** key | Proves you understand what the key does | Two charges, which is why you must never vary it on a retry | | 13 | **Refund the same payment twice** with the same key | A retried refund must not pay the buyer twice | One refund; the replay is flagged rather than issued | :::tip Test 12 is the one worth doing deliberately It is the only test here designed to produce a *wrong* outcome. Doing it once, on purpose, in sandbox, is how the rule stops being abstract. ::: --- ## Step 7 — Go live → **[Go-live checklist](./guides/go-live-checklist.md)**: keys, HTTPS, secret storage, monitoring, and the account requirements. **Do not skim the observability rows.** Logging the `X-Request-Id` header from every error response is the one people leave for later; it is the first thing support will ask for, and it turns a day of back-and-forth into one message. --- ## The short list of things not to do Every one of these is covered in depth on the page linked beside it. Collected here because they are the mistakes that actually get made, and because they are easier to avoid than to find afterwards. | Don't | Why | Where | |---|---|---| | Fulfil an order from the redirect | A decline is signed just as validly as an approval | [Handle the return](./integration/handle-return.md) | | Treat a pending result as a failure | Re-issuing on it charges or refunds twice | [Payment intents](./integration/payment-intents.md) | | Mark an order failed on a client timeout | The charge may have succeeded after your request gave up | [Reconciliation](./integration/reconciliation.md) | | Add an attempt counter to an `Idempotency-Key` | Retrying is the case the key exists to collapse | [Idempotency](./integration/idempotency.md) | | Mint a fresh key to get past a `409` | It removes the only thing preventing a double charge | [Error codes](./reference/error-codes.md#charge_in_progress) | | Charge again after an embedded charge-at-submit | Submit already took the money | [Embedded quickstart](./embedded-fields/quickstart.md) | | Send `setup_for_future_use` on the charge instead of at collection | Consent is write-once and cannot be repaired later | [Recurring and saved cards](./guides/recurring-and-saved-cards.md) | | Omit `return_url` when 3-D Secure may trigger | The buyer completes the challenge and lands nowhere | [3-D Secure](./embedded-fields/3ds.md) | | Install a payment processor's own SDK alongside ours | Its tokens are not ours, and your page breaks when your account is routed elsewhere | [Integration paths](./integration-paths.md) | | Dedupe webhooks on anything but the event `id` | A session can produce several events | [Reconciliation](./integration/reconciliation.md) | --- ## Where to go next - **[Troubleshooting](./troubleshooting.md)**: every error code with what to do about it. Bookmark this one. - **[API reference](./reference/api.md)**: every endpoint, once you know what you're looking for. - **[Building with an AI agent?](./agents/overview.md)**: the machine-readable index and what it covers. --- ## Payment routing Source: https://docs.vonpay.com/concepts/vora # VORA — Payment Routing **VORA** is Von Payments' gateway-orchestration layer. It picks the underlying payment processor at payment time so you don't have to. **From your perspective as a developer, VORA is transparent.** You always call the same endpoint — `POST /v1/sessions` — and we handle processor selection, failover, and reconciliation. You do not need to know which processor fires a given charge. ## Why it exists Every merchant on Von Payments is attached to exactly one "entry point" for their payments. That entry point is one of two things: | Entry point | What happens at payment time | |---|---| | **Direct gateway** | We call a single processor directly. No router in between. | | **VORA router** | We hand the payment to VORA, which internally picks a processor based on cost, geography, and failover rules. | From your code, both look identical. `POST /v1/sessions` returns a `checkoutUrl` and the buyer pays on our hosted page. > **Note:** The active entry point also determines the **submit semantics of the Embedded Fields SDK (`vora.js`)**. On some entry points, calling submit is pure tokenization — it returns a reusable `vp_pmt_*` token and charges nothing. On the charge-and-save flow, submit charges the card on the spot and (with a buyer on the session) also vaults a reusable token in the same step. Your Embedded Fields code must handle both, so it discriminates on the result shape rather than assuming a token is always returned. See [Charge-and-save](../embedded-fields/tokenization.md#charge-and-save) for the result-shape contract. ## What changes in your integration **Nothing.** Session creation is unchanged, session retrieval is unchanged, and how you confirm a payment on the return is unchanged. VORA is entirely server-side inside Von Payments — none of its state is exposed on the merchant API. Processor selection state (which processor handled a charge, the processor-side merchant ID, etc.) lives in internal Von Payments systems and surfaces only to merchants through the Dashboard and Ops tooling — not through the public API. ## What you don't need to do - **Choose or integrate your own payment processor.** Payment processing is provided by Von Payments — [work with us to activate payment processing across the VON acquiring network](../guides/go-live-checklist.md), and VORA selects and routes the right path for each charge. - Handle processor-specific failure codes. The SDK normalises errors into a single `VonPayError.code` union. - Build any failover logic. VORA routes around processor outages for you. - Change how you handle the return. You confirm a payment the same way regardless of which processor was used — see [Handle the return](../integration/handle-return.md). ## When you might care which processor fired You usually don't. If you need processor-side reconciliation (matching Von Payments transactions against a processor statement) or support escalation context, contact support with your session ID — we can surface the processor-side transaction ID for that specific session. There is no API surface for bulk processor-level reporting on the merchant side today. ## Related - [Session object](../reference/session-object.md) — field reference - [Checkout overview](../integration/index.md) — end-to-end session flow - [Handle the return](../integration/handle-return.md) — confirming the payment when the buyer comes back --- ## Overview Source: https://docs.vonpay.com/connected-platforms # Connected Platforms — mirror Vora orders into your CRM Take payment through Vora and keep your existing commerce platform as your system‑of‑record. (Running a WooCommerce store? Use the [Von Payments plugin](./woocommerce.md) instead: it takes the payment inside your WooCommerce checkout, with nothing to mirror.) When Vora captures a payment, it creates the matching **already‑paid order** in your connected platform, so orders, fulfillment and customer records stay where they are today. ## How it works 1. **Connect your store** once, from the Vora dashboard → Vora → Integrations → Connected Platforms. 2. **Describe the order once**, with an `order` object (what was bought) and a `mirrorTo` object (which store records it). Vora uses that one description for the checkout page the buyer sees and for the order it creates in your store. 3. **When the payment is captured**, Vora creates the already‑paid order in your platform. Vora charged the card; the platform records the order without touching a card. ## Describing the order {#describing-the-order} ```json { "amount": 4498, "currency": "USD", "order": { "lineItems": [ { "name": "Trail Runner - Size 10", "quantity": 1, "unitAmount": 3499, "sku": "TR-10" }, { "name": "Merino Socks", "quantity": 2, "unitAmount": 200, "sku": "MS-01" }, { "name": "Standard shipping", "quantity": 1, "unitAmount": 599 } ] }, "mirrorTo": { "platform": "shopify", "store": "acme.myshopify.com" }, "buyer": { "email": "casey@example.com", "name": "Casey Rivera", "address": { "addressLine1": "18 Bridge Street", "city": "Portland", "state": "OR", "postalCode": "97205", "country": "US" } } } ``` The buyer sees those lines and the shipping row on the checkout page; the store order carries the same lines, the same shipping, and Casey as the customer. - Identical on hosted checkout (`POST /v1/sessions`) and on a direct charge (`POST /v1/payment_intents`) — same fields, same rules, same errors. - `order` and `mirrorTo` travel together; one without the other is a `400`. - Every amount is an integer in minor units — `amount`, `unitAmount`, `shipping.amount`. `3499` is $34.99. - The buyer address takes `addressLine1` / `addressLine2`; the store-order addresses (`mirrorTo.shippingAddress`, `mirrorTo.billingAddress`) take `line1` / `line2`. Both reject unknown fields, so a mix-up is a `400` naming the field. - On most accounts the store order is created **after** the payment succeeds, out of band: a problem at the store is a missing order you can see and fix, never a failed checkout for the buyer. Accounts that [create the order first](#order-before-charge-created) refuse the charge instead. - On a direct charge, `order` / `mirrorTo` is validated **before** the charge: an oversized block, an unconnected store or a missing permission is a `400` / `422` with no money moved. ### The money rule What you describe must add up to what you charge, in minor units. If the two figures disagree the request is refused with `400 order_total_mismatch` before any charge; the response carries `derived_amount` and `charge_amount`. **Shopify** — shipping is a line item: ``` Σ (lineItems[].unitAmount × quantity) = amount ``` Above: `3499 + (200 × 2) + 599 = 4498`, with shipping as its own line. `order.shipping` is refused on Shopify before the charge (`400 validation_error`, message beginning `Invalid order:`), because Shopify does not carry that amount onto the order — send shipping as a line item. **Next Commerce** — shipping is its own field and counts toward the total: ``` Σ (lineItems[].unitAmount × quantity) + shipping.amount = amount ``` `order.coupon` records a discount code for reference; its amount is display-only and excluded from the total. If a discount changes what the buyer pays, subtract it from `amount` and the line prices yourself. A store-priced `voucher` is Next Commerce only, and not expressible on this shape at all — see [Three things only the raw block can express](#raw-only). ### The buyer is stated once too `mirrorTo` inherits from the buyer you already send: the one buyer address fills both the shipping and billing address on the order. A single `buyer.name` is split for the store record — the first word is the given name, the rest the family name (`"Ada King Lovelace"` → `"Ada"`, `"King Lovelace"`); a one-word name yields a given name only. Set `mirrorTo.customer`, `mirrorTo.shippingAddress` or `mirrorTo.billingAddress` explicitly and your value wins. Two rules that look like bugs otherwise: - **Always send `city` and `state` on the buyer address.** They are optional on the buyer profile but required on a store-order address, and inheritance is all-or-nothing: a buyer address missing either is not inherited at all, and the result is a paid order with nowhere to ship it and no error. If you hold a complete address the buyer record does not, send `mirrorTo.shippingAddress`. - **No buyer email means no store customer.** The store customer is keyed on email; send `mirrorTo.customer` yourself. On a direct charge, the top-level `billing_address` is **not** used as a fallback for the store order — it exists for the address-verification check with the payment provider. Only the buyer profile is inherited. Send `mirrorTo.shippingAddress` if the buyer record has no address. ### Fields `order`: | Field | Required | Type | Notes | |---|---|---|---| | `lineItems` | yes | array (1–100) | What was bought. See below. | | `shipping` | no | object | **Next Commerce only.** `amount` in minor units (counts toward the total), optional `label` up to 120 chars. Refused on Shopify — send shipping as a line item, see [the money rule](#the-money-rule). | | `coupon` | no | object | `code` (1–64 chars) and an optional display-only `amount`. | | `metadata` | no | object | Your own keys (≤64 chars) and values (≤500). | `order.lineItems[]`: | Field | Required | Type | Notes | |---|---|---|---| | `name` | yes | string (1–200) | What the buyer sees, and the line description on the store order. | | `quantity` | yes | integer (1–9999) | | | `unitAmount` | yes | integer | Price per unit in **minor units** — `1499` is $14.99. | | `variantId` | no | string or integer | Catalog id of the exact buyable variant. **Required per line on Next Commerce.** | | `sku` | no | string (1–255) | | `mirrorTo`: | Field | Required | Type | Notes | |---|---|---|---| | `platform` | yes | `"shopify"` or `"nextcommerce"` | | | `store` | yes | string (1–255) | The store host exactly — `acme.myshopify.com` or `acme.29next.store`. | | `customer` | no | object | `email` (required within), `firstName`, `lastName`. Inherited from the buyer when omitted. | | `shippingAddress` | no | object | Inherited from the buyer when omitted. | | `billingAddress` | no | object | Inherited from the buyer when omitted. | | `parentOrderRef` | no | string (1–64) | Append to an existing store order instead of creating one. **Next Commerce only** — see below. | | `upsellKey` | no | string (1–64) | Your key for one upsell decision. Valid only with `parentOrderRef`. | **`parentOrderRef` is Next Commerce only.** A Shopify order can be created but never appended to, so `platform: "shopify"` with `parentOrderRef` is refused before the charge with a `400 validation_error` whose message begins `Invalid order:` (the raw `mirror` block returns `mirror_upsell_unsupported` for the same case — a handler keyed on that code alone will not match). Send the extra item as its own new order. An address takes `line1`, `city`, `state`, `postalCode` and a two-letter `country`, plus optional `name`, `line2` and `phone`. Unknown fields are a `400`. ### Three things only the raw block can express {#raw-only} A store-priced [`voucher`](./next-commerce.md#voucher-real-discount), the per-charge [`skip`](#skip) opt-out and `attribution` are not reachable through `order` / `mirrorTo` — sending any of them there returns a `400`. For those, send a raw [`mirror` block](#raw-block). :::warning Do not drop a voucher to get past that error A `voucher` is a store-priced discount. Removing it so the request validates charges the buyer the discounted amount while the store records a full-price order, and the two never reconcile. Switch to a raw `mirror` block. ::: ### The raw `mirror` block {#raw-block} The raw block describes the same store order as `order` / `mirrorTo`, and it is the way to reach the three fields above. Send one or the other: a request carrying both is refused with `400 order_mirror_conflict`. Both create calls take the raw block at the **top level**, as a JSON object (never a string): - **Hosted checkout** (`POST /v1/sessions`): top-level `mirror`. A string under `metadata.mirror` is never parsed; sending both returns `400 mirror_dual_specification`. - **Direct charge** (`POST /v1/payment_intents`): top-level `mirror`, or the identical object at `metadata.mirror` (supported, no sunset). Send it in one place or, if identical, in both; two blocks that **differ** are rejected with `400 mirror_alias_conflict`. The raw block is **one shared contract** across every destination — the same fields (`contract_version`, `destination`, `shop`, `line_items`, `customer`, plus optional `skip`, `attribution`, `shipping_address`, `billing_address`, `metadata`). You pick the platform with `destination`; each platform's page below covers only what's specific to it. ### Skipping the store order for one charge {#skip} Set `mirror.skip` to `true` to record the payment normally and create **no** store order for that one charge — for an order you already entered in the store by hand, a test charge against a live store, or a correction. Identical on both create calls; it is a field on the raw `mirror` block only. `skip` cannot be combined with a `voucher` (the store computes a store-priced discount, so there would be nothing recording the store-derived total) — that combination returns a `400`. ## Supported platforms | Platform | Status | Guide | |---|---|---| | **Shopify** | Available | [Shopify order mirroring](/connected-platforms/shopify) | | **Next Commerce (29next)** | Available | [Next Commerce order mirroring](/connected-platforms/next-commerce) | ## Attribution and your own metadata Pass marketing / direct‑response data (`affiliate`, `utm_*`, funnel, and a `passthrough` map) as `attribution` on the raw block and it lands on the created order — Next Commerce maps it to the order's native attribution fields, Shopify to order custom attributes. Attach your own correlation data with `mirror.metadata`, and reference the charge from the order via the `vora_payment_intent_id` stamped on it. The raw block is capped at 4096 bytes, checked before the charge on hosted checkout and on Next Commerce direct charges. ## When the order is created The store order is created when the payment is **captured**, not when it is authorized. On an ordinary automatic capture those are the same moment. On a manual capture (`capture_method: "manual"`), **no store order exists** while the charge is only authorized, and the poll route reports `pending` until you capture (`POST /v1/payment_intents/{id}/capture`) — it does not advance on its own. On these accounts a mirrored order always represents money already captured; on [order-before-charge accounts](#order-before-charge-created) it does not. ## Confirming the order A successful charge is not proof the store order exists. Poll the route for the flow you used, or subscribe to the `mirror.order.created` webhook (it carries `payment_intent_id`, `order_number` and `order_status_url`). - **Hosted checkout**: `GET /v1/public/sessions/{sessionId}/mirror-order`, with your **publishable** key (`vp_pk_*`; a secret key is refused). - **Direct charge**: `GET /v1/payment_intents/{id}/mirror-order`, with your **secret** key (`vp_sk_*`). Both return `{ status, order_number, order_status_url }`; `order_number` and `order_status_url` are non-null **only** when `status` is `created`. `order_number` is the number the shopper and your store staff see, safe to print on a thank-you page: on Shopify the order name (for example `#1001`), elsewhere the platform's order number. A Shopify order created before 28 September 2026, or one recovered by an automatic retry, can carry the store's internal numeric id instead. `order_status_url` is a private link to that shopper's order: show it only to them, and do not log, store or share it. | `status` | What it means | |---|---| | `pending` | Not created yet. Keep polling, with a cap. On the direct charge this also covers a charge that carried no `mirror` block at all, which that route cannot tell apart from "not finished yet". | | `created` | The store order exists. | | `failed` | Terminal — stop polling. On the direct charge a `code` and a plain-language `message` say why. **Read `code` before you act**: most values mean the order was rejected and needs your attention, but one means it was released on purpose and needs nothing. See [What `code` tells you](#mirror-failure-codes). | | `not_mirrored` | Terminal, but read the warning below before you act on it. | ### What `code` tells you {#mirror-failure-codes} On the direct charge, a `failed` response carries a stable `code` and a merchant-safe `message`. Branch on `code`. | `code` | What happened | What to do | |---|---|---| | `mirror_line_invalid` | A line on the order was rejected — typically a product reference the store could not resolve. | Fix the line and resend. | | `mirror_connection_error` | The store connection needs re-authorizing. | Reconnect the store, then retry. | | `mirror_shipping_country_unsupported` | The store does not deliver to this order's country using the shipping method that was sent. The connection is healthy. | Add that country to the shipping method in the store's settings, or send a shipping method that already covers it. | | `mirror_rejected` | The store rejected the order for a reason we could not classify. | Compare the order's items, shipping method and delivery address against the store's settings. Reconnecting does **not** help here — a broken connection has its own code. | | `mirror_order_cancelled` | **Nothing is wrong.** An order was created before the payment, the payment did not complete, and the order was released. | **Nothing.** No order stands and no money moved. | `mirror_order_cancelled` only appears on accounts using order-before-charge — see below. ### `not_mirrored` is not proof that no order exists {#not-mirrored-index} `order_number` is null in every `not_mirrored` case, so the response cannot tell these apart: - the session carried no `mirror` block at all (hosted route only; the direct-charge route reports `pending` for that case); - the mirror was recorded but never dispatched — the store was disconnected, its authorization was revoked, or mirroring is off for the account. No order exists and the charge needs reconciling; - an order **was** created in the store and later **cancelled** there. :::danger Check the store before you reconcile or refund A second refund issued on the strength of `not_mirrored` is money out twice. Stop polling, open the store, then act. ::: ### On order-before-charge accounts, `created` does not mean the payment succeeded {#order-before-charge-created} Most accounts create the store order **after** the charge settles, so `created` implies money moved. Some accounts create the order first: on those, the direct-charge poll can report `created` for a payment that then fails — `created` answers *"does an order exist?"*, not *"has the payment completed?"*. Confirm payment from the payment intent's own `status` or the `charge.succeeded` webhook. If the payment never completes, the order is released automatically and the same endpoint switches to `failed` with `code: mirror_order_cancelled` — no order stands and no money moved. A captured payment always reports `created` with its `order_number`, so a settled order never hides behind a `failed`. ## Retrying safely Duplicate protection is per **payment**, not per cart: at most one store order per payment. - Retry with the **same** `Idempotency-Key` → one charge, one store order. - Retry the same cart with a **fresh** `Idempotency-Key`, or as a new payment → a **second charge and a second store order**. An order that lands in the store with fewer lines than you sent is never re-driven (re-driving could create a duplicate order); the poll still reports `created`, so compare the mirrored order's lines against what you sent if that matters to you. ## Testing: use reserved email addresses {#test-emails} When you test, give the store customer an email at a reserved test domain: any address at `example.com`, `example.org`, `example.net` or `@localhost`, or one ending in `.test`, `.invalid` or `.localhost`. A realistic address is refused before the charge with `mirror_sandbox_email_not_reserved`, on every store platform. The check runs on `POST /v1/sessions` and `POST /v1/payment_intents`, for a test key or any request from a sandbox account. ## Get started 1. **Connect** your store in the dashboard — each platform's guide has the exact steps: [Shopify](/connected-platforms/shopify), [Next Commerce (29next)](/connected-platforms/next-commerce). 2. **Send `order` and `mirrorTo`** on your create call and confirm the order appears in your store. --- ## Next Commerce (29next) Source: https://docs.vonpay.com/connected-platforms/next-commerce # Next Commerce (29next) order mirroring Send a `mirror` block with any Vora payment and, on a successful charge, Vora creates the matching **already-paid order** in your Next Commerce (29next) store. Vora charges the card; 29next stays your system of record for fulfilment and CRM. Post-purchase upsells consolidate onto the **same** order — one order, multiple payments. ## Before you send a mirror block 1. **Connect each 29next store** in your Vora dashboard → Integrations → Connected Platforms → **Connect 29next**. Paste the store host (`your-store.29next.store`) and a 29next **Admin API token** (Settings → API Access → Create App). Grant it the **full capability set** in [What the store key must be able to do](#store-key-capabilities) — a token scoped to orders alone connects cleanly and then fails every order. Repeat per store (see [Multiple stores](#multiple-stores)). 2. **Create an External Payment Method in 29next** (Settings → Payments → External Payment Methods → Add): name it anything and set its **Code** (we recommend `vora`). You enter the same code when you connect the store; it is per store. Set it explicitly — a blank Code is auto-generated. ## When the store key is refused {#store-key-refused} Vora tests the key with a live read against your store before saving it; a refused key stores nothing, so fix it and paste it again. These codes come from the **Connect** step in the dashboard, not from an API response: | Code | What it means | Fix | |---|---|---| | `scope_missing` | Your store recognised the key and **refused the request** — the key exists but isn't permitted to perform it. A permissions problem, not a wrong or expired key. | Grant the key the capabilities [below](#store-key-capabilities), on the key itself in your 29next admin, then paste it again. **A brand-new key does not help unless it carries those capabilities.** | | `auth_failed` | Your store didn't recognise the key at all — wrong value, revoked, or belonging to a different store. | Re-copy the key from your store admin, check it's for the host you entered, paste again. | | `not_found` | The host was reachable but the account behind it couldn't be resolved. Usually a typo in the store host. | Check the host matches your store exactly, then retry. | | `rate_limited` | Your store is throttling API calls. **Not a verdict on the key.** | Wait a minute and paste it again. | | `unavailable` | Your store didn't answer, or returned a server error. **Not a verdict on the key.** | Retry shortly. If it persists, check your store's status, then contact support. | | `webhook_provisioning_failed` | **The key worked, but Vora couldn't set up the notifications your store sends back.** On a **first-time** connect this refuses rather than half-completing. On a **reconnect** with a secret already on file, the same failure does NOT refuse — see the warning below. The response carries a `reason`; `scope_missing` there means the key isn't permitted to manage notifications. | Grant the key permission to **read and manage notifications** ([last row below](#store-key-capabilities)), then paste it again. If your store plan can't grant it at all, contact support **before** taking payments. | ### A refund only reaches the card once Vora is told about it {#refund-notification} A refund you enter in your store reaches the buyer's card only through the notification your store sends us, which Connect registers. A **first-time** connect that cannot register it is refused outright. A **reconnect** is not: if a working secret is already on file, a notification failure keeps that secret and reports **connected** — even though the subscription may have been removed at your store, and nothing surfaces that. After re-pasting a key, confirm one real refund reaches the card before relying on the connection. ### What the store key must be able to do {#store-key-capabilities} Vora talks to your store for the whole order lifecycle, so a read-only key is not enough. Grant a key that can do all of this: | Vora needs to | So that | |---|---| | Read store details | The Connect step can confirm the key works before saving it. | | Create orders | Your paid checkout becomes a real order in your store. | | Read orders and their lines | Vora can find an order again to settle, refund, or reconcile it. | | Mark an order paid | The order stops showing as unpaid once the card is charged. | | Add a line to an existing order | [Upsells](#post-purchase-upsells-one-order-multiple-payments) attach to the original order instead of creating a second one. | | Calculate and create refunds | A **full** refund you issue through Vora is recorded on the store order too; a partial one is not. Once any amount has been refunded through the API, refund the remainder the same way — a refund entered in the store after that does not reach the card. | | Cancel an order | An abandoned or unpayable order is released rather than left open. | | Create and delete carts | Vora can ask the store to price a discount code before charging, then clean up. Only needed if you use [store-priced vouchers](#voucher-real-discount). | | **Read and manage notifications** | Your store can tell Vora when something happens in it — above all, **when you issue a refund**. Vora is the processor, so a refund you record in your store only reaches the customer's card once Vora is told about it. Setup registers this automatically; without the permission it cannot — and a **first-time** connect is refused rather than half-completed. | :::warning A key that passes setup can still fail every order Connect performs only the first row and the notification setup in the last; everything in between is untested at that moment. A key that can read the store and manage notifications connects cleanly and then fails every order. Grant the full set up front. ::: ## Where the `mirror` block goes Both charge surfaces take the block at the **top level**; the direct-charge call also accepts it under `metadata.mirror`, and the two positions are equivalent there — both supported, no sunset. ```http POST /v1/sessions { "amount": 2900, "currency": "USD", "mirror": { "...": "..." } } ``` ```http POST /v1/payment_intents { "amount": 2900, "currency": "USD", "metadata": { "mirror": { "...": "..." } } } ``` ### Send the block as an object, and send it once {#block-object-once} On the direct-charge call, two refusals land **before the buyer is charged**: - **A stringified block** returns [`mirror_malformed`](#errors). In either position the mirror must be a JSON **object**; a block coerced to a string on the way out (`String(block)`, template-literal interpolation, an extra `JSON.stringify()`) arrives as `"[object Object]"`. - **Two different blocks in one request** — a top-level `mirror` and a `metadata.mirror` that do not match — return [`mirror_alias_conflict`](#errors). Identical blocks are fine. On both surfaces the block is validated strictly: an unknown key returns `validation_unknown_field`, and the serialized block must be ≤ 4096 bytes (`mirror_too_large`). To skip mirroring on a charge, omit the block. For a Next Commerce mirror, block size, store authorization and variant ids are all checked **pre-charge on both surfaces**: an unconnected store or a missing variant id is a `400`, not a payment followed by a failed mirror. `shipping_address` is checked pre-charge on the direct charge only: an order create without one is refused with [`mirror_missing_shipping_address`](#errors) and nothing is billed. On hosted checkout the buyer's typed address fills it; if none is usable, 29next refuses the order *after* the charge and the poll reports `failed`. ## The `mirror` block — field reference | Field | Required | Type / limit | Next Commerce notes | |---|---|---|---| | `contract_version` | ✅ | string (1–10) | `"2026-05-15"`. | | `destination` | ✅ | string: `shopify` \| `nextcommerce` | Selects the 29next adapter. | | `shop` | ✅ | string (1–255) | Your store host, e.g. `"your-store.29next.store"` — also selects **which** connected store to mirror to. | | `line_items` | ✅ | array 1–100 | `{ title, quantity, price, external_product_ref }`. | | `skip` | optional | boolean | Record the payment normally but create **no** store order for this one charge. Only `true` does anything: `skip: false` is treated exactly as if you had not sent the field. Cannot be combined with a `voucher` — a store-priced discount is computed by the store, so skipping the order would charge a store-derived total with nothing recording it. | | `settle_only` | optional | boolean | Record a payment against an order you built yourself: settles the existing order's balance and appends no line. Requires `parent_order_ref`, forbids `line_items`, and cannot be combined with `skip`. Every one of those is refused **before** any charge, so a bad combination costs you nothing. | | `customer` | ✅* | object | `email` (required), `first_name`, `last_name`. *Not required on an upsell.* | | `shipping_address` | **effectively required** | object | Neutral address. On `POST /v1/payment_intents` an order create without one is refused **before** the charge (`400 mirror_missing_shipping_address`); on hosted checkout the buyer's typed address fills it, and if none is usable 29next refuses the order *after* the charge. Omit it only on an upsell, which inherits the parent order's. | | `billing_address` | optional | object | Same shape; defaults to shipping. | | `shipping` | optional | object | `{ code?, price? }` — the order's shipping method code + amount. Omit → no shipping line is added (the mirrored order total is just your line items). See [Shipping method + amount](#shipping-method--amount). | | `attribution` | optional | object | Marketing / direct-response — maps to `order.attribution`. | | `metadata` | optional | object of string | Your own correlation data, carried onto the 29next order. | | `coupon` | optional | object | `{ code, amount? }` — **display-only** coupon/voucher shown on the order; never changes the total. See [Coupon (display-only)](#coupon-display-only). | | `voucher` | optional | object | `{ code }` — a **real, store-priced discount** that your store applies and that **reduces the order total**. Not the same as `coupon`. See [Voucher (real discount)](#voucher-real-discount). | | `parent_order_ref` | for upsells | string (1–64) | The initial order's `number`, **or** the initial charge's payment-intent id (`vpi_…`). Present → this charge is an **upsell appended** to that order; absent → create a new order. | | `upsell_key` | optional | string (1–64) | Your id for one upsell **purchase decision**. A later repeat returns [`409 upsell_duplicate`](#errors) and is not charged — with one narrow exception for simultaneous retries, see [`upsell_key`](#upsell-key). Upsells only. | ### Line items → 29next variants ```json { "title": "Rad Cat Tee", "quantity": 1, "price": "29.00", "external_product_ref": "12345" } ``` - `price` is a **string** (`"29.00"`). - **`external_product_ref` is required on every line** and must be the **sellable (variant) id** — the id of the specific buyable variant, a positive integer as a string. It maps to the order line's `product_id`. A line with no ref is refused before the charge with `mirror_line_missing_product_ref`. - **Do not send the parent product id.** A parent product has no price of its own, so 29next rejects the whole order — **after the buyer has been charged**, because a parent id is a valid positive integer that passes our pre-charge checks and only 29next can tell the two apart. The payment succeeds, no order is created, and the mirror-order poll reports **`failed`**. 29next resolves the parent from the sellable id itself; there is no separate variant or package field. - **Finding a variant id:** in 29next, call `GET /api/admin/products/{product_id}/` and use each entry in `variants[].id`. A single-variant product still has its own variant id — use that. :::note The raw block's item list is separate from the buyer's `mirror.line_items` describes the **29next order**; on hosted checkout the buyer's page reads the optional top-level `lineItems`, and neither derives from the other — describing the order only inside `mirror` yields a correct store order behind a checkout page showing nothing but a total. Send both, or use [`order.lineItems`](./index.md#describing-the-order), which feeds both. The shapes differ: `name` / `unitAmount` (minor units) on the buyer's list; `title` / `price` (decimal string) in the block. On a direct charge there is no buyer-facing page, so this does not apply. ::: ### Address ```json { "name": "Sam Rivera", "line1": "1 Test St", "line2": "Apt 4", "city": "Austin", "state": "TX", "postal_code": "78701", "country": "US", "phone": "5125550123" } ``` `line1`, `postal_code` and a 2-letter `country` are required when the address is present. Two fields are conditional: | Field | Rule | |---|---| | [`city`](#address-city-by-slot) | Required on **shipping**, optional on **billing** — but see [below](#billing-city). | | [`state`](#address-state-by-country) | Required for countries that have subdivisions, omitted for those that don't. | #### `city` depends on which address it is {#address-city-by-slot} `mirror.shipping_address` requires a city — an order with no city cannot be delivered, and the refusal names `shipping_address.city`. `mirror.billing_address` does not: it exists for address verification, which needs only street and postal code. An **absent** billing address makes this connector send `billing_same_as_shipping_address`, so the order carries the shipping address as its billing address. ### On Next Commerce, send a city on billing anyway {#billing-city} The API accepts a city-less billing address; this connector still needs one, because it packs the city into a field the store requires. What happens depends on when it is caught: - **Before the charge** — refused outright, nothing billed. The message tells you to send a city on `billing_address`, or drop the billing address entirely if it should match shipping. - **After the charge** (an upsell appended to an order that is already paid) — it cannot refuse without leaving a paid buyer with no order, so the billing address is **dropped** and the order carries the shipping address as billing. Send the city. #### `state` is required by country, not always {#address-state-by-country} Roughly 150 countries have no state or province. `state` is required only for countries that have one, and must be **omitted entirely** for countries that do not — an empty string is not the same as omitting it; `""` lands on the store order as the province. | Country | `state` | |---|---| | Has subdivisions (US, CA, AU, …) | **Required.** A US address without one is refused. | | Has none (SG, MC, …) | **Omit the field.** | The same per-country rule governs the address a buyer types on our hosted page — the form, the pay button and the store projection read one rule. ### Shipping method + amount ```json "shipping": { "code": "express", "price": "12.50" } ``` Optional. `code` is your store-configured shipping method code (e.g. `default` / `express`); `price` is a non-negative decimal string. Omit the whole block and no shipping line is added — the mirrored order total is just your line items, with no store-default shipping charge. Set `price` (and optionally `code`) so the store's shipping total matches what Vora charged; a `code` without a `price` lets that store method price itself. ### Attribution → native 29next order fields ```json "attribution": { "affiliate": "acme", "funnel": "spring-promo", "utm_source": "newsletter", "utm_campaign": "spring", "passthrough": { "affid2": "xyz", "landing_page": "/lp/spring" } } ``` Named fields (`affiliate`, `subaffiliate1-5`, `funnel`, `utm_*`) map 1:1 to `order.attribution.*`, capped at 255 characters each; anything under `passthrough` lands in `order.attribution.metadata`, capped at 2000 characters per value, so a full landing-page URL fits. ### Discounts: two options Pick by whether the code should **change the order total**, and send one of them for a given discount, not both: | | `coupon` (display-only) | `voucher` (real discount) | |---|---|---| | Effect | Records the code as a label | Your store applies it and **lowers the order total** | | Order total | Unchanged | Recomputed by the store's promotions engine | | Where it shows | The order's metadata | A discount line in the order's Payment Summary | | You charge | Whatever you decide | The store's discounted total (from the quote endpoint) | ### Coupon (display-only) ```json "coupon": { "code": "SUMMER25", "amount": "10.00" } ``` Optional. `code` (required) is the code itself — an opaque code, never buyer-identifying text; `amount` (optional) is a display-only decimal string. It never changes the mirrored order total: it is written to the 29next order's `metadata` as `coupon_code` / `coupon_amount`, not as a discount line, and only on the initial order create — not on an upsell append. ### Voucher (real discount) ```json "voucher": { "code": "SUMMER25" } ``` A real discount: your store's own discount engine applies the code, a discount line appears on the order and the **order total is reduced**. The code must be a voucher configured and active in your 29next store. Store-priced vouchers work on the direct-charge call (`POST /v1/payment_intents`) with `destination: "nextcommerce"`. Hosted checkout bills the amount fixed at session-create and never re-checks a discounted total, so a `voucher` on `POST /v1/sessions` returns `400 mirror_voucher_disabled`; use [`coupon`](#coupon-display-only) there to record the code without changing the total. Your store owns the discount math — computed from the prices you send, **excluding shipping**, with 29next's own rounding — so you charge the amount it returns: 1. **Send full-price line items** and the `voucher` code. Pre-discounted prices with a voucher would be discounted a second time, leaving the order total below what you charged. 2. **Ask for the amount to charge** with [`POST /v1/mirror/quote`](#pricing-a-discounted-basket-post-v1mirrorquote). It returns the discounted item total; add your shipping. 3. **Charge exactly that amount.** Vora re-checks it against the store before the charge and refuses a mismatch — a clean rejection you retry, never a mis-charge. Rules, all enforced before the charge: - **The order total must equal what you charged** — verified before the card is charged and again before the order is marked paid; a mismatch there leaves the order unpaid for review rather than recorded wrong. - **Put the discount on the initial order** — a `voucher` with `parent_order_ref` is rejected. - **Shipping must have a price.** A shipping `code` with no `price` alongside a voucher is rejected: the store would price that method itself, so the total cannot be known before the charge. - **`code` must be an opaque code**, never buyer-identifying text. Pre-charge errors from `POST /v1/payment_intents`: | Code | HTTP | Meaning | |---|---|---| | `mirror_voucher_amount_mismatch` | 400 | The amount doesn't equal the store's discounted total. Re-quote and charge that. | | `mirror_voucher_rejected` | 400 | The store didn't apply the code — check it exists and is active, or drop the voucher. | | `mirror_voucher_unverifiable` | 503 | The store couldn't price it right now. Retry. Not a verdict on your code or amount. | ## Pricing a discounted basket: `POST /v1/mirror/quote` When a buyer applies a discount code, ask the store what the basket costs **before you charge**. Server-to-server, secret key. Call it on an explicit "apply code" action, not on every page load — it shares the connected-store request budget with order creation and refunds. ```json title="Request" { "destination": "nextcommerce", "shop": "your-store.29next.store", "currency": "USD", "line_items": [{ "line_ref": "12345", "quantity": 1, "unit_amount": 2900 }], "voucher_code": "SUMMER25", "shipping_amount": 500 } ``` - `line_ref` is the **sellable (variant) id**, the same value as `external_product_ref`; `unit_amount` is your **full** price for one unit, in **minor units**. The store discounts off this. - `voucher_code` is optional. Omit it to price the basket with no code: a store can run always-on promotions that apply to any basket, and quoting once without the code and once with it shows how much the code itself is worth. - `shipping_amount` (optional, minor units) is the shipping you will send. Supply it and the response includes `amount_to_charge` — one number to bill; omit it and add your own shipping to `item_total`. Always inspect `status` — a `200` does not mean a discount applied: ```json title="Response (quoted)" { "status": "quoted", "amount_to_charge": 3110, "item_total": 2610, "undiscounted_item_total": 2900, "discount_amount": 290, "attributed_discount_amount": 290, "unattributed_discount_amount": 0, "discount_attribution": "complete", "shipping_amount": 500, "currency": "USD", "offer_name": "10% OFF" } ``` | `status` | What to do | |---|---| | `quoted` | Charge `amount_to_charge` (or `item_total` + your shipping if you didn't send `shipping_amount`). | | `code_rejected` | The store refused the code. Show a friendly message and charge without the discount. | | `unavailable` | No answer (store unreachable or throttled). **Not** a verdict on the code — don't tell the buyer it's invalid and don't charge a discounted amount. Retry, or charge undiscounted. | `item_total` is items and the discount only; charging it directly undercharges by your shipping. If your code discounts **shipping**, the quote cannot reflect that — it prices items only. ### How much of the discount the code actually earned {#discount-attribution} `discount_amount` is the whole discount. The store also returns the offers it applied, and the two do not always agree — an always-on store promotion lands in the total without appearing in the list. Three fields report that gap: | Field | What it is | |---|---| | `attributed_discount_amount` | The part the store credited to named offers, in minor units. | | `unattributed_discount_amount` | The remainder — discount applied but not tied to a named offer. **Signed** (see below). | | `discount_attribution` | `complete`, `partial`, or `unknown`. | `attributed + unattributed == discount_amount` whenever both are present. `unattributed_discount_amount` is **signed**: a negative value means the store's itemised offers add up to more than its stated total — a misconfiguration in that store, surfaced rather than hidden. :::warning Branch on `discount_attribution`, never on the amounts being zero `complete` means every unit is accounted for; `unknown` means the store returned an amount we could not read — and in that case both amounts are **`null`, never `0`**, because zero would assert that everything was accounted for. ::: ## Multiple stores One Von Payments merchant can connect **several 29next stores**. Repeat the Connect step per store, each with its own Admin API token and External Payment Method code; per-store tokens give hard cross-brand isolation. There is no separate routing rule — **`mirror.shop`** on each block *is* the router: ```jsonc "mirror": { "destination": "nextcommerce", "shop": "brand-a.29next.store", ... } // → Store A "mirror": { "destination": "nextcommerce", "shop": "brand-b.29next.store", ... } // → Store B ``` The connector loads that store's credentials for the order push, settle, refund and inbound-webhook verification. A `mirror.shop` that is not a connected, active store for your account returns **`400 mirror_shop_not_authorized`** before any charge. ## What happens on a successful charge 1. Vora charges the card. 2. The rail creates the order in 29next as `payment_method: "external"` (the External Payment Method you configured), then settles it to **paid** — no card is charged in 29next. 3. The order appears in your 29next admin with your line items, customer, address and attribution. :::warning The order is created on capture, not on authorization A `capture_method: "manual"` charge creates **no** 29next order while it sits authorized; the poll below reports `pending` and stays there until you capture (`POST /v1/payment_intents/{id}/capture`). ::: **Duplicate protection is per payment, not per cart.** Vora guarantees at most one 29next order per payment intent: re-driving a mirror internally never duplicates, and reusing your `Idempotency-Key` on a retry gives one charge and one order. Re-sending the same cart with a **fresh** `Idempotency-Key`, or as a new payment intent, is a new payment — a second charge and a second order. An upsell sent without [`upsell_key`](#upsell-key) has the same gap across charges. ## Getting the order back (webhook + retrieve) The 29next order is created **asynchronously**, a few seconds after the charge succeeds, so the order `number` is not in the charge response. **Webhook (recommended).** Subscribe to **`mirror.order.created`**: ```json { "payment_intent_id": "vpi_...", "order_number": "10023", "order_status_url": "https://your-store.29next.store/..." } ``` **Retrieve (poll).** One route per flow, both returning the same shape: ``` // Hosted / session flow — publishable key GET /v1/public/sessions/{sessionId}/mirror-order // Discrete payment-intent flow — secret key GET /v1/payment_intents/{id}/mirror-order → { status, order_number, order_status_url, code?, message? } status ∈ "pending" | "created" | "failed" | "not_mirrored" ``` - **`pending`** — not created yet, or a retriable hiccup: keep polling. On the discrete route it is also what you get when no record exists at all — the block never arrived — and while a charge is authorized but not captured. A `pending` that never resolves means one of those three; check them rather than continuing to poll. - **`created`** — `order_number` (and `order_status_url` when 29next provides one) are present. Use `order_number` for your thank-you page and as `parent_order_ref` on upsells; `order_status_url` is 29next's signed customer order page: a private link, so show it only to that shopper and do not log, store or share it. - **`failed`** — **terminal**: stop polling. The response adds a `code` and a merchant-safe `message`; branch on `code`. Most values are a rejection you fix and resend (an `external_product_ref` pointing at a parent product, say). One, `mirror_order_cancelled`, means an order was created before the payment, the payment did not complete and the order was released — nothing is wrong and nothing is owed. See [What `code` tells you](index.md#mirror-failure-codes). - **`not_mirrored`** — also terminal, reachable on both routes, and it does **not** prove no order exists: the charge carried no mirror block (hosted route only), the mirror was `skip`ped, or it was recorded but never dispatched (store disconnected, authorization revoked, mirroring switched off), or an order **was** created and later cancelled in the store. `order_number` is `null` in every case. :::danger Check the store before you reconcile or refund If the order was created and cancelled in 29next, a refund may already have been issued there; a second one on the strength of `not_mirrored` is money out twice. Once you have confirmed no order exists, the charge still stands — enter the order in 29next yourself, or refund it. ::: ## Post-purchase upsells (one order, multiple payments) A post-purchase upsell is another Vora charge — there is no separate upsell API. To append it to the buyer's existing 29next order, send the charge with `mirror.parent_order_ref`: ```http POST /v1/payment_intents // the upsell charge { "amount": 1500, "currency": "USD", "metadata": { "mirror": { "contract_version": "2026-05-15", "destination": "nextcommerce", "shop": "your-store.29next.store", "parent_order_ref": "10023", "upsell_key": "offer-7f3a-accepted", "line_items": [ { "title": "Warranty add-on", "quantity": 1, "price": "15.00", "external_product_ref": "67890" } ] } } } ``` Shown nested; the top-level `mirror` position works the same on this call and is the only position on `POST /v1/sessions`. - `parent_order_ref` makes this an **append**: Vora adds the upsell line to that order and records the payment as a second external transaction — one 29next order with N payments. Omit it to create a new order. - **You need not wait for the `order_number`.** `parent_order_ref` accepts the parent order's `number` **or** the parent charge's payment-intent id (`vpi_…`, the `id` in the base charge response). With the payment-intent id you can fire the upsell immediately: Vora resolves it to the order, retrying the append until the base order lands; if it never lands, the upsell's mirror-order status fails rather than stalling. - An upsell carries one or more `line_items` (up to 10), each appended to the parent order in one charge and checkpointed independently, so Vora re-driving *that charge* never double-appends. Several upsell products go in one charge's `line_items`, or in separate charges each with `parent_order_ref`. - `customer` and `shipping_address` are not required on an upsell (inherited from the parent). - The reference is validated against your account and store — an upsell to an order you do not own, or on a different store, is rejected. ### `upsell_key` — don't let a retry charge twice {#upsell-key} Reusing the `Idempotency-Key` header on a retry is the first-line answer to a timeout. `upsell_key` covers the case that header cannot: an integration that generates a fresh idempotency key on each attempt. It is **your identifier for the buyer's purchase decision** — an offer-acceptance id, not a per-request id — sent on every attempt at that one decision: | | | |---|---| | **Type** | string, 1–64 chars, optional | | **Where** | inside the `mirror` block, alongside `parent_order_ref` | | **Scope** | one key per connected store | | **Valid on** | upsells only — sending it without `parent_order_ref` is rejected at parse (`use the Idempotency-Key header for order creates`) | Vora checks the key **before charging**. A later charge carrying a key already held is refused with [`409 upsell_duplicate`](#errors) — the buyer is **not** charged, and the response carries `original_payment_intent_id`, the payment that already covered the decision. Treat it as success you already have: read that payment via `GET /v1/payment_intents/{original_payment_intent_id}` and show the buyer the result. Do not retry with the same key, and do not strip the key to force the charge through. :::warning Two *simultaneous* retries are caught after the charge, not before If two re-fires of the same key are in flight at the same moment, both can pass the pre-charge check and both can be charged. A database constraint catches the collision immediately afterwards: the second is refused before any line is appended and raised as a high-priority alert for reconciliation — the order never gains a duplicate line, but that second charge needs reversing. Space your retries instead of firing them in parallel. ::: ## Settle-only: record a payment against an order you built yourself {#settle-only} When you have already added the line to the 29next order yourself and only need Vora to take the money and record it against that order's balance, send `settle_only`: ```json { "amount": 1500, "currency": "USD", "mirror": { "contract_version": "2026-05-15", "destination": "nextcommerce", "shop": "acme.29next.store", "parent_order_ref": "10021", "settle_only": true, "customer": { "email": "casey@example.com" } } } ``` Vora charges the card and settles that order's balance, appending **no** line. The rules, all enforced before the charge: | Rule | Why | |---|---| | Requires `parent_order_ref` | It names the existing order the payment is recorded against. Without it there is nothing to settle. | | Must not carry `line_items` | You built the line yourself. Send lines and Vora would add a second one. Drop `line_items`, or drop `settle_only` and let Vora add the line. | | Next Commerce only | Not supported on other destinations. | | Cannot combine with `skip` | `settle_only` records a payment by settling an existing order's balance; `skip` creates no store order at all. Together the buyer is charged with nothing recording it. | | Refused if unavailable on the call | If `settle_only` is not available for the request as sent, it is rejected with [`mirror_settle_only_disabled`](../reference/error-codes.md#mirror_settle_only_disabled) **before the card is charged**. | ## Correlating the order back to Vora Every mirrored order carries the Vora payment reference in its 29next `order.metadata`. The canonical key is **`vora_payment_intent_id`**; the older `von_payment_intent_id` is still written alongside it with the identical value. Both are stamped last, so a key of your own in `mirror.metadata` can never shadow them. Resolve the full Vora record via `GET /v1/payment_intents/{id}`. ## Attaching your own metadata - `payment_intents.metadata` (open JSON, ≤ 8KB) is retained on the Vora payment intent and readable via `GET /v1/payment_intents/{id}`. - `mirror.metadata` (`Record`) is carried onto the 29next **order** itself, visible directly in the CRM. ## Complete example (initial checkout) Hosted checkout, so this session carries two item lists — `lineItems` for the buyer's page and `mirror.line_items` for the 29next order: ```http POST /v1/sessions { "amount": 2900, "currency": "USD", "metadata": { "your_order_id": "A-1001" }, "lineItems": [ { "name": "Rad Cat Tee", "quantity": 1, "unitAmount": 2900 } ], "mirror": { "contract_version": "2026-05-15", "destination": "nextcommerce", "shop": "example-store.29next.store", "line_items": [ { "title": "Rad Cat Tee", "quantity": 1, "price": "29.00", "external_product_ref": "12345" } ], "customer": { "email": "buyer@example.com", "first_name": "Sam", "last_name": "Rivera" }, "shipping_address": { "line1": "1 Test St", "city": "Austin", "state": "TX", "postal_code": "78701", "country": "US" }, "attribution": { "utm_source": "newsletter", "utm_campaign": "spring" }, "metadata": { "your_order_id": "A-1001" } } } ``` ## End-to-end test checklist 1. **Connect** your 29next store and confirm the wizard shows connected. 2. Grab a real 29next **variant id** (use as `external_product_ref`). 3. Fire the initial charge with a Vora test card and a customer email at a [reserved test domain](/connected-platforms#test-emails), such as `@example.com`; complete the returned `checkout_url`. 4. Catch the **`mirror.order.created`** webhook (or poll the retrieve) → note the `order_number`. 5. In 29next → **Orders**, confirm a new **paid** order with the right line items, customer, address, attribution — and the `vora_payment_intent_id` in the order metadata. 6. Send an **upsell** charge with `parent_order_ref` = that `order_number` and an `upsell_key`; confirm the **same** order now has a second line + a second payment. 7. **Replay that exact upsell charge** with the same `upsell_key`. Confirm you get `409 upsell_duplicate`, that no second charge appears, and that `original_payment_intent_id` points at the charge from step 6. ## Errors All are `400` unless noted. | Response | Meaning | Fix | |---|---|---| | `validation_unknown_field` | Unexpected key inside the `mirror` block | Send only the documented fields. The block itself is accepted at the top level on both charge calls, so this is about a key *within* it. | | `mirror_malformed` | The mirror on `POST /v1/payment_intents` isn't a JSON object (typically stringified or coerced to `"[object Object]"`) | Send the block as an object, not a string, in either position. | | `mirror_alias_conflict` | `POST /v1/payment_intents` carried a top-level `mirror` **and** a `metadata.mirror`, and the two differ | Send one block. Identical blocks in both places are accepted; only a disagreement is refused. See [Where the `mirror` block goes](#where-the-mirror-block-goes). | | `mirror_too_large` | Block > 4096 bytes | Trim line items / metadata. | | `mirror_amount_below_line_items` | The block's `line_items` + `shipping` come to **more** than the `amount` being charged. Refused **before** the charge — nothing moved. One-sided: charging more than the lines itemise is allowed. **Exempt when `mirror.voucher` is present**, because full-price lines against a store-discounted total is the correct shape there | Don't subtract a discount yourself — send it as [`mirror.voucher`](#voucher-real-discount) and let the store price it. Otherwise make `amount` cover the lines and shipping you're mirroring. The response carries `charge_amount` and `mirror_line_items_sum`. | | `mirror_line_missing_product_ref` | A line has no `external_product_ref` | Add the 29next variant id to each line. | | `mirror_missing_shipping_address` | An order create carried no `shipping_address`. On `POST /v1/payment_intents` this is a `400` **before** the charge. On hosted checkout, where the buyer's typed address normally fills it, a create that still has none fails at the store **after** the payment — the poll / webhook reports a terminal `failed` | Send `shipping_address` on every order create, or collect it on the hosted page with `shipping: "required"`. | | `mirror_sandbox_email_not_reserved` | While testing, the store customer's email is not at a reserved test domain. Refused before the charge | Use an `@example.com` (or `.test` / `.invalid` / `.localhost`) address. See [Testing](/connected-platforms#test-emails). | | `mirror_shop_not_authorized` | `shop` isn't a connected, active store for your account | Reconnect; confirm `shop` = your `*.29next.store` host. | | `mirror_dual_specification` | On `POST /v1/sessions`: sent both the top-level `mirror` and a legacy stringified `metadata.mirror` | Keep the top-level `mirror`; drop `metadata.mirror`. On `POST /v1/payment_intents`, `metadata.mirror` is a supported object and the equivalent clash is [`mirror_alias_conflict`](#errors) instead. | | `mirror_voucher_disabled` | A `voucher` was sent on `POST /v1/sessions`. Hosted checkout doesn't support store-priced vouchers: the charge bills the amount fixed at session-create and never re-checks a discounted total. Refused before the charge | Move the charge to [`POST /v1/payment_intents`](#voucher-real-discount), or use [`mirror.coupon`](#coupon-display-only) to record the code without changing the total. Don't drop the voucher and charge the discounted amount anyway: the 29next order would be created at full price and permanently disagree with the money taken. | | **`409` `upsell_duplicate`** | This `upsell_key` is already held — the decision was already paid for. **The buyer was not charged.** | Read `original_payment_intent_id` off the response and use that payment's status. Don't retry the key; don't strip it. See [`upsell_key`](#upsell-key). | --- ## Shopify Source: https://docs.vonpay.com/connected-platforms/shopify # Shopify order mirroring A successful payment can create the matching **paid order** on a connected Shopify store. You opt in per charge by describing the order on the request — with [`order` / `mirrorTo`](./index.md#describing-the-order), or with the raw `mirror` block this page documents in full. ## Before you start The store you name must already be **connected** to your Von Payments account (dashboard → **Connected Platforms → Shopify**). You can connect more than one store; `mirror.shop` selects which one each charge mirrors to. A `mirror.shop` that is not a connected, active store is refused with [`mirror_shop_not_authorized`](#errors). ### Required Shopify permissions {#permissions} The one-click install asks Shopify for: ``` write_orders, write_customers, read_customers, read_orders, write_draft_orders ``` **`write_orders`** (create the order) and **`write_customers`** (attach the buyer) are load-bearing; without either, no mirror can succeed. A store connected by pasting an Admin API token can be left with no recorded permissions, and the two cases behave differently: - **The known grant is missing a load-bearing permission.** Hosted checkout refuses the charge before any money moves with [`mirror_shop_missing_scopes`](#errors) (`422`). A raw `mirror` block on `POST /v1/payment_intents` is not checked up front: the charge succeeds and the order fails afterwards. - **No grant is recorded.** Nothing is refused; the charge succeeds, and the order-create discovers the problem, so the [order poll](#confirming) reports `failed`. If orders are not appearing and the poll reports `failed`, reconnect through the one-click install before debugging anything else. ## Where the block goes `mirror` is an optional top-level field on both charge surfaces — a JSON **object**, never a string. Attach it and the order is mirrored when the payment completes; omit it and nothing is mirrored. ```http POST /v1/sessions { "amount": 2998, "currency": "USD", "mirror": { "...": "..." } } ``` ```http POST /v1/payment_intents { "amount": 2998, "currency": "USD", "mirror": { "...": "..." } } ``` On `POST /v1/payment_intents` the identical object is also accepted at `metadata.mirror`; send it in one place or both, and two blocks that **differ** are refused with [`mirror_alias_conflict`](#errors). On `POST /v1/sessions`, `metadata` values are strings and a stringified block there is **never parsed** — the payment succeeds and no order is created; sending both is refused with [`mirror_dual_specification`](#errors). ### On the direct charge, most of a bad Shopify mirror is not rejected, and the charge still succeeds {#direct-charge-mirror-checks} On hosted checkout the block is checked **before** the buyer is charged: a bad block is a `400` and no money moves. On a direct-charge Shopify mirror, these checks run before the charge: 1. The mirror value is not a JSON object → [`mirror_malformed`](#errors). 2. The block contains the reserved key `_gdpr_redacted`, at any depth → [`mirror_malformed`](#errors). Rename yours if you carry your own privacy state in the block. 3. The block carries `parent_order_ref` → [`mirror_upsell_unsupported`](#errors). A Shopify order is created, never appended to. 4. The block carries `settle_only` → [`mirror_settle_only_disabled`](#errors). 5. The block carries `voucher` or `shipping` → [`mirror_shopify_voucher_unsupported` / `mirror_shopify_shipping_unsupported`](#accepted-not-carried). 6. The block's `line_items` add up to **more** than `amount` → [`mirror_amount_below_line_items`](#amount-shortfall). Everything else is checked **after** the charge: an unconnected store, missing [permissions](#permissions), an oversized block or a well-formed block that is wrong in content produces a completed payment and an order that never appears, with no error on the charge response. :::warning Never treat a `201` on this path as proof the order exists Confirm it: poll `GET /v1/payment_intents/{id}/mirror-order` or subscribe to `mirror.order.created` — see [Confirming the order](#confirming). Describing the order with [`order` / `mirrorTo`](./index.md#describing-the-order) runs those checks **before** the charge on Shopify too. ::: ### Charging less than the order is worth {#amount-shortfall} `amount` and the mirror's line items may differ: `amount` is what the card is charged, the mirror describes the store order, and tax, fees, Von-only discounts and partial mirrors legitimately diverge. **One direction is refused:** if `line_items` add up to **more** than `amount`, the request returns `400` [`mirror_amount_below_line_items`](#errors) and nothing is charged — otherwise Shopify would create the order at the higher figure, marked paid and fulfillable, against the smaller amount actually captured. Charging *more* than the lines itemise stays allowed. The error carries `charge_amount` and `mirror_line_items_sum`. The check stands down in three cases: a line price that cannot be read in the session currency, an absent `amount` (optional on hosted checkout), and `mirror.skip: true`. Do not lower `amount` below the lines to apply a discount: on Shopify, itemise only what you are charging for; on [Next Commerce](./next-commerce.md#voucher-real-discount), send `mirror.voucher` and let the store price it. The `mirror` block is strict wherever it is validated: a typo or an extra field returns [`validation_unknown_field`](#errors). ## Describing the items {#two-lists} State the items once, in `order.lineItems`, and Vora uses them for both the buyer's checkout page and the Shopify order: ```json "order": { "lineItems": [ { "name": "Trail Runner - Size 10", "quantity": 1, "unitAmount": 3499, "sku": "TR-10" }, { "name": "Standard shipping", "quantity": 1, "unitAmount": 599 } ] }, "mirrorTo": { "platform": "shopify", "store": "acme.myshopify.com" } ``` Prices are integers in minor units and must add up to `amount`, checked before the card is touched. `order.shipping` is refused on Shopify before the charge (`400 validation_error`, message beginning `Invalid order:`) — send shipping as its own line, as above. Field reference and buyer-inheritance rules: [Describing the order](./index.md#describing-the-order). ## Field reference — the raw `mirror` block The raw block is the other way to describe the order, and the only way to reach `attribution` and `skip` on Shopify. Five fields are required: | Field | Required | Type | Notes | |---|---|---|---| | `contract_version` | yes | string (1–10) | Block-shape version. Use `"2026-05-15"`. | | `destination` | yes | string: `shopify` \| `nextcommerce` | The connected platform. Send `"shopify"`. | | `shop` | yes | string (1–255) | Your store's `.myshopify.com` hostname (e.g. `your-store.myshopify.com`). Must be a store you've connected; with several connected, it selects which one. | | `line_items` | yes | array 1–100 | The order lines mirrored to Shopify. Each item: [see below](#line-items). This is **not** the buyer-facing list — see the note above. | | `customer` | yes | object | The customer recorded on the Shopify order. [See below](#customer). | :::note The raw block's item list is separate from the buyer's `mirror.line_items` describes the **store** order; the buyer's checkout page reads the optional top-level `lineItems`, and neither derives from the other — describing the order only inside `mirror` yields a correct Shopify order behind a checkout page showing nothing but a total. Send both, or use `order.lineItems`, which feeds both. The shapes differ: `name` / `unitAmount` (minor units) on the buyer's list; `title` / `price` (decimal string, `"14.99"`) in the block. ::: ### Optional fields Shopify honours {#optional-fields} | Field | Type | Notes | |---|---|---| | `shipping_address` | object | Written to the Shopify order's shipping address. Omit it and the order is still created and paid, but [flagged as unshippable](#degradations). | | `billing_address` | object | Written to the order's billing address. Send it explicitly: omit it and the slot is filled only by a billing address the buyer typed on our checkout — otherwise the order has [no billing address at all](#degradations). | | `skip` | boolean | Per-transaction opt-out. `true` records the payment normally but creates **no** Shopify order for that one charge. Useful for an order you already entered in the store yourself, or a correction. Omit it (or send `false`) for normal behaviour. | Both addresses take the same shape: ```json { "name": "Sam Rivera", "line1": "1 Test St", "line2": "Apt 4", "city": "Austin", "state": "TX", "postal_code": "78701", "country": "US", "phone": "5125550123" } ``` `line1`, `postal_code` and a 2-letter `country` are required when the address is present; `name`, `line2` and `phone` are optional. Two fields are conditional: - **`city`** is required on `mirror.shipping_address` (an order with no city cannot be delivered; the refusal names `shipping_address.city`) and optional on `mirror.billing_address`, which needs only street and postal code for address verification. The Shopify adapter maps shipping and billing independently: an omitted billing address is never substituted with the shipping one. - **`state`** is required by country, not always. Send it for countries that have states or provinces (US, CA, AU) and **omit it entirely** for countries that do not — an empty string lands on the store order as the province. Rule: [`state` by country](./next-commerce.md#address-state-by-country). ### Fields the block has that Shopify does not support {#unsupported-fields} Sending one is refused **before** the charge: | Field | On a Shopify mirror | |---|---| | `parent_order_ref` | Upsell append — a Shopify order can only be created, never appended to. Refused with [`mirror_upsell_unsupported`](#errors). | | `upsell_key` | Only meaningful for an upsell append, so it has no effect here either. | | `settle_only` | Next Commerce only — it records a payment against an order you built yourself, which Shopify has no equivalent for. Refused with [`mirror_settle_only_disabled`](../reference/error-codes.md). | ### Fields Shopify accepts and does not carry {#accepted-not-carried} | Field | Type | On a Shopify mirror | |---|---|---| | `attribution` | object | **Carried** — lands on the order's custom attributes. [See below](#attribution-shopify). | | `metadata` | object of string | **Carried** — same place. [See below](#attribution-shopify). | | `coupon` | object | Accepted, then **dropped** — a display-only label; it changes no total. | | `shipping` | object | **Refused before the charge** — a raw block on `POST /v1/payment_intents` returns [`mirror_shopify_shipping_unsupported`](#errors); `POST /v1/sessions` and `order` / `mirrorTo` return `validation_error`. Fold shipping into `line_items[]` as a line whose price is the shipping cost, so the order total matches what you charge. | | `voucher` | object | **Refused before the charge** — a raw block on `POST /v1/payment_intents` returns [`mirror_shopify_voucher_unsupported`](#errors); `POST /v1/sessions` and `order` / `mirrorTo` return `validation_error`. [See below](#voucher-shopify). | | `external_product_ref` | string (≤255) | Accepted, then **dropped** — Shopify uses its own `variant_id`, so every mirrored line is a custom line. | `coupon` and `external_product_ref` are accepted and never written to the order — the request succeeds; dropping `external_product_ref` is why [stock is never reduced](#degradations). An *unknown* field is rejected with [`validation_unknown_field`](#errors). The two `mirror_shopify_*` codes are what the raw block on `POST /v1/payment_intents` returns; on `POST /v1/sessions`, and for `order` / `mirrorTo` on either route, the same refusal comes back as `400 validation_error` before the charge. ### Where attribution and metadata land on the order {#attribution-shopify} `mirror.attribution` and `mirror.metadata` are written to the Shopify order's **custom attributes** — the *Additional details* block on the order page, visible with no setup and included in the standard order CSV export. Fields carried: `affiliate`, `subaffiliate1`–`5`, `funnel`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_content`, `utm_term`, your `attribution.passthrough` map, and `mirror.metadata`. This is custom data an affiliate app must be configured to read; Shopify does not let an app write its native attribution, so its built-in marketing reports never see it. - A key beginning `vora_` is silently ignored — that namespace is ours (`vora_payment_intent_id` links the order to the payment). Rename yours. - Merchant attributes are capped at 50 per order; the overflow is dropped, not refused, because the order is created after the buyer is charged. Attribution is not available on the `order` / `mirrorTo` shape (it is rejected as unknown); send a raw `mirror` block for it. ### `voucher` is refused on Shopify — charge the full line-item total {#voucher-shopify} Shopify does not price store vouchers, so the field is refused **before the card is charged**: a raw block on `POST /v1/payment_intents` returns [`mirror_shopify_voucher_unsupported`](#errors); `POST /v1/sessions` returns `400 validation_error`. Dropping it instead would charge the discounted `amount` against a full-price order. Charge Shopify mirrors at the full line-item total; to record a code for display, send it as `coupon`. Store-priced vouchers are a [Next Commerce](./next-commerce.md#voucher-real-discount) capability. ### `line_items[]` {#line-items} Between 1 and 100 items. | Field | Required | Type | Notes | |---|---|---|---| | `title` | yes | string (1–255) | Product/line name. | | `quantity` | yes | integer (1–9999) | Units ordered. | | `price` | yes | string (≤50) | Unit price as a **decimal string** in your store currency, e.g. `"14.99"`. Not minor units. | | `external_product_ref` | no | string (≤255) | A catalog reference for platforms that use one. **Shopify does not read it** (see [degradations](#degradations)). | The charge `amount` is what Von Payments captures; `mirror.line_items` is what appears on the Shopify order. A divergence (tax, fees, discounts, partial mirrors) is not rejected, except the [one direction above](#amount-shortfall). ### `customer` {#customer} | Field | Required | Type | Notes | |---|---|---|---| | `email` | yes | string (email, ≤254) | The buyer's email; recorded on the Shopify order. | | `first_name` | no | string (≤100) | | | `last_name` | no | string (≤100) | | Only these three fields are accepted; a fourth key is rejected. Addresses go on the block-level [`shipping_address` / `billing_address`](#optional-fields). If the buyer types a phone on our checkout and you did not supply one, `customer.phone` is filled for you; a `customer` object is never created where none was sent. ## Three things a Shopify mirror quietly does not do {#degradations} None of these return an error; each produces a paid charge and a Shopify order that is wrong in a way nothing on our side reports. ### 1. Stock is never reduced `external_product_ref` (and any other catalog id) is not carried to Shopify. Every mirrored line is a **custom line**: title, quantity and price only — no product link, no inventory decrement, no product-level analytics. ### 2. No delivery address still creates a paid, unshippable order Omit `shipping_address` and Shopify still creates the paid order, with no delivery address, marked so you can find it: | Marker | Value | |---|---| | Order tag | `vora-no-shipping-address` | | Order note attribute | `vora_no_shipping_address` = `true` | Filter your Shopify orders on the `vora-no-shipping-address` tag. "Missing" means unusable: `line1`, `city` and `country` must all be non-empty; an address carrying only a country counts as none. On hosted checkout the address the buyer types under [`shipping: "auto"` or `"required"`](../integration/create-session.md#buyer-fields) is merged into the block before the store order is built, phone included. What you send always wins; the merge fills gaps only: | Your `mirror.shipping_address` | What the buyer typed | | --- | --- | | omitted | used, phone included | | sent, without a `phone` | only the phone is filled in | | sent, with a `phone` | ignored — your block is unchanged | The buyer's typed billing address fills an absent `billing_address` the same way, and their typed phone an absent `customer.phone`. The typed address is used only if it carries a street line, a postal code, a two-letter country, a city and — in countries that have them — a state; miss any one and the **entire** address is dropped, which is how a tagged order still happens. Three ways to get there: - `shipping: "auto"` does not demand a complete address. Use `shipping: "required"` when the order must ship — Pay stays disabled until the address is complete, and the same rule runs again on our server before the charge. - An over-long value counts as missing: a street line over 255 characters, a city over 120, a postal code over 32 or a state over 120 drops the whole address rather than trimming it. - A name over 200 characters or a second address line over 255 drops on its own, and nothing tags it — the order ships to the street address without the apartment line. If you already know the destination, set `mirror.shipping_address` at create time. ### 3. No billing address means the order has none There is no fallback to the shipping address. When you omit `billing_address` it is filled by a billing address the buyer typed on our checkout ([`billingAddress: "auto"` or `"required"`](../integration/create-session.md#buyer-fields)) or by `mirrorTo.billingAddress`; if neither applies the order carries no billing address, which silently breaks store workflows keyed on it — tax and nexus determination, fraud review, invoicing. :::warning A half-filled billing address is dropped whole, and nothing tags it `billingAddress` is `auto` by default and nothing checks that what the buyer typed is complete: miss the street line, postal code, country, or a state in a country that has one, and the entire billing address is discarded. There is no tag for this, unlike shipping. If your tax, invoicing or fraud-review workflow depends on it, send `billing_address` yourself or set `billingAddress: "required"`. A billing address is accepted without a city. ::: ## Worked example A create-session request that charges `$29.98` and mirrors a two-unit order — note the two item lists in their two shapes, and the two addresses: ```bash curl https://checkout.vonpay.com/v1/sessions \ -H "Authorization: Bearer vp_sk_live_..." \ -H "Content-Type: application/json" \ -d '{ "amount": 2998, "currency": "USD", "successUrl": "https://your-store.example.com/thanks", "cancelUrl": "https://your-store.example.com/cart", "lineItems": [ { "name": "Premium Widget", "quantity": 2, "unitAmount": 1499 } ], "mirror": { "contract_version": "2026-05-15", "destination": "shopify", "shop": "your-store.myshopify.com", "line_items": [ { "title": "Premium Widget", "quantity": 2, "price": "14.99" } ], "customer": { "email": "buyer@example.com", "first_name": "Ada", "last_name": "Lovelace" }, "shipping_address": { "name": "Ada Lovelace", "line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "country": "US" }, "billing_address": { "name": "Ada Lovelace", "line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "country": "US" } } }' ``` The response is a normal session object (`checkoutUrl`, `expiresAt`, …); the Shopify order is created after the buyer completes payment. ## After the checkout completes - **On success** the order appears in your **Shopify orders feed**. The **Connected Platforms → Shopify** dashboard lists only mirrors that are retrying or parked, so a healthy integration shows nothing there; to check one charge, use the [confirmation methods below](#confirming) or open the payment under **Transactions**. - **On a transient failure** the mirror retries automatically — five attempts over roughly two and a half hours — then is parked for manual review. A failed mirror never reverses a completed charge. - **Re-run a parked order** from the **Connected Platforms → Shopify** dashboard. ## Refunding a mirrored order {#refunds} :::danger Shopify's own Refund button moves real money A mirrored order is created paid, with a sale transaction attached for the full amount captured. On an order with no refund through our API yet, **refunding it inside Shopify issues a real card refund through Von Payments, automatically, for the amount you refunded in the store.** ::: Pick one side per order. A full refund through `POST /v1/refunds` is pushed onto the Shopify order for you; a partial one is not (the store order is left as it was). Either way the order is marked as refunded from our side, and **refunds entered in the store after that never reach the card** — the store shows them refunded and the buyer receives nothing. So once any amount has gone back through the API, refund the remainder the same way. Refunding in the store first, in full or in part, reaches the card each time; the amount is capped at the charge's remaining balance. ## Confirming the order was created {#confirming} A successful charge does not guarantee the Shopify order exists. Poll the route for the flow you used, or subscribe to the `mirror.order.created` webhook: - **Hosted checkout** — `GET /v1/public/sessions/{sessionId}/mirror-order` with your **publishable** key (`vp_pk_*`; a secret key is refused). - **Direct charge** — `GET /v1/payment_intents/{id}/mirror-order` with your **secret** key. Both return `{ status, order_number, order_status_url }`; `order_number` and `order_status_url` are non-null only when `status` is `created`. `order_number` is the number the shopper and your store staff see, safe to print on a thank-you page: on Shopify the order name (for example `#1001`), elsewhere the platform's order number. A Shopify order created before 28 September 2026, or one recovered by an automatic retry, can carry the store's internal numeric id instead. `order_status_url` is a private link to that shopper's order: show it only to them, and do not log, store or share it. | `status` | What it means | |---|---| | `created` | The Shopify order exists. | | `pending` | Still working — keep polling, with a cap. On the direct charge this also covers a charge that carried no mirror block, which that route can't tell apart from "not finished yet." | | `failed` | The mirror ran and will not succeed. Stop polling, then **read `code` before reconciling** — `mirror_order_cancelled` means the order was released on purpose and no money moved, so there is nothing to reconcile. See [What `code` tells you](index.md#mirror-failure-codes). | | `not_mirrored` | Terminal. Stop polling, but read the warning below before you act on it. | ### `not_mirrored` does not prove there is no order {#not-mirrored-shopify} `order_number` is `null` for every `not_mirrored` case, so the response alone cannot tell these apart: - on hosted checkout, the session carried no mirror block (the direct charge reports `pending` for that case), or the block set `skip: true`; - the mirror was recorded but never dispatched — the store is disconnected, its authorization revoked, or mirroring turned off for the account. No order exists; reconcile the charge; - an order **was** created in the store and later cancelled there. :::danger Check the store before you reconcile or refund A second refund issued on the strength of `not_mirrored` is money out twice. ::: The mirror fires on **capture**, not authorization: an authorized-only charge creates no store order and the poll reports `pending` until you capture. Duplicate protection is per payment: retrying the same cart with a fresh `Idempotency-Key`, or as a new payment, yields a second charge *and* a second Shopify order — reuse the key on retries. ## Size limit The serialized `mirror` block is capped at **4096 bytes** on the hosted flow; over the cap returns [`mirror_too_large`](#errors). The direct-charge path does not enforce this up front, but an oversized block still fails after the charge — stay inside it. ## Sandbox — test emails are enforced {#sandbox} When you test, `mirror.customer.email` must use a reserved test domain; a realistic address is refused before the charge with [`mirror_sandbox_email_not_reserved`](#errors). Accepted: any address at `example.com`, `example.org`, `example.net` or `@localhost`, and anything ending in `.test`, `.invalid` or `.localhost`. The check runs on `POST /v1/sessions`, for a test-mode key or any request from a sandbox account. The same rule applies on [every store platform](/connected-platforms#test-emails). ## The legacy `metadata.mirror` string {#legacy} Older integrations sent the block as a JSON string under `metadata.mirror` on `POST /v1/sessions`. That string is accepted and **never parsed** — the payment succeeds and no Shopify order is created — and the `201` carries `Deprecation: true`, `Sunset: Wed, 27 Aug 2026 00:00:00 GMT` and a `Link` header pointing here. A string over 500 characters — a block carrying both addresses, like the worked example below — is refused by metadata validation before it reaches that path. Move the block to top-level `mirror`, as an object; sending both is refused with [`mirror_dual_specification`](#errors). ## Errors {#errors} `400` unless noted. On the [direct-charge path](#direct-charge-mirror-checks) most problems with a Shopify mirror produce **no error at all** — the charge succeeds and the order never appears. | Code | When | Fix | |---|---|---| | `mirror_shop_not_authorized` | `mirror.shop` isn't a connected, active store for your account | Connect the shop under **Connected Platforms → Shopify**, then retry with the exact hostname. | | **`422` `mirror_shop_missing_scopes`** | The connected store's known grant is missing `write_orders` or `write_customers`, so it cannot create the order. Refused before the charge on hosted checkout; the missing permissions are listed in `missing_scopes`. **Not** checked up front for a raw block on `POST /v1/payment_intents`, where it instead becomes a charge with no store order | Re-connect the store and grant the [required permissions](#permissions). This is a store-side re-grant, not a request-field change. | | `mirror_dual_specification` | On `POST /v1/sessions`: the request sends both top-level `mirror` and a legacy `metadata.mirror` **string** | Remove `metadata.mirror`; keep the top-level `mirror`. See [the legacy string path](#legacy). | | `mirror_alias_conflict` | On `POST /v1/payment_intents`: the request sends a mirror block in both `mirror` and `metadata.mirror`, and the two **differ**. Identical blocks are accepted | Send one block. Both positions stay supported, with no sunset and no migration; they just must not disagree. | | `mirror_too_large` | The serialized `mirror` block exceeds 4096 bytes | Trim line-item titles / move long free-text out of the block. | | `mirror_amount_below_line_items` | The mirror's `line_items` + `shipping` add up to **more** than the `amount` being charged. Refused **before** the charge on both surfaces — nothing moved. One-sided: charging more than the lines itemise is allowed | Itemise only what you're charging for, or raise `amount` to cover the lines and shipping you're mirroring. The response carries `charge_amount` and `mirror_line_items_sum`. See [Charging less than the order is worth](#amount-shortfall). | | `mirror_malformed` | On `POST /v1/payment_intents`, two causes: (1) the mirror isn't a JSON object (typically stringified, or coerced to `"[object Object]"`); (2) the block contains the reserved key `_gdpr_redacted` at any depth | (1) Send it as a nested object, not a string. (2) Rename that key at the path named in the message. It is written only by our privacy erasure process and may never be supplied by a caller. | | `mirror_upsell_unsupported` | A Shopify mirror carried `parent_order_ref`. Shopify orders can only be **created**, never appended to. Refused before the charge | Remove `parent_order_ref` (and `upsell_key`) and mirror the item as its own new order. Sent on the hosted flow the same combination is refused with `validation_error` and the same message, so don't branch on this code alone. | | `mirror_voucher_disabled` | `POST /v1/sessions` with a **Next Commerce** `voucher` — hosted checkout never re-prices a discounted total. A Shopify `voucher` on that route fails schema validation first and returns `validation_error` | Charge the full line-item total; record the code as `coupon`. | | `mirror_shopify_voucher_unsupported` | A `voucher` was sent on `POST /v1/payment_intents` with `destination: shopify`. Shopify does not price the code, so the order would be created at full price while the buyer is charged the discounted amount. Refused **before** the charge | Record the code with `coupon` as a display-only label, or charge the full line-item amount. Store-priced vouchers are Next Commerce only. | | `mirror_shopify_shipping_unsupported` | A `shipping` amount was sent on `POST /v1/payment_intents` with `destination: shopify`. The amount is not carried to the Shopify order, so its line total would be short by that amount versus the charge. Refused **before** the charge | Fold shipping into `line_items[]` as a line whose price is the shipping cost, so the order total matches what you charge. | | `mirror_sandbox_email_not_reserved` | While testing, `mirror.customer.email` was outside the reserved test domains | Use an `@example.com` (or `.test` / `.invalid` / `.localhost`) address — see [Sandbox](#sandbox). | | `validation_unknown_field` | The block contains a field that isn't in this reference | Remove the unrecognized field. The error response lists every unknown key it found and suggests the canonical spelling where it can. | --- ## Von Payments for WooCommerce Source: https://docs.vonpay.com/connected-platforms/woocommerce # Von Payments for WooCommerce Take card payments inside your own WooCommerce checkout. The shopper types their card into a secure field served by the payment processor inside your checkout page, and the card is charged when they place the order. The card number never reaches your store or Von Payments, which is what keeps your store in the lightest PCI category (SAQ-A). This page is for the person setting the store up. Nothing here needs code. ## Before you install - WordPress 6.4 or newer, WooCommerce 8.3 or newer, PHP 7.4 or newer. - Your checkout page served over HTTPS. The card field will not load on a page that is not. - Your Von Payments [API keys](/reference/api-keys): a publishable key and a secret key for test, and the same pair for live. The plugin takes card payments. The **Apple Pay** row on its settings page is not in use in this version. ## Install The plugin is not in the WordPress plugin directory, so searching for it in your admin will not find it. Download it from **[www.vonpay.com/woocommerce](https://www.vonpay.com/woocommerce)** instead, then upload that file: 1. Download the plugin from [www.vonpay.com/woocommerce](https://www.vonpay.com/woocommerce). That page always gives you the current release, so there is no version to pick. 2. In WordPress, go to **Plugins → Add New → Upload Plugin**. 3. Choose the `.zip` you just downloaded, then **Install Now**. 4. **Activate** it. That same address is the plugin's home page, so it is also where the **View details** link on your Plugins screen goes. ## Keeping the plugin up to date {#updates} **Leave WordPress's auto-update toggle on for this plugin.** Because it does not come from the WordPress directory, it brings its own update channel: it asks Von Payments what the current version is, and WordPress then offers or installs it exactly as it would any other plugin. This is how a fix reaches your store, so turning auto-updates off means watching for them yourself. You do not have to trust that channel blindly, and neither does the plugin. Before anything is installed it checks two things: that the update notice really came from Von Payments, and that the downloaded file is byte-for-byte the one that notice described. If either check does not pass, the update is refused and your store keeps running the version it already has. ## Connect your keys Go to **WooCommerce → Settings → Payments** and open **Von Payments**. 1. Tick **Enable Von Payments**. 2. Paste your **test** publishable key and **test** secret key. 3. Paste your **live** publishable key and **live** secret key. 4. **Save changes.** Each key is checked as you save. A key carries its own type and mode in its name, so a mix-up is refused rather than stored: a secret key pasted into a publishable field is not saved (the publishable key is sent to every shopper's browser, so a secret key there would be visible to anyone), a publishable key in a secret field is not saved, and a test key in a live field is not saved. The plugin then confirms the keys with Von Payments. If Von Payments rejects them, the payment method is taken off your checkout until you fix them, so a shopper is never asked for a card the store cannot charge. **Test mode is on when you install.** The store uses your test keys, and no real card is charged, until you untick **Test mode** and save. ## Receive payment updates {#webhooks} Von Payments tells the store when a payment settles, fails, or is refunded. Set this up once: 1. In the plugin settings, under **Webhooks**, copy the endpoint URL shown. 2. In your Von Payments dashboard, [create a webhook endpoint](/integration/webhooks) at that URL, subscribed to these seven events: `charge.succeeded`, `charge.failed`, `charge.refunded`, `refund.failed`, `payment_intent.succeeded`, `payment_intent.failed`, `payment_intent.cancelled` (the plugin uses `refund.failed` to prove delivery). Chargeback notifications need no subscription: Von Payments sends them to every endpoint. 3. Copy the signing secret shown when the endpoint is created and paste it into **Webhook signing secret**. Save. The **Webhook delivery** row on the settings page reports **verified** only once Von Payments has sent a test event and your store received it. Until then the checkout still works, but two things depend on this step: an order whose payment takes a moment to settle waits on hold until the update arrives, and a refund issued from the Von Payments dashboard reaches the order only through it. From plugin version 0.5.23, a test notification sent from your Von Payments dashboard never changes an order, whether **Test mode** is on or off; the log records that it arrived. ## Test a payment With **Test mode** ticked, place an order at a [test-mode total](/reference/test-cards#processor-test-account). A successful payment marks the order paid, and the shopper's card brand and last four digits appear on the order. The order is created before the card is charged, so a payment always has an order to land on. A card that clears in one step is confirmed in the same visit. When you are ready to take real payments, untick **Test mode** and save. ## When a shopper's bank asks for extra verification {#bank-verification} Some banks ask the shopper to confirm a payment before approving it (3-D Secure). When that happens the shopper is sent to their bank's page, confirms there, and comes back to your store's order confirmation; the plugin then asks Von Payments what happened before it marks anything paid. There is nothing to set up and nothing for you to do when it happens. If the shopper leaves the bank's page without confirming, no money moves and the order stays **Pending payment**. The plugin keeps checking it, and marks it paid only if the payment later goes through. ## Both checkouts are supported The block checkout and the classic (shortcode) checkout both show the same card field and use the same settings. ## Saved cards A signed-in shopper sees a tick box, **Save this card for future purchases in this store**, which starts unticked. Tick it and the card is kept when the payment completes without a bank verification step; leave it unticked and the card is not kept. Guests are never offered it. A saved card is listed under the shopper's **My Account → Payment methods** and can be chosen at their next checkout. Cards are saved only as part of a purchase. The **Add payment method** screen in My Account does not offer Von Payments, because there is no way to save a card there without charging it. ## Refunds Refund from the order screen as you would with any gateway: **Refund**, enter the amount (the full order or part of it), then **Refund via Von Payments**. The refund is sent to Von Payments and the money goes back to the shopper's card. A refund is refused until the order has a confirmed payment. A refund you issue from the Von Payments dashboard instead shows up on the order through the [payment updates](#webhooks) above. ## Troubleshooting | What you see | Why | What to do | |---|---|---| | The card field is not on the checkout | The payment method is disabled, a key is missing, Von Payments rejected the keys, your account cannot settle the store's currency, or the checkout page is not HTTPS | Open the settings: check **Enable**, that all four keys are saved, and any message shown under the keys. Confirm the checkout page loads over HTTPS. | | A shopper's card is declined | The bank or Von Payments refused the charge. The shopper sees what they can do about it and can type another card on the same order, which stays **Pending payment**. A saved card can be chosen again once that checkout expires | Look the attempt up in your Von Payments dashboard for the reason. A typo (wrong security code, wrong postcode) is the shopper's to fix; a final decline needs another card. A declined saved card marks the order **Failed** and writes the reason as an order note. If the note says the order **may or may not have been charged**, check your Von Payments dashboard for a payment against it before you retry or refund. | | An order sits **Pending payment** or **On hold** | Pending payment: the order was created and the charge has not been confirmed yet (the shopper may still be at their bank). On hold: the payment is settling and the update has not reached the store yet, or the amount paid does not match the order total; the order note says which | Check [payment updates](#webhooks) are set up. The plugin also re-checks such orders itself every 15 minutes, starting 10 minutes after the order. An **On hold** order it still cannot confirm after 24 hours is marked **Failed** with a note: look the payment up in your Von Payments dashboard and settle or refund it there. A **Pending payment** order is left as it is unless the payment goes through. | | A shopper disputes a payment with their bank (a chargeback) | Disputes are handled in your Von Payments dashboard. The order keeps its paid status: the plugin never refunds or restocks it for a dispute, and a lost dispute is not always for the full order amount | Respond to the dispute, and reconcile the order, in the Von Payments dashboard rather than from the order screen. | | A shopper sees *"This payment needs an extra verification step that this store cannot complete yet"* | The bank asked for a verification step and no usable address for its page came back, so the store could not send the shopper there. Rare. The shopper has not been charged | The shopper tries again or pays another way. If it repeats, contact Von Payments with the order number. | | Von Payments is not offered for a subscription product | The plugin does not take subscription payments, so WooCommerce does not list it for one | Use another method for subscriptions. | The **Debug log** option in the settings writes gateway events to **WooCommerce → Status → Logs**. It never logs card data or secret keys. --- ## 3D Secure Source: https://docs.vonpay.com/embedded-fields/3ds # Embedded Fields — 3D Secure 3D Secure (3DS / SCA) is the issuer's challenge that proves the buyer is the cardholder. The issuer decides whether to challenge, not your code. Where the challenge surfaces depends on which flow the session runs: | Session | When 3DS is required | You | |---|---|---| | [Charge at submit / charge-and-save](/embedded-fields/charge-at-submit) — the embed charges on submit | `submit()` resolves early with `chargeStatus: "requires_action"` and a `redirectUrl`; **nothing is charged** | Send the buyer to `redirectUrl`. They return to the session's `successUrl`; the `charge.*` webhook is the outcome. | | Tokenize-only — your server charges via `POST /v1/payment_intents` | The intent comes back `status: "requires_action"` with `next_action` | Send `return_url` on the create call and redirect the buyer to `next_action.redirect_to_url.url`. The `charge.*` webhook is the outcome. | Code the `requires_action` branch on every charge-at-submit integration even if you have never seen it fire — a card that needs a challenge lands nowhere else, and a buyer left on a spinner is an abandoned payment. Full contract: [Charge at submit → 3-D Secure](/embedded-fields/charge-at-submit#3-d-secure). ## Tokenize-only: the hosted redirect {#hosted-redirect} `submit()` mints the `vp_pmt_*`; your server creates the intent. Send an absolute-HTTPS **`return_url`** on the create call — the page the provider returns the buyer to after they authenticate — and redirect the buyer when the intent needs a challenge. This is the path for every card your server charges, on every connection: ```typescript // server/api/charge.ts — req.body.payment_method is the vp_pmt_* from submit() const response = await fetch(`${API}/v1/payment_intents`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${SECRET_KEY}` }, body: JSON.stringify({ amount: 4999, currency: "USD", payment_method: { id: req.body.payment_method }, // object form: { id: "vp_pmt_..." } return_url: `https://yourstore.com/checkout/return?order=${req.body.order_id}`, // absolute HTTPS, with your order ref }), }); const intent = await response.json(); if (intent.status === "requires_action" && intent.next_action?.type === "redirect_to_url") { // Send the buyer to the hosted challenge; the provider returns them to return_url on completion. return res.json({ redirect: intent.next_action.redirect_to_url.url }); } // requires_action with no next_action: still processing, nothing for the buyer to do. // Do not retry the charge. Wait for the webhook, which can take up to a day. res.json({ status: intent.status }); ``` ```javascript // Browser — send the buyer to the hosted challenge. `vora` is your page's // `new window.Vora({ publishableKey })` instance, `result` is the resolved // `collection.submit()` that minted the token, and `orderId` is your own order reference. const res = await fetch("/api/charge", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ payment_method: result.token, order_id: orderId }), }); const body = await res.json(); if (body.redirect) vora.redirectToChallenge(body.redirect); ``` If the redirect never reaches the buyer (a timeout, a crash, a closed tab), read `GET /v1/payment_intents/{id}`: it returns the same `next_action`, with `issued_at` for its age, for as long as the intent is `requires_action`. On a browser charge, resending with the same `Idempotency-Key` while the check is open also returns the same challenge link; a resent wallet charge does the same ([Standalone wallets](../guides/standalone-wallets.md)). Send the buyer to it once. `vora.redirectToChallenge()` (vora.js 1.32.0 or later) checks that the URL is a genuine bank challenge before sending the buyer, and throws `frame_3ds_required` rather than navigate to a host it does not recognise, so do not replace it with a plain `window.location` assignment. It also accepts `intent.next_action` or the Node SDK's `intent.nextAction` as they are. `return_url` must be absolute HTTPS (`javascript:` / `data:` / plain `http:` are rejected; `localhost` is accepted for local development). It is ignored when no challenge is required, so it is safe to always send, and it is not used for merchant-initiated charges (`mit.initiator: "merchant"`) — an off-session rebill has no buyer browser to redirect. The return is UX only: the authoritative outcome is the `charge.succeeded` / `charge.failed` webhook (or `GET /v1/payment_intents/{id}`), never the query string the provider appends to your `return_url`. ## Test cards Test payments run on a sandbox account, which runs 3-D Secure on every card payment. To test a challenge, pay with `4111 1111 1118 1072` (Visa) or `5240 0000 0000 1072` (Mastercard): the test page lets you pass or fail it. `4111 1111 1110 1203` and `5200 0000 0000 1203` verify without a challenge. [Test cards](../reference/test-cards.md#test-cards) has the full list, and the totals that decline. A merchant account's test key cannot take a test payment at all: it is refused with [`sandbox_account_required`](../reference/error-codes.md#sandbox_account_required). ## Error codes | Code | When | |---|---| | `frame_3ds_required` | Charge at submit only — the fail-closed case: 3DS was required but no usable challenge URL could be produced. Re-running `submit()` will not help; a different card fails identically. (Tokenize-only has no SDK error for this — the server's `status: "requires_action"` is the signal; see [the hosted redirect](#hosted-redirect).) | | `provider_request_rejected` | Not an SDK code — HTTP **422** from the charge endpoint. The provider rejected the *request*; the card was neither charged nor declined, so a different card fails the same way until the request is corrected. `selfHeal.retryable: false`, `nextAction: "fix_request"`. | | `frame_3ds_challenge_failed` | The challenge ran and was rejected, or failed technically. Not buyer cancellation. | | `frame_3ds_challenge_timeout` | The buyer did not complete within `challengeTimeout`. | | `frame_3ds_challenge_cancelled` | The buyer closed the challenge. Nothing charged, the card is fine — an abandoned checkout, not a failure. | | `frame_tokenization_failed` | The processor rejected the card before any challenge. | | `frame_payment_declined` | The issuer declined the charge on a charge-and-save / charge-only submit. | Full handling reference: [Errors](errors.md). ## What's next - [Charge at submit](charge-at-submit.md) — the redirect contract, AVS/CVV results, recovering the saved card - [Quickstart](quickstart.md) — end-to-end card-only happy path (no 3DS) - [Errors](errors.md) — full `VoraMirrorError` reference --- ## Apple Pay setup Source: https://docs.vonpay.com/embedded-fields/apple-pay-setup # Embedded Fields — Apple Pay domain setup Before Apple Pay buttons appear in your checkout, Apple verifies that the domain serving the page is one registered with our payments network — a one-time setup per domain, self-serve from **Settings → Wallet Domains** in your Vonpay dashboard: add the domain, download its verification file, host the file, click **Verify**. About five minutes per domain; no Apple Developer account needed — Vonpay holds the Apple Pay registration on your behalf. **Google Pay needs none of this** — its domains verify automatically when you add them. Apple's verifier fetches `https://{your-domain}/.well-known/apple-developer-merchantid-domain-association` and compares its bytes with what we registered. If that fetch ever fails — a 404, a TLS problem, changed content — Apple flips the domain back to **unverified** with no email or alert: buyers simply stop seeing the button. ## When you need to set up a domain Every distinct hostname where buyers will see the card field — `checkout.acmehats.com`, `pay.acmehats.com`, `staging.acmehats.com` (a separate registration from live). Subdomains are separate domains and Apple Pay supports no wildcards. Behind a CDN, Apple verifies the domain in the buyer's address bar, not the CDN's. ## Step-by-step setup ### 1. Download the verification file In **Settings → Wallet Domains**, click **Add domain**, enter the hostname (no `https://`, no path), choose **Apple Pay**, then **Add domain** — or click an existing domain row. On the detail page click **Download verification file**; your browser saves a single binary file named exactly `apple-developer-merchantid-domain-association`, no extension, unique to your account-and-domain pair. Each domain row shows **Verified**, **Pending**, **Failed** or **Revoked**, and flags a domain whose annual re-check is approaching as **Expiring soon**. ### 2. Host the file on your domain Serve it at: ``` https://{your-domain}/.well-known/apple-developer-merchantid-domain-association ``` The path is exact and case-sensitive. Apple's verifier does not follow redirects, does not accept gzip content-encoding, and does not accept a 200 with HTML — plain raw bytes at exactly this URL over HTTPS with a valid certificate. Any `Content-Type` works; Apple reads the bytes. #### Framework-specific recipes **Next.js** Drop the file into `public/.well-known/apple-developer-merchantid-domain-association`. Next.js serves the `public/` directory at the site root automatically; no config change. **Vite / Vue / Svelte** Drop the file into `public/.well-known/apple-developer-merchantid-domain-association`. Vite serves `public/` at the site root in both dev and production builds. **Vercel** Hosting any of the framework patterns above on Vercel works out of the box. For static sites without a framework, you can also use a `vercel.json` rewrite: ```json { "rewrites": [ { "source": "/.well-known/apple-developer-merchantid-domain-association", "destination": "/_static/apple-developer-merchantid-domain-association" } ] } ``` **Cloudflare Pages** Drop the file at the project root path `_static/.well-known/apple-developer-merchantid-domain-association` (or wherever your build outputs). Cloudflare's static handler serves it directly — no edge function needed. **Cloudflare Workers** Add a route for the well-known path that returns the file content from KV or an `env.ASSETS.fetch()` lookup: ```typescript async fetch(req: Request, env: Env) { if (new URL(req.url).pathname === "/.well-known/apple-developer-merchantid-domain-association") { return new Response(APPLE_PAY_DOMAIN_FILE, { headers: { "content-type": "application/octet-stream" }, }); } // ...rest of your worker }, }; ``` **Nginx** ```nginx location = /.well-known/apple-developer-merchantid-domain-association { alias /var/www/.well-known/apple-developer-merchantid-domain-association; default_type application/octet-stream; } ``` **Apache** Drop the file into your document root at `.well-known/apple-developer-merchantid-domain-association`. Apache serves dotfile directories by default; if you've disabled that, add: ```apache Require all granted ``` **Express / Node** ```javascript app.get("/.well-known/apple-developer-merchantid-domain-association", (req, res) => { res.type("application/octet-stream").send(APPLE_PAY_DOMAIN_FILE); }); ``` **Static site / CDN-only hosting** Most static-site hosts (Netlify, Surge, Render, Render-Cloudflare-R2) serve `public/.well-known/` or equivalent without config. If yours doesn't, check the host's docs for "well-known files" or "dotfile directories." ### 3. Confirm the file is reachable ```bash curl -v https://{your-domain}/.well-known/apple-developer-merchantid-domain-association ``` Expect HTTP 200, a non-empty body (a few KB), a valid certificate, and no redirects. Fix any `301` / `302`, `404`, `403` or TLS error first. ### 4. Click Verify in the dashboard On the domain's detail page click **Verify**. Vonpay triggers the verification call with Apple and the page polls for the result, usually within 30 seconds: - **Verified** — Apple Pay buttons start working on the buyer's next page load; the SDK picks up the state on the next session retrieve. - **Failed** — the page shows why, in one of four ways: *we couldn't confirm your domain* (re-host the file at the exact path and click **Verify** again — re-run the curl above first), *a temporary issue interrupted verification* (try again in a moment), or *this wallet isn't enabled / set up on your account yet* (contact support). If the page stops polling while verification is still running, reopen the domain or click **Verify** again to pick up the result. ### 5. Keep the file in place Apple re-verifies on a rolling annual cycle, and a missing file (a cleanup script, a purged CDN cache, a host that does not serve dotfile paths) flips the domain back to unverified silently. Our daily renewal worker re-runs the verification when the file disappears, but it can only verify what you serve — pin the file in version control and in your deploy pipeline. ## What can go wrong | Symptom | Likely cause | Fix | |---|---|---| | `curl` returns 404 | File not deployed, or path mismatch (extra extension, missing dot, wrong directory) | Verify exact path on disk; redeploy | | `curl` returns 301 / 302 | Web server redirects all paths to `https://`, `www.`, or trailing-slash version | Carve out an exemption for `/.well-known/` | | `curl` returns 200 but body is your homepage HTML | SPA catch-all route is intercepting the path | Add `/.well-known/*` to the framework's exclude list (Next.js `middleware.ts` matcher, Vite catch-all config, etc.) | | Verification fails again after a redeploy that had worked | CI compressed the file with gzip, or transformed it as an asset | Add `/.well-known/*` to your build's no-transform list | | Domain flips back to unverified after working for months | Apple's annual re-check failed because the file is no longer reachable | Re-host the file; Vora's renewal worker re-runs the verification automatically once the URL works | | Apple Pay button still doesn't appear after verification | Browser caching of the device-eligibility check | Test in a private Safari window; the Apple Pay availability cache clears on session end | --- ## Testing your setup Once the domain is **Verified**, use a real Apple device — Safari on macOS (Touch ID, or a paired iPhone with a card in Wallet) or iOS Safari with a card in Wallet; no other browser can render the button. Visit your checkout at the registered domain, create a session through the normal integration ([Quickstart](quickstart.md)) and mount the card field: the Apple Pay button appears inside the card mount when the buyer is eligible ([eligibility model](elements.md#apple-pay--google-pay--built-into-the-card-mount)). Tap the button, authenticate in the sheet, and `elements.submit()` resolves with the same two arms as a card in `embed` mode — `charged: true` for a guest, or `token` for a buyer on the session. **On a charge-and-save session the tap has already moved the money**: a returned token is the vaulted card, not a handle to charge this payment again. Confirm settlement via the webhook before fulfilling. If the button does not appear, the embed emits no error — it simply does not render. Re-check the domain's status in **Settings → Wallet Domains** (click **Verify** again if it is not **Verified**) and the eligibility gates: Safari on an Apple device with a card in Wallet. Standalone wallet buttons in `elements` mode report the `frame_wallet_*` codes instead — [Errors → Wallet errors](errors.md#wallet-errors-apple-pay--google-pay). ## What's next - [Elements reference → Apple Pay & Google Pay](elements.md#apple-pay--google-pay--built-into-the-card-mount) — the runtime behavior and eligibility model - [Accept Apple Pay & Google Pay](../guides/standalone-wallets.md) — standalone wallet buttons in `elements` mode - [Quickstart](quickstart.md) — end-to-end Embedded Fields integration --- ## Render modes: embed vs elements Source: https://docs.vonpay.com/embedded-fields/binder-profiles # Render modes: embed vs elements Embedded Fields renders in one of **two modes**, chosen **per session** via `integrationMode` when you create the session — the same account can create one session in `embed` and the next in `elements`. - **`embed`** *(the default)* — a **monolith**: one combined card iframe renders the whole payment surface (card number + expiry + CVC, cardholder name, billing address, the payment-method picker, a returning buyer's saved cards, and any eligible Apple Pay / Google Pay buttons). You place only `email` and `save-for-future-use` yourself. - **`elements`** — **composable**: you mount the card, email, cardholder, address and the other pieces as discrete elements you place yourself, where your account supports it. The card can stay the combined box **or** split into the three discrete [`card-number` / `card-expiry` / `card-cvc`](elements.md#discrete-card-fields) secure fields, each mounted into its own container (they share one controller and tokenize together via one `submit()`); on a binder without discrete-field support a discrete field's `.mount()` throws `frame_unsupported_element`. :::warning `elements` falls back to `embed` silently — it never errors Where discrete rendering is unavailable, an `integrationMode: "elements"` session renders as `embed`: the `card` iframe collects the cardholder name and billing address itself, and a `cardholder` / `address` element you mounted separately still renders the SDK's own input — the buyer sees the field twice. Read the `integrationMode` echoed on the created session, and the capability map from `vora.sessions.retrieve()`, then code both layouts. See [Custom checkout with Elements](custom-checkout.md) for the create-session call. ::: ## The capability map `vora.sessions.retrieve(sessionId)` returns a capability map. Each element takes one of four lowercase values for the active session: - **`"native"`** — the gateway ships a UI primitive for this element and the SDK wraps it (one iframe; the gateway owns internal layout; your `style` option translates to the gateway's theme API). - **`"proxy"`** — the SDK renders the DOM itself, no iframe; your CSS has full control. - **`"monolith"`** — the main embed iframe already contains this element's surface, so there is no separate mountable slot. `elements.create()` for the payment-method picker in this state **throws `frame_unsupported_element`** — it does not return `null`, and the throw happens in `create()`, not `mount()`, so a `try`/`catch` around `.mount()` will not catch it. Do not list that element when the map says `"monolith"`. - **`"not_available"`** — this binder does not support the element at all; for the payment-method picker any method marked this way is filtered out of `methods` before render. Same throw in `create()` as above. Handle this value explicitly — it is the case a three-way branch falls through. Your code never has to name or detect the underlying vendor — the capability map is the contract. ## Capability matrix | Element | embed *(default)* | elements | |---|---|---| | `card` | `"monolith"` (one iframe contains the entire checkout) | `"native"` (one combined number/expiry/CVC card iframe) | | `card-number` / `card-expiry` / `card-cvc` | *(not applicable — card is one combined box)* | `"native"` (three discrete secure fields, mounted independently) | | `cardholder` | `"proxy"` — **not gated** (see below) | `"proxy"` (SDK-rendered input) | | `email` | `"proxy"` | `"proxy"` | | `address` (billing) | `"monolith"` (inside the main iframe) | `"monolith"` is what the map reports; the SDK renders its own input — style it as SDK-rendered DOM, and do not branch on `"proxy"` | | `payment-method-picker` | `"monolith"` | `"monolith"` — `create()` throws `frame_unsupported_element`; the processor's own surface handles method selection, so do not list it | | `save-for-future-use` | `"proxy"` (the consent surface is always SDK-rendered) | `"proxy"` | | `saved-methods` | `"proxy"` — renders its `unavailable` state: the saved-card read surface is not enabled, for any account. A returning buyer's saved cards appear inside the embed card mount instead | `"proxy"` — same `unavailable` state; see the [element index](elements.md#status-overview) | Apple Pay / Google Pay buttons render inside the `card` mount in every mode when the buyer's device and your domain are eligible — [Element reference → Apple Pay & Google Pay](elements.md#apple-pay--google-pay--built-into-the-card-mount); there is no separate row element to position. Per-element **options** and collect shapes are in the [Element reference](elements.md). ## Composing multiple elements With `integrationMode: "elements"`, mount each element where you want it and submit the collection as a unit: ```javascript const elements = vora.elements.create({ theme: { font: { size: "16px" }, color: { text: "#1a1a1a" } }, }); const card = elements.create("card", {}); const address = elements.create("address", { mode: "billing" }); const email = elements.create("email", {}); card.mount("#card-element"); address.mount("#address-element"); email.mount("#email-element"); // One submit() collects from every mounted element, drives tokenization, // and returns a single flat SubmitResult — branch error -> chargeStatus -> // token -> charged. See Elements for the full branch order. const result = await elements.submit(); ``` The collection shares theme defaults across child elements. With the default `integrationMode: "embed"` the same code is valid — but the `card` iframe already collects the billing address, so a separately mounted `address` produces a second form. Mount it only in `elements` mode. **`cardholder` is the exception**: it is not gated by the capability map — the SDK always renders its own text input, in both modes, on every binder. Mounting it in `embed` mode therefore produces a visible field, and if the binder's own iframe also collects a cardholder name you get two. Mount it only where you want the SDK's input. `email` behaves the same way. The submit result is one flat `SubmitResult` regardless of how many elements you mount — every field optional, branched in the order `error` → `chargeStatus` → `token` → `charged`. The interface and the full contract: [Element reference → `SubmitResult` shape](elements.md#submitresult-shape); the styling model (what `style` themes vs what you style on your own DOM): [Customize the look & feel](customize.mdx). ## What's next - [Element reference](elements.md) — the default `embed` path, per-element options, and the `SubmitResult` shape - [Custom checkout with Elements](custom-checkout.md) — the elements-mode create-session walkthrough - [Customize the look & feel](customize.mdx) — the styling model, themes, and playground - [Quickstart](quickstart.md) — end-to-end integration walkthrough - [Error handling](errors.md) — `VoraMirrorError` codes and recovery --- ## Charge at submit — CVV, AVS & 3-D Secure at the first charge Source: https://docs.vonpay.com/embedded-fields/charge-at-submit # Charge at submit — CVV, AVS & 3-D Secure at the first charge **Charge at submit** is the [charge-and-save](/embedded-fields/tokenization) flow for **elements mode**. On the buyer's first charge one `collection.submit()` charges the card, verifies the CVV the buyer just typed, runs address verification against the billing address, hands off to the issuer's hosted 3-D Secure challenge when one is required, and — with a buyer on the session — vaults the card as a reusable `vp_pmt_*`. A stored token has no CVV, so the default vault-then-charge path can never return a CVV result; charging at submit keeps the buyer-present security code in the request that authorises the card. Charge at submit is enabled per session; CVV recovery, AVS and 3-D Secure activate at the account level with your gateway — confirm with your Vonpay contact before relying on the result codes in production. ## Enable it on the session Create the session server-side with `integrationMode: "elements"` and `chargeAtSubmit: true`, and always set `successUrl` — it is where the issuer returns the buyer after a hosted 3-D Secure challenge (without one Vonpay uses its own hosted return page, so the buyer finishes on a Vonpay page instead of yours; `cancelUrl` is used when they abandon the challenge): ```bash curl -X POST https://checkout.vonpay.com/v1/sessions \ -H "Authorization: Bearer $VON_PAY_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 4999, "currency": "USD", "buyerId": "buyer_123", "integrationMode": "elements", "chargeAtSubmit": true, "successUrl": "https://your-store.example.com/checkout/return", "cancelUrl": "https://your-store.example.com/checkout/cancelled" }' ``` Return the session id and `publishableKey` to the browser, then mount and submit exactly as in [custom layout](/embedded-fields/custom-checkout) — the session flag alone switches `submit()` to the charge path. A session created without `chargeAtSubmit: true` vaults the card and returns a token instead. ## Read the result Charge at submit adds `chargeStatus`, `redirectUrl` and `paymentIntentId` to the [`SubmitResult`](/embedded-fields/elements#submitresult-shape). Branch `error` → `chargeStatus` → `token` → `charged` (`charged: false` beside `chargeStatus: "succeeded"` on a [`captureMethod: "manual"`](/integration/create-session#capture-method) session: authorised, held, not yet taken; fulfil only on `charged === true`); on the `requires_action` arm nothing has been charged yet, and if your code only handles `succeeded` and `pending` a 3-D Secure card lands in neither — the buyer sees a spinner or a false success and the payment is silently abandoned: ```typescript const result = await collection.submit(); if (result.error) { // Nothing was charged. Surface result.error.message (see "Errors" below). } else if (result.chargeStatus === "requires_action") { // 3-D Secure required. NOTHING has been charged yet. Send the buyer to the // issuer's hosted challenge; they return to the session's successUrl and the // charge.* webhook is the authoritative outcome. window.location.href = result.redirectUrl; } else if (result.chargeStatus === "pending") { // Authorised but not yet settled — do NOT fulfil yet. Wait for the charge // webhook, keyed on result.paymentIntentId. result.token may be absent here. } else if (result.recovered) { // Resolved by polling after a lost response. result.paymentIntentId is // absent here (only result.transactionId). Never fulfil on result.charged // for a recovered outcome; confirm the payment server-side first. } else { // chargeStatus === "succeeded": the charge completed. result.charged is true // when money was captured, and FALSE on a session created with // captureMethod: "manual": the card was authorised, the money is held, and // nothing moves until you capture result.paymentIntentId server-side. Fulfil // only on result.charged === true; treat an absent value as unknown. // A buyer // on the session also gets result.token (vp_pmt_*); a guest gets // result.charged with no token. } ``` :::danger Submit already charged — never charge the session twice When charge at submit is on, `submit()` **has already charged the card**. Do not also call `POST /v1/payment_intents` for the same session — that double-charges the buyer. The client result is a UX signal; confirm settlement via the [charge webhook](/integration/webhook-events) before fulfilling, and treat `chargeStatus: "pending"` as not yet settled. For a hold (`charged: false`), take the outcome from the [capture response](/integration/create-session#capture-method), not a webhook. ::: ## Billing address for AVS AVS only runs when the charge carries a billing address. Mount an [`address` element](/embedded-fields/elements) and it is forwarded automatically: ```typescript const address = collection.create("address", { mode: "billing" }); address.mount("#billing-address"); // …mount card fields… const result = await collection.submit(); // the address rides along ``` Or pass it from your own form and skip the element: ```typescript const result = await collection.submit({ billingAddress: { line1: "1600 Pennsylvania Ave NW", city: "Washington", state: "DC", postalCode: "20500", country: "US", // 2-letter ISO 3166-1 code }, }); ``` `line1`, `postalCode` and `country` are required and validated before any network call — a missing or malformed field returns `frame_field_validation_failed` and nothing is charged. An explicit `submit({ billingAddress })` takes precedence over a mounted `address` element. Read the address and security-code results on your server: `avs_result_code` and `cvv_result_code` on the [`charge.succeeded` and `charge.failed` webhooks](/integration/webhook-events). The `submit()` result does not carry them. A `no_match` is a signal for you to act on, not an automatic decline. ## 3-D Secure When a card requires 3-D Secure, the challenge runs on the issuer's hosted page and the buyer leaves your page for its duration (issuers increasingly refuse to render their authentication page inside another site's frame). `submit()` resolves with `{ chargeStatus: "requires_action", redirectUrl, last4, brand }` and nothing has been charged. Send the buyer to `redirectUrl`; they authenticate, return to your `successUrl`, the charge settles, and the `charge.succeeded` / `charge.failed` webhook is the authoritative outcome — fulfil on that, not on the redirect. The return to `successUrl` is performed by the payment provider and Vonpay does not add or control its query string: treat it as "the buyer is back", and confirm the outcome from the webhook or a server-side read of the payment intent. If 3-D Secure is required but no usable challenge URL can be produced, `submit()` fails closed with `frame_3ds_required` and nothing is charged — a configuration fault, not a card fault; a different card fails identically. The [3-D Secure guide](/embedded-fields/3ds) covers sandbox test cards and the server-driven confirm path used by the tokenize-then-charge flow. ## Getting the saved card back A card is vaulted when the charge completes, so what you get back depends on the arm: | `chargeStatus` | `result.token` | `result.paymentIntentId` | |---|---|---| | `"succeeded"` | present, with a buyer on the session | present | | `"pending"` | may be present (a buyer's card is vaulted at authorisation) — read it if it is, otherwise read it back once settled; it is not a settled charge | present | | `"requires_action"` | never | **never** | **On `requires_action` you get neither** — that is every 3-D Secure card, and it needs a recovery path. ### Recovering the card after a 3-D Secure charge The [`charge.succeeded` webhook](/integration/webhook-events) is where the payment intent's id comes from — there is no list endpoint for payment intents and `GET /v1/sessions/{id}` does not return one. 1. Receive `charge.succeeded`. Read `payment_intent_id` from it. 2. Read the intent back from your server (neither the Node nor the Python SDK exposes a payment-intent read, so this is a direct REST call with your secret key): ```bash curl https://checkout.vonpay.com/v1/payment_intents/$PAYMENT_INTENT_ID \ -H "Authorization: Bearer $VON_PAY_SECRET_KEY" ``` ```json { "id": "vpi_live_8rT3jQv5xY9mNa_pQ", "status": "succeeded", "payment_method_id": "vp_pmt_live_sLCHe8nrneFldDJF" } ``` :::warning A `null` on the first read does not mean the card was not saved The webhook can reach you before the vaulted card is attached to the intent, so `status: "succeeded"` with `payment_method_id: null` is a normal first read. Re-read with a short backoff. A `null` that persists on a succeeded payment that had a buyer attached is a support case — writing "no card on file" into your records re-prompts a buyer for a card they already gave you. ::: `payment_method_id` is legitimately `null` when the payment vaulted nothing — a guest payment, or one that never succeeded. A saved card outlives the payment that saved it, so it keeps being returned on later reads. The same handle appears as `id` on a `POST /v1/tokens` response, `token` on the charge-at-submit result, and `payment_method_id` on the payment intent; all three are what you pass as `payment_method.id` on [`POST /v1/payment_intents`](/integration/payment-intents). ### Two permissions, and only one is about charging {#consent-permissions} A vaulted card is single-use by default. Mounting the [`save-for-future-use` element](/embedded-fields/elements) records two consents when the buyer ticks it, and both are write-once — a token vaulted without them cannot be upgraded ([Tokenization → reusability](/embedded-fields/tokenization#reusability--setup_for_future_use)): | Field | The question it answers | |---|---| | `setup_for_future_use` | May you **charge** this card again? `off_session` makes the token usable for merchant-initiated charges (subscriptions, renewals); `on_session` permits reuse only while the buyer is present. | | `allow_redisplay` | May the card be **shown back to the buyer** in a later checkout? `always` \| `limited` \| `unspecified`. | A ticked box vaults `off_session` with `allow_redisplay: "always"`. When your label names one arrangement ("Save this card for my subscription"), pass `displayScope: "limited"` on the element so the card is not offered back on an unrelated purchase; an unticked box records `unspecified`. Both values come back on the result (`setupForFutureUse`, `allowRedisplay`) echoing what was persisted on the returned `token` — absent when nothing was vaulted. Saved cards are read server-side with [`GET /v1/payment_methods`](/reference/tokens#list-a-buyers-saved-cards). ## Errors All failures come back on `result.error` — the card is not charged when `error` is set: | Code | Meaning | |---|---| | `frame_field_validation_failed` | A required field (including a supplied billing address) was missing or malformed. Fixed before any charge. | | `frame_payment_declined` | The card was authorised through 3-D Secure (or no 3DS was required) but the issuer/processor declined the charge. | | `frame_3ds_challenge_failed` | The 3-D Secure challenge was **rejected**, or couldn't finish technically. Nothing charged. Not buyer cancellation — that has its own code. | | `frame_3ds_challenge_timeout` | The buyer ran out of time on the 3-D Secure challenge. Nothing charged. | | `frame_3ds_challenge_cancelled` | The buyer **closed** the 3-D Secure challenge. Nothing charged, card is fine — an abandoned checkout, not a failure. | | `frame_3ds_required` | 3-D Secure was required but no usable challenge URL could be produced, so the charge failed closed. A **configuration** fault, not a card fault — a different card fails identically and re-running `submit()` will not help. Contact support with the session id. | A charge can also be rejected by the provider before any authentication is attempted: HTTP **422 [`provider_request_rejected`](/reference/error-codes#provider_request_rejected)** (`selfHeal.retryable: false`, `nextAction: "fix_request"`). The card was neither charged nor declined, so a different card fails the same way — fix the request. The [error reference](/embedded-fields/errors) has the complete code union. ### If you change the session's total {#if-you-change-the-sessions-total} A charge-at-submit session is an `elements` session, so its total can be changed with [`PATCH /v1/sessions/{sessionId}`](/reference/api#update-session-total) (`sessions.update()` in the server SDKs). After changing it, call `collection.fetchUpdates()` and wait for it before the buyer pays (vora.js 1.36.0 and later): ```javascript // Your server has just changed the total. const { amount, currency } = await collection.fetchUpdates(); showTotal(amount, currency); // show the buyer what they will pay ``` It re-reads the session and resolves with the total the checkout now holds. From then on, every card payment from this collection carries that total, and a payment whose total changed again without another `fetchUpdates()` is refused with `frame_wallet_total_mismatch`; the card is not charged. Show the new total and let the buyer pay again. - Calling `submit()` while `fetchUpdates()` is still reading is refused with `frame_session_not_ready`, and nothing is charged. - A failed `fetchUpdates()` leaves the previous total in force, so a later payment can only be refused, never charged at a total nobody saw. - It is opt-in. Until the first call, card payments are sent as before, and a total change is caught only if it happens while the charge request is being processed ([`409 expected_amount_mismatch`](/reference/error-codes#expected_amount_mismatch), nothing charged). A server that calls `POST /v1/public/sessions/{sessionId}/charge` directly sends the same check itself as `expected_amount` (`{ amount, currency }`, the total the buyer was shown). ## Related - [Custom layout (elements mode)](/embedded-fields/custom-checkout) — mounting the discrete card fields - [Saved cards & tokenization](/embedded-fields/tokenization) — the charge-and-save vs vault-only flows - [3-D Secure](/embedded-fields/3ds) — the full 3DS model and sandbox test cards - [Errors](/embedded-fields/errors) — the complete `frame_*` code union --- ## Custom checkout with Elements Source: https://docs.vonpay.com/embedded-fields/custom-checkout # Custom checkout with Elements **Vora Elements** lets you build a fully custom checkout: you position and style the card element and the other checkout elements yourself — the card box plus, as your account supports them, separate `email`, `cardholder`, `address`, `save-for-future-use` and `payment-method-picker` elements, and optionally standalone wallet buttons — instead of the single drop-in [Embedded Fields](quickstart.md) embed that owns the whole payment surface. For the card entry you have a choice: the combined `card` box (number, expiry and CVC in one secure field) or the three discrete secure fields `card-number`, `card-expiry` and `card-cvc`, placed and styled independently. Both produce the same `vp_pmt_*` token; this page walks the combined `card` element end to end and shows the [discrete-fields variant](#variant--discrete-card-fields) at the end. You opt in with one field when you create the session — `integrationMode: "elements"`. Where your account does not support discrete rendering the session renders as the standard embed instead and never errors mid-checkout; the `integrationMode` echoed on the created session (and on `vora.sessions.retrieve()`) tells you which surface you got, so branch your UI on that value. Everything else is the surface you already know: the same `vora.js` SDK, the same `vp_pmt_*` token, and the same [`POST /v1/payment_intents`](../integration/payment-intents.md) charge path — the full model is in [Render modes: embed vs elements](binder-profiles.md). Returning buyers: the discrete `saved-methods` picker renders its `unavailable` state for every account, so for one-click saved cards either use the default `embed` mode with a buyer on the session (`buyerId` — the embed lists that buyer's saved cards inside the iframe; see [buyer identification](../integration/create-session.md#buyer-identification)) or charge a `vp_pmt_*` you already hold server-side through [Payment Intents](../integration/payment-intents.md) without re-collecting the card. For standalone Apple Pay / Google Pay buttons on the same session, see [Accept Apple Pay & Google Pay](../guides/standalone-wallets.md). ## How it works ``` 1. Server → POST /v1/sessions { integrationMode: "elements" } → session id (vp_cs_*) 2. Browser → load vora.js (CDN) → new Vora() → vora.sessions.retrieve(sessionId) 3. Browser → mount the `card` element (one combined card box) into your own container 4. Browser → cardCollection.submit() → vp_pmt_* token 5. Browser → send the token to your server 6. Server → POST /v1/payment_intents { payment_method: { id } } → charged 7. Confirm settlement via the webhook before fulfilling ``` The buyer's card data never reaches your servers — PAN and CVC stay inside the secure field iframes. You only ever hold the resulting `vp_pmt_*` token plus display metadata (`brand`, `last4`). What does live in your page in this mode: the `email`, `cardholder`, `address` and `save-for-future-use` inputs, if you mount them. Any other script on the page can read those values, so keep the checkout page's scripts to ones you can vouch for; the [element reference](elements.md#cardholder-and-address--inputs-in-your-page) and the [security reference](../reference/security.md#pci-compliance) spell out the trade-off. ## Step 0 — Get your keys You need a publishable key (`vp_pk_test_*`, for the browser) and a secret key (`vp_sk_test_*`, server-only) — [Quickstart §0](../integration/quickstart.md#step-0-get-your-test-keys). ## Step 1 — Create an Elements session (server) Create the session from your backend with your secret key: ```typescript // server/create-session.ts (Node) app.post("/api/create-session", async (req, res) => { const response = await fetch("https://checkout.vonpay.com/v1/sessions", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.VON_PAY_SECRET_KEY}`, // vp_sk_* }, body: JSON.stringify({ amount: 4999, // minor units (cents) currency: "USD", integrationMode: "elements", // ← discrete fields instead of the embed }), }); const session = await response.json(); // session.id is vp_cs_* — browser-safe. Send only the id to the client. res.json({ session_id: session.id }); }); ``` The response is a session object — `{ id, checkoutUrl, expiresAt, integrationMode }`. If your account doesn't support Elements, the echoed `integrationMode` comes back `"embed"` rather than erroring. ## Step 2 — Load vora.js and retrieve the session (browser) Load the SDK from the CDN — `@vonpay/vora-js` is not on npm; the ` ``` ```javascript const vora = new window.Vora({ publishableKey: "vp_pk_test_…", // a secret key throws a TypeError apiBaseUrl: "https://checkout.vonpay.com", // the default — NOT inferred from your key; // must name the host your server called in Step 1 }); // Fetch the session id from your server (Step 1), then: await vora.sessions.retrieve(sessionId); // loads the render config for this session ``` `vora.sessions.retrieve()` resolves how this session should render and loads the matching field adapter for you — your browser code never names a provider. ## Step 3 — Mount the card field Create an elements collection, create a `card` element, and mount it into a container you own. Listen for `change` to know when the field is complete: ```javascript const cardCollection = vora.elements.create(); const card = cardCollection.create("card", { style: { color: { text: "#1f2937", placeholder: "#9ca3af" }, font: { family: "system-ui, sans-serif", size: "16px" }, }, }); let cardComplete = false; card.on("change", (e) => { cardComplete = e.complete; // enable your Pay button when true showFieldError(e.error?.message ?? null); // e.error is { code, message } }); card.mount("#card-element"); // your own
``` `style` is the unified Vora schema: on this surface the secure card iframe takes `font`, `color`, `background` and `spacing.padding`; borders and margins are drawn on your own wrapper `
` — see [where each token lands](customize.mdx#token-map). Calling `create("card", …)` before `vora.sessions.retrieve()` throws `frame_session_not_ready`. ## Step 4 — Tokenize on submit On your Pay click, call `submit()` on the card collection. On a session without [charge at submit](charge-at-submit.md) it vaults the card and resolves a `vp_pmt_*` token — it does not charge (the charge happens server-side in Step 5, so pass no `paymentIntent` argument): ```javascript payButton.addEventListener("click", async () => { const result = await cardCollection.submit(); if (result.error) { showFieldError(result.error.message); // validation / tokenize failure } else if (result.chargeStatus === "requires_action") { window.location.href = result.redirectUrl; // charge at submit only — 3-D Secure, nothing charged } else if (result.chargeStatus === "pending") { // charge at submit only — in flight; await the charge.* webhook, never charge again } else if (result.recovered) { // charge at submit only — resolved by polling after a lost response. There is no // paymentIntentId here, only transactionId: confirm server-side, never charge again } else if (result.token) { await chargeOnYourServer(result.token); // vp_pmt_* — send to your backend } else if (result.charged) { // The session charged at submit (or fell back to a charging embed): the buyer // was ALREADY charged and there is no reusable token. Do NOT charge again — // confirm settlement via the webhook (Step 6). } }); ``` `SubmitResult` is one flat object (every field optional) — branch `error` → `chargeStatus` → `recovered` → `token` → `charged`, in that order ([the full contract](elements.md#submitresult-shape)). `result.last4` / `result.brand` are display-only; a saved-card list also gets `result.expMonth` / `result.expYear` — camelCase, 1-12, absent rather than placeholdered when unavailable ([Card expiry on the result](tokenization.md#expiry-on-the-result)). ## Step 5 — Charge on your server Send the `vp_pmt_*` token to your backend and charge it with [`POST /v1/payment_intents`](../integration/payment-intents.md) using your secret key. `payment_method` takes an object — `{ id }`: ```typescript // server/charge.ts (Node) app.post("/api/create-payment-intent", async (req, res) => { const response = await fetch("https://checkout.vonpay.com/v1/payment_intents", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.VON_PAY_SECRET_KEY}`, // Production: send a stable Idempotency-Key (e.g. the cart id) so a // retry never double-charges. }, body: JSON.stringify({ amount: 4999, currency: "USD", payment_method: { id: req.body.payment_method }, // the vp_pmt_* token // Where the bank returns the buyer after a 3-D Secure challenge. Without // it, a card that needs a challenge is refused. return_url: `https://yourstore.com/checkout/return?order=${req.body.order_id}`, }), }); const intent = await response.json(); // On requires_action, hand the browser the bank's challenge URL. requires_action // with no next_action is still processing: do not retry, wait for the webhook. const redirect = intent.next_action?.type === "redirect_to_url" ? intent.next_action.redirect_to_url.url : null; res.json({ intent_id: intent.id, status: intent.status, redirect }); }); ``` The browser half, the `chargeOnYourServer` your Pay handler calls in Step 4 (`orderId` is your own order reference): ```javascript async function chargeOnYourServer(token) { const res = await fetch("/api/create-payment-intent", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ payment_method: token, order_id: orderId }), }); const body = await res.json(); if (body.redirect) vora.redirectToChallenge(body.redirect); // 3-D Secure: the bank's page } ``` The intent `status` is one of `requires_action`, `authorized`, `captured`, `succeeded`, `voided` or `failed`. ## Step 6 — Handle 3DS and confirm settlement If `status === "requires_action"` the charge needs a 3-D Secure challenge before it settles: send the buyer to `next_action.redirect_to_url.url`, which is why the create call sets `return_url`. `chargeOnYourServer` above calls `vora.redirectToChallenge(body.redirect)` when `redirect` is present; it checks the link is a genuine bank challenge before sending the buyer: [3D Secure & SCA](3ds.md). The client-side result is a UX signal only — a timeout or network error on submit can land *after* the charge already succeeded, so never mark the order failed on a client error, and never resubmit blindly. Confirm settlement server-side via the [webhook](../integration/webhooks.md) before fulfilling; one payment emits both `charge.succeeded` and `payment_intent.succeeded`, so dedupe on the event envelope's `id` (`vp_evt_*`) and make fulfilment idempotent per session — [Reconciliation](../integration/reconciliation.md). Treat `409 session_already_completed` as *already paid or being paid right now*; do not create a new session, and read the outcome from the webhook rather than assuming success. ## The two-collection rule If you mount both a card field and standalone wallet buttons on the same page, keep them in separate collections: ```javascript const cardCollection = vora.elements.create(); // card lives here const walletCollection = vora.elements.create(); // wallets live here ``` `collection.submit()` tokenizes whatever `card` element is registered in that collection; if the wallet shared the card's collection, submitting the wallet would try to tokenize the still-empty card field. ## Variant — discrete card fields To lay out the three card inputs independently, swap the single `card` element for `card-number`, `card-expiry` and `card-cvc`. Only Step 3 changes; you get the same `vp_pmt_*` from `submit()`. ```html ``` ```javascript const cardCollection = vora.elements.create({ theme: { color: { text: "#1f2937", placeholder: "#9ca3af" }, font: { size: "16px" } }, }); const number = cardCollection.create("card-number", { placeholder: "1234 1234 1234 1234", showIcon: true }); const expiry = cardCollection.create("card-expiry", { placeholder: "MM / YY" }); const cvc = cardCollection.create("card-cvc", { placeholder: "CVC" }); number.mount("#card-number"); expiry.mount("#card-expiry"); cvc.mount("#card-cvc"); // Wire per-field validation errors. On the direct-card binder each // field fires its own field-scoped `change`; on the secure-fields binder `change` // is whole-form. Treat `complete` as a form-level signal for portability — see // the events caveat in the Elements reference. number.on("change", (e) => showFieldError(e.error?.message ?? null)); expiry.on("change", (e) => showFieldError(e.error?.message ?? null)); cvc.on("change", (e) => showFieldError(e.error?.message ?? null)); ``` `submit()` is unchanged — it drives the shared secure-fields controller and vaults all three fields together; branch the result exactly as in Step 4. Three rules ([Elements reference → Discrete card fields](elements.md#discrete-card-fields)): all three fields must be created and mounted before `submit()`; never put both the combined `card` and the discrete fields in one collection (that tokenizes twice); and discrete fields need `"elements"` mode on a supporting binder — otherwise `.mount()` throws `frame_unsupported_element`, so fall back to the combined `card`. ## What's next - [Element reference](elements.md) — every element type, options, theming, and the `SubmitResult` shape - [Accept Apple Pay & Google Pay](../guides/standalone-wallets.md) — standalone wallet buttons on the same session - [Render modes: embed vs elements](binder-profiles.md) — the two-mode model and the capability matrix - [Tokenization](tokenization.md) — the `vp_pmt_*` reusability model (saved cards, MIT) - [Payment Intents](../integration/payment-intents.md) — the server-side charge / capture / refund lifecycle --- ## Customize the look & feel Source: https://docs.vonpay.com/embedded-fields/customize # Customize the look & feel Make the card field match your brand — fonts, colors, radius, spacing — with the `style` option (`VoraFieldStyle`), in both render modes. The mode a session renders in (`"embed"` by default, `"elements"` where your account supports it — [Render modes](binder-profiles.md)) selects the renderer, and **the same token lands differently per renderer**: the [token map](#token-map) below says where. In `"embed"` the card mount is your whole payment surface (card field, wallet buttons, billing address, method picker), so tokens reach more surfaces there; in `"elements"` you place those pieces yourself. Read the `integrationMode` echoed on the session to know which surface rendered, then confirm with the live iframe. ## Interactive playground Edit the `VoraFieldStyle` JSON by hand, pick a preset, or **paste your site URL and we'll match it for you** — then copy the generated integration snippet. What the tool gives you: 1. **Theme & JSON tab** — pick a preset (Default / Light / Dark) or hand-edit the `VoraFieldStyle` JSON. 2. **Match my site tab** ⭐ — paste your site's public URL or stylesheet, and we extract a `VoraFieldStyle` config that matches your fonts, colors, radius, and spacing. 3. **Code generator** — a copy-paste snippet that reflects your JSON. Replace `vp_pk_test_...` with your own publishable key and wire the session-mint step to your server. 4. **Preview (live sandbox)** — click **Try with live SDK** to mount the **real** card iframe against a sandbox demo merchant. The preview is the real iframe, not a mock — the field is rendered by the session's active surface, so only the live iframe is faithful. The `style` JSON and the snippet are the contract. --- ## Three reference themes The `style` option is an allowlist-validated object — anything outside the allowlist throws `frame_style_invalid` before the iframe mounts, so your CI catches misuse before merchants see it. **Two places, and the split is portable.** The `style` object reaches *inside* the secure card iframe; your own CSS on the mount `` `
` `` styles the box *around* it and renders identically on both surfaces. The themes below therefore put in `style` only the tokens that reach the iframe on *both* surfaces — `font.family`, `color.text` / `placeholder` / `error` / `focus` — and `border`, `padding`, `background`, `font-size` / `font-weight` on the wrapper. A surface-specific token placed in `style` for the wrong surface is not applied, and the SDK logs a `console.warn` naming the dropped token. ### Where each token lands {#token-map} A **`Your wrapper
`** cell means the binder does not apply that token to the iframe on that surface — style it in your own DOM. | Style token | `"embed"` (default) | `"elements"` (discrete) | | --- | --- | --- | | `font.family` | Card iframe | Card iframe | | `font.size`, `font.weight` | **Your wrapper `
`** | Card iframe | | `color.text` / `.placeholder` / `.error` / `.focus` | Card iframe | Card iframe | | `color.label` / `.accent` | Card iframe | **Your wrapper `
`** | | `background`, `surface.field` | Card iframe | Card iframe | | `surface.container` / `.page` / `.hover` / `.unchecked` | Card iframe | **Your wrapper `
`** | | `spacing.padding` | **Your wrapper `
`** | Card iframe | | `spacing.margin` | **Your wrapper `
`** | **Your wrapper `
`** | | `border.radius` / `.color` / `.width` | Card iframe ¹ | **Your wrapper `
`** | ¹ On `"embed"`, `border.radius` and `border.width` snap to the binder's preset scale — radius → `none` / `subtle` / `rounded`, width → `none` / `thin` / `thick` — and `border.color` applies as a direct color; a non-px value is reported unsupported and belongs on the wrapper. See [inner vs outer iframe](#inner-iframe-vs-outer-iframe). Accounts on a direct-card processor render a third way: the card iframe takes `font.*` and `color.text` / `.placeholder` / `.error` only; `border`, `spacing`, `background`, `surface.*` and `color.focus` are dropped (the console names them) and belong on your wrapper. ### Default — what you get if you pass nothing {#default-style} ```javascript const elements = vora.elements.create(); const card = elements.create("card", { // No style — the SDK's default look applies, property by property. }); ``` A card field you do not style is still a visible field. Any property you leave out is filled from this default, so an empty `style` gives you all of it: | Group | Default | | --- | --- | | `font` | system font stack, `16px`, weight `400` | | `color` | text `#1a1a1a` · placeholder `#6b7280` · error `#b91c1c` | | `border` | `1px` `#d1d5db`, radius `6px` | | `spacing` | padding `12px` | | `background` | `#ffffff` | | placeholder text | `1234 1234 1234 1234` · `MM / YY` · `CVC` | Three things to know about it: - **It is a floor, not an override.** Layers merge in the order default → collection `theme` → element `style`, and inside `font`, `color`, `spacing` and `border` they merge **per property**. Passing `{ color: { text: "#fff" } }` changes the text colour and keeps the default border, placeholder colour and everything else; it does not drop you back to an unstyled field. `background` and `surface` are replaced whole. - **It applies to the card surfaces only** — `card`, `card-number`, `card-expiry` and `card-cvc`, the ones rendered inside the secure iframe. `email`, `cardholder` and `address` are ordinary inputs in your own page and inherit your stylesheet as before. - **`font.size` is `16px` on purpose.** Mobile Safari zooms the whole page when a focused input is smaller than that, which throws the layout around under the buyer's thumb mid-payment. If you set your own size, keep it at `16px` or above on mobile. How much of the default you *see* follows the [token map](#token-map) exactly as your own values do: a default border lands only where `border.*` reaches the card iframe. On the `"elements"` surface it does not, and what keeps an unstyled field visible there is the placeholder text, background and padding. ### Light + branded accent ```javascript // style: the tokens that reach the iframe on BOTH surfaces. const card = elements.create("card", { style: { font: { family: "system-ui, -apple-system, sans-serif" }, color: { text: "#1a1a1a", placeholder: "#9ca3af", error: "#dc2626" }, background: "#ffffff", }, }); ``` ```css /* Your own CSS on the element you mount into — border, radius, padding, and font size/weight. Renders the same on "embed" and "elements". */ #card-mount { border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; font-size: 16px; font-weight: 400; } ``` ### Dark mode ```javascript const card = elements.create("card", { style: { font: { family: '"Inter", system-ui, sans-serif' }, color: { text: "#e5e7eb", placeholder: "#6b7280", error: "#f87171" }, background: "#1f2937", }, }); ``` ```css #card-mount { border: 1px solid #374151; border-radius: 12px; padding: 14px; font-size: 16px; font-weight: 500; } ``` --- ## The `VoraFieldStyle` token set All elements accept a `style` option whose shape is governed by an allowlist (any key not in the allowlist throws `frame_style_invalid` at validation time, before the iframe mounts). ```typescript interface VoraFieldStyle { font?: { family?: string; // alphanumeric + space + comma + double-quote + hyphen only size?: string; // single dimension: "16px" / "1rem" / "100%" weight?: "400" | "500" | "600" | "700"; // exact string, not a free-form number }; color?: { text?: string; // hex / rgb() / rgba() / hsl() / "black" | "white" | "red" | "green" | "blue" | "yellow" | "gray" | "transparent" | "currentcolor" placeholder?: string; // same format set error?: string; // same format set label?: string; // field-label color; defaults to follow color.text when unset accent?: string; // accent / selected payment-method indicator color focus?: string; // focus-ring color }; spacing?: { padding?: string; // SINGLE dimension only — "12px", NOT "12px 16px 8px 16px" margin?: string; // same single-dimension constraint }; border?: { color?: string; // same color format set width?: string; // single dimension radius?: string; // single dimension }; background?: string; // TOP-LEVEL string (not background.color); flat fill for the field + container surface?: { // per-layer background colors; any value set here wins over `background` field?: string; // the card input field background container?: string; // a method-option row / container background page?: string; // the outer page background behind the option rows hover?: string; // a method-option row background on hover unchecked?: string; // an unselected method-option row background }; } ``` A token is applied where the binder exposes a surface for it; where it doesn't, the token is dropped and the SDK logs a `console.warn` naming it once per surface — the payload stays portable across binder profiles. The payment-method **icons** (the card glyph, the Apple Pay / Google Pay marks) are vendor-supplied artwork rendered by the binder and are **not** themeable; the tokens recolor the surfaces around them. ### Theming the method list & surfaces On a combined card mount the `style` payload themes the whole payment surface, not just the card field: - **`color.accent`** colors interactive / selected affordances (the selected payment-method indicator). - **`color.label`** colors field labels; leave it unset to follow `color.text`. - **`color.focus`** colors the focus ring on the active input. - **`surface.*`** sets per-layer backgrounds — `page`, `container`, `unchecked`, `hover`, `field`. Any `surface.*` value wins over the flat `background` shorthand. ### The container's own classes {#container-classes} Every mounted element stamps classes on **your** container — the element you passed to `mount()` — so your stylesheet can react to what happens inside the secure iframe: | Class | When | | --- | --- | | `vora-field` | always, from mount until unmount | | `vora-field--{type}` | always — `vora-field--card`, `vora-field--card-number`, `vora-field--email`, … | | `vora-field--focus` | while the field is focused | | `vora-field--invalid` | while the field reports a validation error | | `vora-field--complete` | while the field validates | There is deliberately no `--empty` — the change event carries `complete` and an optional `error`, and no emptiness signal. Nothing is written to your element's inline `style`. The classes are automatic and separate from the `classes` option below, which lets you name your own for the same three states; both can be used together. ```css .vora-field { box-sizing: border-box; min-height: 44px; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; transition: border-color 0.15s, box-shadow 0.15s; } .vora-field--focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15); } .vora-field--invalid { border-color: #b91c1c; } .vora-field--complete { border-color: #059669; } ``` ### Pseudo-selectors — the `classes` option For focus / invalid / complete states (which `style` can't express), attach your own CSS classes: ```javascript const card = elements.create("card", { classes: { focus: "my-card-focus", // applied while focused invalid: "my-card-invalid", // applied when validation fails complete: "my-card-complete", // applied when all fields valid }, }); ``` ```css .my-card-focus { outline: 2px solid #3b82f6; } .my-card-invalid { outline: 2px solid #dc2626; } .my-card-complete { border-color: #10b981; } ``` ### Format validation — what gets rejected Even within an allowed key, values are regex-validated to prevent CSS-injection. These throw `frame_style_invalid`: - `color: { text: "var(--my-color)" }` — CSS custom properties (would let a page exfiltrate state via computed-style probing). - `font: { family: "'; body { display: none; }" }` — quote injection. - `spacing: { padding: "12px 16px" }` — shorthand; only a single dimension is allowed. - `font: { weight: 600 }` — weight is a strict string union; `"600"` works, `600` doesn't. - `border: { width: "thick" }` — keywords; only `\d+(\.\d+)?(px|em|rem|%)`. For asymmetric padding, shadows, transitions, or hover states, apply those on your **outer wrapper** `
` (your DOM) — visually identical, no allowlist limit. Theme set on the collection is inherited by all child elements; element-level `style` overrides per-property (shallow top-level merge). --- ## Layout & display options Beyond the `style` token set, a few **per-element options** control layout and which UI the binder renders — the look-and-feel knobs that aren't colors or fonts. They live on each element's options object (full per-element context in the [Elements reference](elements.md)); they're gathered here so you can tune the whole look from one page. All are **UI-surface only** — they change what the buyer sees, never what's charged. **Card field (`card` element):** | Option | Values | Default | Effect | |---|---|---|---| | `compactPaymentOptions` | `true` · `false` | `true` | The compact-vs-spacious density toggle — `true` tightens vertical spacing between payment-method rows; `false` gives a roomier layout. | | `display` | `"all"` · `"addOnly"` · `"storedOnly"` · `"supportsTokenization"` | `"all"` | Which method surfaces render (new-card form, saved methods, wallets). Monolith-embed binders only. | | `autoSelectOption` | `"first"` · `"firstStored"` · `"firstNonStored"` · `"none"` | `"first"` | Which method is pre-selected on load. | | `hidePostalCode` | `true` · `false` | `false` | Hide the postal-code input the card iframe renders. | | `hideBrandIcon` | `true` · `false` | `false` | Hide the card-brand icon inside the card-number field. | | `disableAutofillAssist` | `true` · `false` | `false` | Hide the binder's autofill / login-link pill. | | `placeholder` | `{ number?, expiry?, cvc? }` | SDK default, merged per key ([Default](#default-style)) | Custom placeholder text per sub-field. | **Method picker & wallet buttons:** | Option | Values | Default | Effect | |---|---|---|---| | `buttonLayout` | `"horizontal"` · `"vertical"` | `"horizontal"` | Button-row layout on the `payment-method-picker` and `express-checkout` elements. | For per-element specifics (which binder honors which knob), see the [Elements reference](elements.md). ## Inner iframe vs outer iframe {#inner-iframe-vs-outer-iframe} The single most important styling concept: **what's inside the binder's iframe vs what's your own DOM.** - **Inner iframe** = what the secure iframe renders. Under `"embed"` (default) this is the whole monolith — card number / expiry / CVC plus the wallets, billing address, and method-picker rows. Under `"elements"` it's the combined card field (number + expiry + CVC). Either way it's served from the secure-fields domain, so you **can't reach it with CSS** (cross-origin); the only way to style it is the `style` option above. - **Outer iframe** = your DOM. The container `
` you mount into, the label above it, the wrapper around it, every non-PCI field (email, name, summary, CTA — plus billing address when you're in `"elements"` mode and mount it yourself), the page chrome — all your HTML and CSS, no restrictions. The rule the SDK enforces: **the element is the unit of placement; internal layout is the binder's territory.** You decide where each element appears in your page; you can't decide where individual wallet buttons sit *inside* the card mount. ### What you control where | Surface | Controllable via | What you can change | |---|---|---| | Inner iframe (card fields, wallets) | `style` option on `elements.create(...)` | Colors (text / label / placeholder / error / accent / focus), per-layer surface backgrounds, `border`, and `font.family`. `font.size` / `font.weight` / `spacing.padding` apply only on the `"elements"` surface; `spacing.margin` is never applied to the iframe — put those on the outer wrapper | | Outer wrapper around the iframe | Your CSS on the mount `
` and its parents | Anything — box-shadow, gradients, hover states, transitions, position, size, focus rings, floating labels | | Non-PCI fields (`email`, `save-for-future-use`) | Your CSS on the element's mount + the `style` option | Almost anything — these render in your DOM, not an iframe | | Non-Vora content (shipping, summary, header, footer, CTA, layout) | Your HTML + CSS | Anything — the SDK doesn't touch it | ### Things you can't do, and why | Want to do | Why it doesn't work | What does work | |---|---|---| | `box-shadow`, gradients, transitions on the iframe | Allowlist blocks free-form CSS in the iframe (clickjacking defense) | Apply on the outer wrapper `
` — visually identical, fully your CSS | | Multi-value padding (`"12px 16px"`) | `spacing.padding` is single-dimension only | Single value to the iframe + outer-wrapper padding for asymmetric spacing | | Recolor the wallet / card icons | Vendor-supplied artwork rendered by the binder | The `style` tokens recolor the surfaces around the marks | | Custom fonts (`Poppins`, `Inter`) inside the iframe | The iframe is served from the binder origin, which doesn't fetch your fonts | The iframe falls back to a system sans; your outer-DOM labels/fields use custom fonts freely | | Float a label inside the iframe | Iframe content is secure-fields-rendered — can't inject DOM | In `elements` mode (see [Render modes](binder-profiles.md)) the card iframe shows only the input values (no internal labels), so a floating-label wrapper works: draw the box + label on your own outer `
` and mount the card into a padded area inside it | --- ## Test the themes locally {#test-the-themes-locally} Point your integration at a test key and swap the `style` object between the themes above. A [sandbox test card](../reference/test-cards.md#test-cards) such as `4111 1111 1110 1203` runs the full successful tokenization path at an ordinary total, so you can see the completed state; [Test mode](../reference/test-cards.md#processor-test-account) has the totals that produce a decline if you want to style those states too. --- ## What's next - **[Quickstart](quickstart.md)** — end-to-end card-only happy path - **[Custom checkout with Elements](custom-checkout.md)** — mount discrete elements (card, email, name, address) where you choose on the page. The card is collected **either** as the combined box (number + expiry + CVC together) **or** as the three discrete secure fields — your choice per integration - **[Elements reference](elements.md)** — per-element options + collect shapes - **[3D Secure](3ds.md)** — sending the buyer to the bank's challenge and back --- ## Element reference (field & element catalog) Source: https://docs.vonpay.com/embedded-fields/elements # Embedded Fields — Element reference The catalog of UI **elements** you can create with `vora.elements.create()`: the options each accepts, the events it fires, and the value it contributes to the collection's `submit()` result. In the default `"embed"` render mode the `card` element is one iframe that renders the entire payment surface — card fields plus, when the buyer is eligible, Apple Pay / Google Pay — and only `email` and `save-for-future-use` are placed separately; in `"elements"` mode you place `cardholder`, `address` and the others as their own fields, and the card is either the combined `card` box or the three discrete [`card-number` / `card-expiry` / `card-cvc`](#discrete-card-fields) fields. Which mode renders which element, the per-binder capability matrix and the silent fallback: [Render modes: embed vs elements](binder-profiles.md); the elements-mode walkthrough: [Custom checkout](custom-checkout.md). ## Status overview Every kind you can pass to `vora.elements.create(kind, options)`: | `create()` kind | What it is | |---|---| | `card` | The combined card iframe (number + expiry + CVC in one box) — your payment surface. See [`card`](#card--pan--expiry--cvc-iframe). | | `card-number` | Discrete PAN field — mount independently in `"elements"` mode. Adds `showIcon`. See [Discrete card fields](#discrete-card-fields). | | `card-expiry` | Discrete expiry (MM / YY) field. See [Discrete card fields](#discrete-card-fields). | | `card-cvc` | Discrete security-code (CVC) field. See [Discrete card fields](#discrete-card-fields). | | `email` | VORA-rendered email input with validation. See [`email`](#email--email-input). | | `address` | Billing-address form rendered in your page (`"elements"` mode). See [`cardholder` and `address`](#cardholder-and-address--inputs-in-your-page). | | `cardholder` | "Name on card" text input rendered in your page (`"elements"` mode). See [`cardholder` and `address`](#cardholder-and-address--inputs-in-your-page). | | `save-for-future-use` | Compliance-locked opt-in checkbox. See [`save-for-future-use`](#save-for-future-use--opt-in-checkbox). | | `saved-methods` | Discrete saved-card picker for `elements` mode. It renders its `unavailable` state for every account — the saved-card read surface behind it is not enabled — never an empty list, so do not build a returning-buyer flow on it. Returning buyers see their saved cards inside the `card` mount in `embed` mode. | | `express-checkout` | Standalone Apple Pay / Google Pay **wallet button** row. Use this only when you create the session with `integrationMode: "elements"` (discrete rendering) — in the default `"embed"` mode the wallet buttons render inside the `card` mount instead, with no separate element to add. See [Standalone wallets](../guides/standalone-wallets.md). | | `payment-method-picker` | Method-selector button row. Surfaces the buyer's choice on `submit()` — it does not tokenize. See [`payment-method-picker`](#payment-method-picker--method-selector-button-row). | The Apple Pay / Google Pay wallet button is the `express-checkout` element; [Standalone wallets](../guides/standalone-wallets.md) covers it. There is no `wallet-*` element. > **Cardholder name and billing address** are collected inside the `card` mount in the default `"embed"` integration mode, so you don't mount separate elements for them. If you create the session with `integrationMode: "elements"` (and your account's gateway supports discrete rendering), `cardholder` and `address` mount as standalone fields instead; see [Custom checkout (Elements mode)](custom-checkout.md). --- ## Apple Pay & Google Pay — built into the card mount In the default **`"embed"`** integration mode, when the buyer's device supports a wallet (Apple Pay on Safari / iOS, Google Pay on Chrome with a saved card) **and** your domain is verified with each wallet network, the wallet button appears automatically as part of the card mount. There's no separate element to add — `elements.create('card', …)` is the only thing your integration needs. (In `integrationMode: "elements"` you instead mount the standalone [`express-checkout`](#status-overview) element for the wallet buttons.) Placement inside the mount is decided by the gateway runtime so the buyer sees the same wallet UI they see on other checkouts — sometimes a row above the card inputs, sometimes a tab, sometimes a sheet that takes over the mount when tapped. **Eligibility gates** (all must be true for the button to appear): 1. The buyer's device + browser supports the wallet. Apple Pay needs Safari (macOS / iOS) or a Mac with Touch ID + paired iPhone. Google Pay needs Chrome, Edge, or any Chromium-based browser signed in to a Google account. 2. The buyer has a payment method on file with the wallet. 3. Your serving domain is registered with the wallet network for your active merchant account (new merchants are registered automatically on first session-create). **Failure-mode contract:** if any gate fails, the wallet button doesn't appear and the buyer completes checkout via the card field. The button never appears pointing at the wrong destination, and nothing blocks the card field. (The wallet error codes and the `wallet` field on the result belong to the standalone `express-checkout` element in `elements` mode — the embed emits neither.) **Buyer flow when the button appears:** the buyer taps the button, authenticates in the wallet sheet (Face ID, Touch ID, the Chrome dialog), and `elements.submit()` resolves with the same two arms as a card in `embed` mode — `charged: true` for a guest, or `token` for a buyer on the session. **The tap has already moved the money on a charge-and-save session** — a returned token is a vaulted card, not a handle to charge this payment again; do not call `POST /v1/payment_intents` for it. Issuer authentication is usually satisfied by the wallet's device attestation; when a challenge is required the embed runs it inside its own iframe. Branch as in [`SubmitResult` shape](#submitresult-shape), and confirm settlement via the webhook before fulfilling. **Apple Pay domain setup.** Each domain that serves Apple Pay needs a one-time verification ceremony with Apple — host a small file at `https://{your-domain}/.well-known/apple-developer-merchantid-domain-association` and we trigger the verification call. Full walkthrough at [Apple Pay setup](apple-pay-setup.md), including framework-specific hosting recipes (Next.js, Vite, Vercel, Cloudflare, Nginx, Apache, Express) and what to do when verification fails. Google Pay doesn't need this step — Google verifies the page's TLS handshake on the buyer's first wallet sheet. **Testing.** `http://localhost` shows the card field but not the wallet buttons (Apple Pay refuses non-HTTPS). To test them end-to-end, run your integration on an HTTPS dev domain (any tunnel with a public `https://` URL), add that domain under **Settings → Wallet Domains** in the dashboard and complete the [Apple Pay domain verification](apple-pay-setup.md); Google Pay needs no extra step. --- ## Creating elements The `card` element is your payment surface. Create an elements collection, add the card element, and optionally mount `email` and `save-for-future-use` alongside it: ```javascript const elements = vora.elements.create({ theme: { font: { size: "16px" }, color: { text: "#1a1a1a" } }, }); const card = elements.create("card", {}); // no style → the SDK default look applies card.mount("#card-element"); // Optional — separate SDK-rendered fields you place and style yourself: const email = elements.create("email", {}); email.mount("#email-element"); const save = elements.create("save-for-future-use", {}); save.mount("#save-element"); // Submit collects from every mounted element and drives the charge. // Branch the result in the order under "SubmitResult shape" below — // error → chargeStatus → token → charged — and confirm settlement via the // webhook before fulfilling. const result = await elements.submit(); ``` The `elements` collection shares theme defaults across child elements. #### `SubmitResult` shape ```typescript interface SubmitResult { token?: string; // vp_pmt_* — present only when a reusable method was vaulted charged?: boolean; // did money move? true = captured; false = attempted, not moved (held / pending / 3DS); absent = no boolean from the server chargeStatus?: "succeeded" | "pending" | "requires_action"; redirectUrl?: string; // present ONLY when chargeStatus === "requires_action" recovered?: true; // outcome recovered by polling after an ambiguous failure paymentIntentId?: string; // vpi_* — reconcilable reference for a charge transactionId?: string; // vp_tx_* — reconcilable reference on a guest or recovered charge last4?: string; // display-only brand?: string; // "visa" | "mastercard" | … (display-only) expMonth?: number; // 1-12, camelCase, absent when unavailable — expYear?: number; // see Tokenization § Card expiry on the result email?: string; cardholder?: string; // cardholder name (a plain string, not an object) billingAddress?: BillingAddressValue; setupForFutureUse?: boolean; // true when the buyer opted to save (vaulted off_session) error?: VoraMirrorError; // present iff submit failed } ``` `SubmitResult` is **one flat interface — every field is optional**. Discriminate at runtime in this order; `requires_action` and `pending` must be handled before you look at `token` / `charged`, because neither is a completed charge and both can fall through a token-first branch: 1. **`error`** — validation or charge failure. Surface `result.error.message`. Nothing was charged. 2. **`chargeStatus: "requires_action"`** — the charge needs a 3-D Secure challenge and nothing is charged until it completes. Send the buyer to `result.redirectUrl` (single-use, bearer-equivalent — do not log or persist it). 3. **`chargeStatus: "pending"`** — the charge was submitted and settlement is asynchronous; `charged` is `false`. A `token` may be present when a buyer is on the session (the card is vaulted at authorisation) — it is not a signal that the charge settled. Await the `charge.*` webhook, which is authoritative; `paymentIntentId` is your reconcilable reference. Do not start a second payment. 4. **`token`** — a reusable `vp_pmt_*` was vaulted. On a **charge-and-save** session the card was *already charged* in the same step (or, on a [`captureMethod: "manual"`](/integration/create-session#capture-method) session, *authorised and held*: `charged` is `false` and the money moves only when you capture) — do **not** also call `POST /v1/payment_intents` for that session; on a **tokenize-only** session nothing has been charged and the token is what you charge server-side. Branch on which session you created — [Tokenization](tokenization.md#charge-and-save). 5. **`charged`** — `true` means money moved: a guest (no buyer on the session) gets `charged: true` alone, charged once, nothing vaulted, no token; on charge at submit a buyer gets it beside `token`. `false` means a charge was attempted and money has not moved; `chargeStatus` says why (`pending`, `requires_action`, or `succeeded` for a hold). Absent means the SDK had no boolean from the server: treat it as unknown, never as paid or failed. Fulfil only on `charged === true`. `last4` / `brand` are display-only. `recovered: true` marks an outcome resolved by polling after an ambiguous failure the browser never saw a clean response for. The charge is real — never retry a recovered charge; the token, when a buyer was on the session, arrives on the `charge.succeeded` webhook. Do not act on `charged` for a recovered result either: it carries no `paymentIntentId`, only `transactionId`. Confirm the payment server-side first. The interface above lists the fields you branch on; `email`, `cardholder`, `billingAddress`, `setupForFutureUse` and `picker` ride the same object when their element or flow was involved. AVS/CVV results are not in it: read `avs_result_code` / `cvv_result_code` on your server from the `charge.succeeded` / `charge.failed` webhooks. ```typescript if (result.error) { // failed — nothing was charged } else if (result.chargeStatus === "requires_action") { window.location.href = result.redirectUrl; // 3-D Secure; confirm on the webhook return; } else if (result.chargeStatus === "pending") { // charge in flight — await the charge.* webhook. Do NOT charge again. } else if (result.recovered) { // resolved by polling after a lost response. Never fulfil on result.charged // here; confirm server-side (result.transactionId). Do NOT charge again. } else if (result.token) { // charged + vaulted (buyer on session) } else if (result.charged) { // guest charge-only (no token) } ``` The client result is a UX signal; confirm settlement via the webhook before fulfilling. Optional fields are present iff the corresponding element was mounted. `setupForFutureUse` is a boolean — `true` when the buyer checked the save-for-future-use box; the token's reusability scope lives server-side ([Tokenization](tokenization.md)). --- ## `card` — PAN / expiry / CVC iframe Your payment surface. The iframe is served directly from the active binder's CDN; your DOM never sees the PAN. ### Options ```typescript interface CardElementOptions { style?: VoraFieldStyle; classes?: { focus?: string; invalid?: string; complete?: string }; placeholder?: { number?: string; expiry?: string; cvc?: string }; // these three keys ONLY challengeTimeout?: number; // ms — default 300_000 (5 minutes); clamped to 600_000 (10-min) max locale?: string; // BCP-47, e.g. "en-US"; English only today disable3dsModal?: boolean; // no effect where the processor draws its own challenge sheet // Card-iframe rendering knobs. Each is opt-in (default off); // the binder ignores a knob for UI it doesn't render. disableAutofillAssist?: boolean; // suppress the binder's autofill / login-link pill hidePostalCode?: boolean; // hide the inline postal-code input the card iframe renders hideBrandIcon?: boolean; // hide the card-brand icon inside the card-number field // Payment-method display controls. Monolith-embed binders only; // direct-card binders ignore them. display?: "all" | "addOnly" | "storedOnly" | "supportsTokenization"; // default "all" autoSelectOption?: "first" | "firstStored" | "firstNonStored" | "none"; // default "first" compactPaymentOptions?: boolean; // default true — tighten spacing between method rows } ``` #### Placeholder text — three keys, and only three `placeholder` on the combined `card` element takes an **object**, not a string. Exactly three keys are accepted: ```javascript const card = collection.create("card", { placeholder: { number: "Card number", expiry: "MM / YY", cvc: "CVC" }, }); ``` An unrecognised key is not applied — the field keeps its default label and the SDK logs a `console.warn` naming the accepted keys. The keys are `number`, `expiry`, `cvc`, not the processor's own names (`expiryDate`, `securityCode`). Placeholder text is applied by binders that render their own field labels; a binder whose card iframe doesn't expose per-field placeholders ignores it, the same way it ignores any rendering knob for UI it doesn't draw. On the **discrete** fields (`card-number` / `card-expiry` / `card-cvc`) `placeholder` is a plain **string** on each element instead — see [Custom checkout](custom-checkout.md). > Don't confuse this with `style.color.placeholder`, which sets the placeholder's **colour**. The two are unrelated and both can appear in the same options object. #### Card-iframe rendering knobs These three options suppress optional UI the binder renders inside its card iframe. Each is OPT-IN — set to `true` to suppress, omit (or set to `false`) to leave the binder's default behavior intact. A knob for UI the binder doesn't render is ignored silently. | Option | What it does | Typical use case | |---|---|---| | `disableAutofillAssist` | Hides the saved-card / login-link autofill pill the card iframe renders inline. | Merchants whose checkout already has its own saved-card UX, or who want a minimal-chrome card field with no autofill prompt. | | `hidePostalCode` | Removes the postal-code input the card iframe renders alongside PAN / expiry / CVC. | Merchants collecting postal code elsewhere in their own form — avoids asking for it twice. | | `hideBrandIcon` | Hides the card-brand icon (Visa / Mastercard / …) the binder renders inside the card-number field. | Merchants whose visual style requires a flat-text card field with no inline brand icon. | #### Payment-method display controls On a **monolith-embed binder**, the card mount renders the full payment-method list — the new-card form, any saved methods, and the Apple Pay / Google Pay buttons (see [Apple Pay & Google Pay](#apple-pay--google-pay--built-into-the-card-mount)). These options shape that list. Direct-card binders render only the card field and ignore them. Saved methods only appear when a **returning buyer is identified on the session** and that buyer vaulted a card in a prior session — see [create-session → buyer identification](../integration/create-session.md#buyer-identification); a guest or first-time buyer has none, so `display: "storedOnly"` would render an empty list for them. | Option | Values | Default | What it does | |---|---|---|---| | `display` | `"all"` · `"addOnly"` · `"storedOnly"` · `"supportsTokenization"` | `"all"` | Which method surfaces render. `"all"` shows the full list including the wallet buttons; `"addOnly"` shows only the new-card form; `"storedOnly"` shows only saved methods; `"supportsTokenization"` shows only methods that support tokenization. UI-surface only — it does not change what is charged. | | `autoSelectOption` | `"first"` · `"firstStored"` · `"firstNonStored"` · `"none"` | `"first"` | Auto-selects a method on load so the buyer isn't forced to pick first. Selection is by position — `"first"` selects whatever the binder lists first (often a wallet when wallets precede the card). Defaults to `"first"`; pass `"none"` to leave nothing selected. | | `compactPaymentOptions` | `true` · `false` | `true` | Tightens the vertical spacing between method rows for a denser layout. Defaults to `true`; pass `false` for the spacious layout. | ### Events ```javascript card.on("ready", () => { /* iframe loaded and accepting input */ }); card.on("change", (event) => { // event.complete: boolean — all of number/expiry/cvc are valid // event.error?: { code: string; message: string } // scrubbed validation error; undefined when the field is valid }); card.on("focus", () => {}); card.on("blur", () => {}); ``` ### Collect — use the collection's `submit()` The canonical collect path is `elements.submit()` on the collection — see [`SubmitResult` shape](#submitresult-shape) for the unified result and its branch order: ```javascript const elements = vora.elements.create(); const card = elements.create("card", {}); // no style → the SDK default look applies card.mount("#card-element"); const result = await elements.submit(); // collection-level submit charges + (when a buyer is on the session) vaults ``` There is no `card.submit()` and a `card` created via `elements.create()` has no `.tokenize()` method — `submit()` is a **collection** method (`elements.submit()`), and it is what charges the card and resolves with the flat, five-outcome `SubmitResult`. A vaulted `vp_pmt_*` is bound to the buyer on the session that minted it; its reusability is governed by `setup_for_future_use` — [Tokenization](tokenization.md). --- ## Discrete card fields `card-number`, `card-expiry`, and `card-cvc` are the **split-field** alternative to the combined `card` element. Instead of one iframe that renders PAN + expiry + CVC together, you mount **three independently-placed secure fields** — each into its own container — and style and position them anywhere on your page. The PAN, expiry, and CVC still live inside secure field iframes, so your DOM never sees card data. The three fields **share one secure-fields controller** and tokenize **together** via a single `collection.submit()`, which resolves the same `vp_pmt_*` token the combined `card` element produces. You never tokenize a field on its own. :::note Availability — `"elements"` mode only Discrete card fields require a session created with `integrationMode: "elements"` **and** a binder that renders discrete secure fields. If the active binder doesn't support them, `.mount()` throws `frame_unsupported_element` — fall back to the combined [`card`](#card--pan--expiry--cvc-iframe) element or route the buyer to a supported binder. In the default `"embed"` mode the card is always the one combined box. ::: ### The rules - **Mount all three.** `card-number`, `card-expiry`, **and** `card-cvc` must all be created and mounted before `submit()`. Mounting only some can't produce a complete card, so `submit()` returns `frame_field_validation_failed`. - **Don't mix with the combined `card`.** A collection holds **one** card-entry surface — either the combined `card` element **or** the three discrete fields, never both. Mixing them returns `frame_field_validation_failed` from `submit()` (they would tokenize twice). - **One `submit()` for all three.** Because they share a controller, `collection.submit()` vaults all three fields in a single call — you call it once, not per field. ### Options Each discrete field takes the same base options; `card-number` adds `showIcon`: ```typescript interface DiscreteCardFieldOptions { style?: VoraFieldStyle; // the same token set every element accepts placeholder?: string; // placeholder shown when the field is empty disabled?: boolean; // default false — no input accepted when true } // card-number only: interface CardNumberElementOptions extends DiscreteCardFieldOptions { showIcon?: boolean; // show the card-brand icon inside the field; default false } // card-expiry and card-cvc take DiscreteCardFieldOptions as-is. ``` | Option | Fields | Default | What it does | |---|---|---|---| | `style` | all three | SDK default floor, then the collection `theme` | The unified `VoraFieldStyle` token set (font + color), merged per property over the [SDK default](customize.mdx#default-style). See [Customize](customize.mdx). | | `placeholder` | all three | SDK default (merged per key) — see [Customize → Default](customize.mdx#default-style) | Placeholder text shown when the field is empty. | | `disabled` | all three | `false` | Disables input on the field. **Honored on the direct-card binder only** — the secure-fields binder has no native disabled state and drops it. | | `showIcon` | `card-number` only | `false` | Show the card-brand icon (Visa / Mastercard / …) inside the number field. | ### Mount and submit ```javascript const collection = vora.elements.create({ theme: { font: { size: "16px" }, color: { text: "#1a1a1a" } }, }); // Create + mount all three, each into its own container: collection.create("card-number", { placeholder: "Card number", showIcon: true }).mount("#card-number"); collection.create("card-expiry", { placeholder: "MM / YY" }).mount("#card-expiry"); collection.create("card-cvc", { placeholder: "CVC" }).mount("#card-cvc"); // One submit() tokenizes all three fields together. Discrete fields are // elements mode, where a session can charge at submit — so branch all five // outcomes (see "SubmitResult shape"): const result = await collection.submit(); if (result.error) { // Validation or tokenize failure — surface result.error.message } else if (result.chargeStatus === "requires_action") { window.location.href = result.redirectUrl; // 3-D Secure; nothing charged yet } else if (result.chargeStatus === "pending") { // charge in flight — await the charge.* webhook. Do NOT charge again. } else if (result.recovered) { // resolved by polling after a lost response. Never fulfil on result.charged // here; confirm server-side (result.transactionId). Do NOT charge again. } else if (result.token) { // vp_pmt_* minted from all three fields. Charge it server-side via // POST /v1/payment_intents — unless the session charged at submit, in which // case the charge already ran. result.last4 / result.brand are display-only. } else if (result.charged) { // guest charge at submit — charged once, nothing vaulted } ``` The `SubmitResult` shape is identical to the combined `card` element's — see [`SubmitResult` shape](#submitresult-shape). Optional sibling-element values (`email`, `cardholder`, `billingAddress`, `setupForFutureUse`) are present when those elements were also mounted in the collection. The discrete fields expose the same `on()` event API as every element, but event granularity depends on the binder behind your account — field-scoped on the direct-card binder, whole-form on secure-fields, where `focus` / `blur` do not fire. Treat `change` as a form-level signal unless you gate on the binder; see [Element events](events.md#discrete-card-fields--field-vs-form-events). --- ## `email` — Email input VORA-rendered `` with HTML5-aligned validation. Renders as a standalone field on every binder profile. ### Options ```typescript interface EmailElementOptions { style?: VoraFieldStyle; placeholder?: string; // default "you@example.com" required?: boolean; // default true } ``` ### Collect Returns `{ email: string }` in the submit result. This element **renders and validates** an email input and hands you the value back — it does **not** attach the email to the payment or the buyer on its own. To associate an email with the buyer (so their transactions link together and duplicate-charge lookups work), set **`buyerEmail` when you create the session** — see [Buyer identification](../integration/create-session.md#buyer-identification). That is the field that flows to the buyer record. Use this element when you want VORA to render + validate the email field for you: read `result.email` from the submit result and pass it as `buyerEmail` on your server's session-create call (or set `buyerEmail` directly if you already have the address). --- ## `cardholder` and `address` — inputs in your page Both are `"elements"`-mode fields, and where they render depends on the field. `cardholder` is always a plain `` that the SDK creates and appends to your container; it carries `autocomplete="cc-name"`, so the browser may autofill it from a saved card. `address` is a small form the SDK renders the same way on a connection whose capability map declares it `proxy`; on a connection that declares it `native`, the SDK mounts the processor's own address element in that container, and that element renders inside the processor's isolated iframe like the card field. Which one you get is decided per connection by the server, not by anything you pass. For the fields that sit in your page (the cardholder name always; the address form on a `proxy` connection), **any other script on the page can read them**: a tag manager, an analytics vendor, a compromised dependency. Card number, expiry and CVC never leave the iframe and your SAQ-A scope is unchanged, but the buyer's name, and on those connections the billing address, are only as private as your page-script hygiene. If you cannot vouch for every script on the checkout page, use `embed` mode, where the card mount renders these fields inside the iframe. The [security reference](../reference/security.md#pci-compliance) states the same rule, and the [capability matrix](binder-profiles.md#capability-matrix) says which rendering your connection declares. ### Options ```typescript interface CardholderElementOptions { style?: VoraFieldStyle; placeholder?: string; // default "Name on card" required?: boolean; // default true } interface AddressElementOptions { style?: VoraFieldStyle; mode: "billing"; // the only mode today fields?: { line1?: { required?: boolean }; line2?: { hidden?: boolean }; // hide the apartment / suite line city?: { required?: boolean }; state?: { required?: boolean }; postalCode?: { required?: boolean }; country?: { default?: string }; // ISO 3166-1 alpha-2, default "US" }; allowedCountries?: string[]; // ISO 3166-1 alpha-2; omit for all } ``` ### Mount and collect ```javascript const collection = vora.elements.create(); const card = collection.create("card"); const cardholder = collection.create("cardholder", { placeholder: "Name on card" }); const address = collection.create("address", { mode: "billing", fields: { line2: { hidden: true } } }); card.mount("#card"); cardholder.mount("#cardholder"); address.mount("#billing-address"); const result = await collection.submit(); // result.cardholder → "Ada Lovelace" // result.billingAddress → { line1, line2?, city, state, postalCode, country } ``` `submit()` forwards both values with the card token; you do not send them separately. The billing address is what address verification runs against, so read [Address verification](../guides/address-verification.md) for which fields to collect per country. --- ## `save-for-future-use` — Opt-in checkbox VORA-rendered checkbox + label that controls the **reusability** of the token your submit returns; it is always SDK-rendered, on every binder, and has no scope option. Checked, the submit vaults the token as reusable (`setup_for_future_use: off_session`); unchecked, single-use (`null`) — usable only by the originating intent. A server-side caller can set the scope without the checkbox via [`POST /v1/tokens`](../reference/tokens.md). Full model: [Tokenization — reusability](tokenization.md#reusability--setup_for_future_use). ### Compliance lock The checkbox **starts unchecked** and there is no `defaultChecked` option: storing a payment method for future merchant-initiated charges needs the buyer's active opt-in, and a pre-checked box would void that consent record. ### Options ```typescript interface SaveForFutureUseElementOptions { style?: VoraFieldStyle; label?: string; // default "Save this card for future purchases" } ``` ### Collect Returns `{ setupForFutureUse: boolean }` — `true` when the buyer checked the box. --- ## `payment-method-picker` — Method-selector button row A VORA-rendered button row that lets the buyer pick **which** payment method to use. Unlike the other elements, the picker **does not tokenize or charge** — it surfaces the buyer's choice on `submit()` and leaves your checkout UX to route the next step (mount the `card` element, hand off to `express-checkout`, and so on). > Available on **direct-card (proxy)** binders. On a **monolith-embed** binder the active binder renders its own method selector, so `elements.create('payment-method-picker', …)` **throws** `frame_unsupported_element` at create time — drop the element and let the binder's UI handle method selection. ### Options `create()` takes a **required** options object — `elements.create('payment-method-picker', { … })`. ```typescript interface PaymentMethodPickerElementOptions { style?: VoraFieldStyle; methods?: PaymentMethodType[]; // omitted/empty = every method the binder supports defaultMethod?: PaymentMethodType; // pre-select on render (still mutable); default: no pre-selection buttonLayout?: "horizontal" | "vertical"; // default "horizontal" } type PaymentMethodType = | "card" | "apple_pay" | "google_pay" | "link" | "cashapp" | "klarna" | "affirm"; ``` | Option | Values | Default | What it does | |---|---|---|---| | `methods` | `PaymentMethodType[]` | every supported method | Methods to surface, in render order (left-to-right for `horizontal`, top-to-bottom for `vertical`). The list is **capability-filtered** against the active session's binder — see below. | | `defaultMethod` | a `PaymentMethodType` | none | Pre-selects a method on render so the buyer isn't forced to pick first. The selection is still mutable. A `defaultMethod` that isn't a recognized `PaymentMethodType` throws `frame_field_validation_failed` at create time. | | `buttonLayout` | `"horizontal"` · `"vertical"` | `"horizontal"` | Row of buttons (wraps on narrow viewports) vs. one button per row. | #### Capability filtering The `methods` you pass are **intersected with the session's binder capability map**: any method the binder declares `not_available` for is hidden even if you listed it, and methods you didn't list are hidden too. When you explicitly supply `methods` and one or more are dropped, the SDK emits a one-time `console.warn` naming the filtered methods so the filtering isn't silent. If the intersection is empty, `mount()` throws `frame_unsupported_element` — review your `methods` array or route the buyer to a binder that supports at least one of them. ### Collect — the nested `picker` result The picker contributes a **nested** `picker` group to the collection's `SubmitResult`, present only when the element was mounted. The picker neither charges nor vaults, so it never sets `token` / `charged`: ```typescript interface PickerSubmitResult { selectedMethod: PaymentMethodType; // the method the buyer chose } ``` ```javascript const elements = vora.elements.create(); const picker = elements.create("payment-method-picker", { methods: ["card", "apple_pay", "google_pay"], defaultMethod: "card", buttonLayout: "horizontal", }); picker.mount("#picker-element"); const result = await elements.submit(); if (result.error === undefined) { switch (result.picker?.selectedMethod) { case "card": // mount the `card` element and run the card path break; case "apple_pay": case "google_pay": // hand off to `express-checkout` break; // "link" | "cashapp" | "klarna" | "affirm" — route per your checkout UX } } ``` Read `result.picker?.selectedMethod` to branch; the optional chaining covers the picker not being mounted. --- ## Styling & theming Every element accepts a `style` option — the `VoraFieldStyle` token set (font, color, spacing, border, background, per-layer surfaces) — plus a `classes` option for focus / invalid / complete pseudo-states. The **complete styling reference** — the full token set, format-validation rules, the inner-vs-outer-iframe model, the three reference themes, and an interactive playground — lives on one page: ➡️ **[Customize the look & feel](customize.mdx)** --- ## What's next - [Error handling](errors.md) — `VoraMirrorError` shape, codes, retry semantics - [Quickstart](quickstart.md) — end-to-end integration walkthrough --- ## Error handling Source: https://docs.vonpay.com/embedded-fields/errors # Embedded Fields — Error handling Embedded Fields' error surface is `VoraMirrorError` — a typed exception with a stable `code`, a human-readable `message`, and optional sub-hints. Server-side errors from your own calls to `/v1/*` are a different surface: [Reference → Error codes](../reference/error-codes.md). `FrameError` is a deprecated alias of the same class. ## Detection The browser SDK is a ` ``` `vora.js` is delivered from the CDN only — `@vonpay/vora-js` is not on npm — and it is the only script you load: the processor is selected server-side, and a processor SDK's own tokens are rejected by `POST /v1/payment_intents`. To pin a version with Subresource Integrity, see [Script-tag integration (SRI)](sri.md). ## Step 2 — Create a session on your server A session is one buyer's checkout attempt; create it when the buyer is ready to pay, not on every page view (it has a 30-minute TTL — [when to create it](../integration/create-session.md#when-to-create-the-session)). Your server creates it with the secret key: ```typescript // server/api/create-session.ts (Node example) const app = express(); app.use(express.json()); const SECRET = process.env.VON_PAY_SECRET_KEY; // vp_sk_test_* const API = process.env.VONPAY_API_BASE ?? "https://checkout.vonpay.com"; app.post("/api/create-session", async (req, res) => { const response = await fetch(`${API}/v1/sessions`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${SECRET}`, }, body: JSON.stringify({ amount: 4999, // $49.99 in cents currency: "USD", successUrl: "https://mystore.com/thank-you", cancelUrl: "https://mystore.com/cart", // integrationMode defaults to "embed" (one combined card box). // Set "elements" for discrete, individually-placed fields — see Custom checkout with Elements. }), }); const session = await response.json(); // session.id is vp_cs_test_* — safe to send to the browser. res.json({ session_id: session.id }); }); ``` The session id is `vp_cs_test_*` for sandbox keys, `vp_cs_live_*` for live keys. This Quickstart omits `integrationMode`, so the session renders in the default `embed` mode — one card box that renders the whole payment surface. For discrete, individually-placed fields create the session with `integrationMode: "elements"` ([Custom checkout with Elements](custom-checkout.md)). ## Step 3 — Initialize VORA in the browser The script from Step 1 attaches the `Vora` constructor to `window`: ```javascript // `Vora` is the global the CDN script attached (see Step 1). const vora = new window.Vora({ publishableKey: "vp_pk_test_…", // your publishable key apiBaseUrl: "https://checkout.vonpay.com", // this is the default — it is NOT inferred from // your key. It must name the same host your // server called in Step 2. }); ``` Constructor validations: - `publishableKey` is required and must start with `vp_pk_test_` or `vp_pk_live_`. A secret key throws a `TypeError` immediately. - The page must be served over HTTPS (or `localhost` for development); an insecure context throws `frame_insecure_context`. - `apiBaseUrl` defaults to `https://checkout.vonpay.com` and is **not** derived from your key — both key modes are served by the same host. If your browser and your server name different hosts, your server calls succeed and every browser call fails `401 auth_invalid_key_publishable` ([Which environment am I talking to?](../guides/sandbox.md#which-environment)). ### Typing `window.Vora` in a bundler There is no package to import, so declare the global. Save this as `vora.d.ts` anywhere TypeScript picks up types: ```typescript export {}; declare global { interface Window { /** Attached by the CDN script in Step 1. */ Vora: new (config: { /** Publishable key — `vp_pk_test_*` or `vp_pk_live_*`. */ publishableKey: string; /** Defaults to https://checkout.vonpay.com. Must name the same host your * server called in Step 2. */ apiBaseUrl?: string; /** BCP-47 locale for field labels and validation messages. */ locale?: string; /** API version pin; defaults to current. */ apiVersion?: string; /** Set `false` to opt out of load-timing telemetry, which is ON by * default for both key types. */ telemetry?: boolean; /** Fraction of loads to sample for performance timing. */ performanceSampleRate?: number; }) => VoraInstance; } } /** Deliberately open. Copying the full instance surface into your project would * drift from the CDN build the first time we ship one. */ type VoraInstance = Record; ``` `publishableKey` is typed `string`, as in the SDK; the constructor's runtime check is the guard. ### Telemetry (optional) The SDK sends fire-and-forget, PII-free diagnostics — failure events (tokenize / binder-load errors) and load timings: error codes, the operation name, the SDK version and elapsed milliseconds, never card data, buyer identifiers or session ids. Failure telemetry is on for `vp_pk_test_*` keys and off for `vp_pk_live_*`; load-timing telemetry is on for both. ```javascript const vora = new Vora({ publishableKey: "vp_pk_live_…", telemetry: false, // full opt-out — disables BOTH failure and load-timing telemetry // performanceSampleRate: 0, // alternatively, keep failure telemetry but silence load timings // (0..1 sampling fraction; default 1) }); ``` ## Step 4 — Retrieve the session This fetches `/v1/public/sessions/:id` and lazy-loads the binder adapter for your account's active card processor — your code does not pick a binder: ```javascript // Browser — fetch the session_id from your server first const res = await fetch("/api/create-session", { method: "POST" }); const { session_id } = await res.json(); const session = await vora.sessions.retrieve(session_id); // session.id, session.amount, session.currency, session.binderConfig (opaque), … ``` ## Step 5 — Mount the card field ```html
``` ```javascript const elements = vora.elements.create(); const card = elements.create("card", { style: { font: { size: "16px", family: "system-ui, sans-serif" }, color: { text: "#1a1a1a", placeholder: "#9ca3af" }, }, // Exactly three keys are accepted: number, expiry, cvc. // Any other key is not applied — the SDK logs a console.warn naming these three. placeholder: { number: "4242 4242 4242 4242", expiry: "MM / YY", cvc: "CVC" }, }); card.mount("#card-element"); // Unlock the pay button once the field is ready card.on("ready", () => { document.getElementById("pay-button").disabled = false; }); card.on("change", (event) => { // event.complete = true when number/expiry/cvc all valid document.getElementById("pay-button").disabled = !event.complete; }); ``` Both `style` and `placeholder` are optional: the SDK applies its own default look and your values override it property by property ([Default style](customize.mdx#default-style)). Style the box *around* the field from your own stylesheet — the SDK stamps `vora-field` on the container you mounted into, plus `vora-field--focus`, `--invalid` and `--complete` as the field's state changes inside the iframe: ```css .vora-field { box-sizing: border-box; min-height: 44px; padding: 12px; border: 1px solid #d1d5db; border-radius: 6px; background: #fff; transition: border-color 0.15s, box-shadow 0.15s; } .vora-field--focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgb(37 99 235 / 0.15); } .vora-field--invalid { border-color: #b91c1c; } .vora-field--complete { border-color: #059669; } ``` The full class list is on [Customize the look → The container's own classes](customize.mdx#container-classes). In the default `embed` mode this one iframe is your **entire payment surface**: card number / expiry / CVC together and — where your account supports them — cardholder name, billing address, the payment-method picker, saved methods and the Apple Pay / Google Pay buttons (when the device and your domain are eligible — [eligibility model](elements.md#apple-pay--google-pay--built-into-the-card-mount)). The only elements you place separately are `email` and `save-for-future-use`; the card mount already collects the name and address, so mounting `cardholder` or `address` as well renders a second field, and `payment-method-picker` throws at create time. To lay those out yourself, see the [Elements reference](elements.md). ## Step 6 — Tokenize on submit Branch on the result — `error`, then `chargeStatus` where the session can charge at submit, then `token` / `charged` — rather than reading `result.token` unconditionally: ```javascript document.getElementById("pay-button").addEventListener("click", async () => { const result = await elements.submit(); if (result.error) { // Submit failed before any charge happened. if (result.error.code === "frame_field_validation_failed") { alert("Please check the card details."); } else if (result.error.code === "frame_3ds_challenge_cancelled") { // The buyer closed the bank's confirmation step. Nothing was charged. // Abandoned, not failed — let them try again without an error message. } else if (result.error.code === "frame_3ds_challenge_failed") { alert("3DS authentication failed. Try a different card."); } else { console.error(result.error); } // A client-side error is not proof that nothing was charged — a timeout can // land after the charge succeeded. Never mark the order failed on it and // never start a new session blindly; reconcile via the webhook. return; } // On a charge-at-submit session (elements mode) read result.chargeStatus // BEFORE token / charged — see Charge at submit. It is absent on this flow. if (result.token) { // Tokenize-only flow (this quickstart): a vp_pmt_* token — charge it on your // server. On a charge-and-save session the card is ALREADY charged here: // skip the server charge and confirm via the webhook instead. await chargeServer(result.token, yourOrderId); return; } if (result.charged) { // Guest charge-and-save: the embed ALREADY charged on submit, no token. // Do NOT call POST /v1/payment_intents — that double-charges. // Confirm settlement via the webhook before fulfilling. showAwaitingConfirmation(); } }); ``` A `vp_pmt_*` token on the tokenize-only flow is single-use by default and bound to the session that minted it — your server charges it once. On a charge-and-save session a guest submit returns `{ charged: true }` with no token and a with-buyer submit returns a reusable token whose card was already charged. Whichever arm you land in, the client result is a UX signal — confirm settlement via the [webhook](../integration/webhooks.md) before fulfilling. Reusable tokens, consent and reuse rules: [Tokenization](tokenization.md). Pass the token to your server: ```javascript async function chargeServer(token, orderId) { const res = await fetch("/api/charge", { method: "POST", headers: { "Content-Type": "application/json" }, // Send your own order reference too — the server keys the charge's // Idempotency-Key off it (Step 7), so a retry collapses instead of // charging twice. body: JSON.stringify({ payment_method: token, order_id: orderId }), }); const body = await res.json(); if (body.redirect) vora.redirectToChallenge(body.redirect); // 3-D Secure: the bank's page } ``` ## Step 7 — Charge with the token On your server the token becomes the `payment_method` on a payment intent. :::warning Send an `Idempotency-Key` From the **28 October 2026** cut-off it is **required on every charge**, and a keyless charge can be refused with `400` [`idempotency_key_required`](../reference/error-codes.md#idempotency_key_required); some connections already refuse one — and on every connection it is what turns a timed-out retry into a replay instead of a second charge. Key it to the **operation** (`order_123_charge`) and send the same value on every retry. See [Idempotency](../integration/idempotency.md). ::: ```typescript // server/api/charge.ts app.post("/api/charge", async (req, res) => { const { payment_method, order_id } = req.body; const response = await fetch(`${API}/v1/payment_intents`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${SECRET}`, // Names the OPERATION, not the attempt. Reuse this exact value on every // retry of this charge — that is what collapses a retry instead of // charging twice. Never append an attempt counter. "Idempotency-Key": `${order_id}_charge`, }, body: JSON.stringify({ amount: 4999, currency: "USD", payment_method: { id: payment_method }, // object form — { id: "vp_pmt_..." } // Where the bank sends the buyer back after a 3-D Secure challenge. // Without it, a card that needs a challenge is refused. return_url: `https://mystore.com/orders/${order_id}/return`, }), }); const intent = await response.json(); // intent.status === "requires_action": the bank wants 3-D Secure. Send the // buyer to its page; they come back to return_url, and the webhook is the outcome. if (intent.status === "requires_action" && intent.next_action?.type === "redirect_to_url") { return res.json({ id: intent.id, status: intent.status, redirect: intent.next_action.redirect_to_url.url, }); } // requires_action with no next_action: still processing. Do not retry; wait // for the webhook. res.json({ id: intent.id, status: intent.status }); }); ``` `chargeServer` above sends the buyer to `redirect` when it is present. Fulfil from the webhook, not the return: [3D Secure](3ds.md). Server-side lifecycle (capture, refund, void): [`/v1/payment_intents`](../integration/payment-intents.md). ## Test cards In test mode your processor's test environment decides the outcome. Use one of the [sandbox test cards](../reference/test-cards.md#test-cards) (common numbers such as `4242 4242 4242 4242` are declined); with those, an ordinary total approves. Use a [decline-trigger total](../reference/test-cards.md#processor-test-account) to exercise the failure paths. ## Before going live Confirm that every element mounts, that validation fires as the buyer types, and, on your merchant account's test path, that a card needing 3-D Secure sends the buyer to the bank's page and back, and that the order settles from the webhook rather than the return. [3D Secure](3ds.md) walks those paths. ## What's next - [Elements reference](elements.md) — every element, its options and collect shape - [Error handling](errors.md) — `VoraMirrorError` codes and recovery - [`/v1/payment_intents` reference](../integration/payment-intents.md) — server-side payment lifecycle - [Webhooks](../integration/webhooks.md) — async settlement events --- ## Script-tag integration (SRI) Source: https://docs.vonpay.com/embedded-fields/sri # Script-tag integration — auto-update vs pinned (SRI) `vora.js` is the only script you reference: it is code-split internally and loads its own sub-chunks on demand at session-load time. Do not load a payment processor's SDK yourself — the card field is rendered inside `vora.js`'s iframe. The bundle ships from `https://js.vonpay.com/` through two channels: | Channel | URL shape | Updates | Supply-chain check | |---|---|---|---| | **Auto-update** | `/v1/vora.js` | Tracks the latest `v1.x.x` automatically | Continuous integrity monitoring by Von Payments | | **Pinned (SRI)** | `/vX.Y.Z/vora.js` + `integrity="sha384-…"` | Byte-exact: stays on the build you tested | Browser-enforced — refuses to execute if the bytes don't match | ## Auto-update channel (default) ```html ``` New features, bug fixes and security patches reach your checkout as they ship; Von Payments polls the served bytes against the registry continuously (minutes, not per request). `crossorigin="anonymous"` is required once you add `integrity`, so set it now. ## Pinned channel (SRI) ```html ``` `vX.Y.Z` is a placeholder. Copy **both** the current version number **and** its matching `integrity` hash from the live registry at [`https://js.vonpay.com/integrity.json`](https://js.vonpay.com/integrity.json); never transcribe either from this page. You bump both on each release — a stale pin keeps working but misses new features. :::warning What `integrity=` does not cover `vora.js` is a loader. Once it runs it fetches the Elements UI runtime (every session) and one processor adapter chunk from `/{version}/adapters/` with a dynamic `import()` — the full set is `artifacts[]` in the registry — and a dynamic `import()` cannot carry an `integrity` attribute, a browser limitation. Pinning gives you a fixed, verified **entry point** and a fixed version of everything downstream; it is not browser-enforced byte verification of every script involved in a render. ::: ## Where the hashes come from The registry at `https://js.vonpay.com/integrity.json` (one version shown; the `sha384-…` values are placeholders): ```json { "versions": { "vX.Y.Z": { "builtAt": "2026-06-04T08:35:21.677Z", "sha384": "sha384-…", "bytes": 77756, "artifacts": [ { "role": "core", "path": "vora.js", "sha384": "sha384-…", "bytes": 77756 } ] } } } ``` - `sha384` at the top level is the core-bundle hash — the value you pin. `artifacts[]` carries per-file hashes for the core bundle and its internal sub-chunks. - `builtAt` is the publish timestamp; `bytes` lets you sanity-check before pinning (a 0-byte entry is the signal the registry is corrupted). - The registry is append-only — old entries stay published, so pinned snippets in deployed code keep working. ## Automating updates Check the version and hash into source control with the snippet. When you move to a version you have tested, your pipeline fetches `integrity.json`, reads that version's `versions.{v}` entry, rewrites the ` ``` `vora-hosted.js` requires a publishable key (`vp_pk_*`). Secret keys are rejected — see [vora-hosted.js](../sdks/vora-hosted.md) for details. ## Option B: Server-side redirect Your server creates the session, then redirects the buyer: ```typescript // Express.js example app.post("/checkout", async (req, res) => { const session = await vonpay.sessions.create({ amount: req.body.amount, currency: "USD", country: "US", successUrl: `https://mystore.com/order/${req.body.orderId}/confirm`, }); res.redirect(303, session.checkoutUrl); }); ``` ## On the hosted page ### 3D Secure (3DS) 3DS challenges on the hosted page are handled automatically — when the issuer requests a step-up, the buyer is redirected to the bank's challenge page and returned to your `successUrl` after a result, exactly as if no challenge had fired. There is no integrator work to enable or wire 3DS on hosted checkout. If you need programmatic control over the 3DS step (e.g. fraud-check-before-capture), use [Payment Intents](payment-intents.md) directly — those expose `next_action.redirect_to_url` for you to drive. ### Buyer receipt email Von Payments does **not** send a confirmation email to the buyer after a successful hosted-checkout payment. Order-confirmation email is your responsibility — wire it off the `charge.succeeded` webhook (see [Webhooks](webhooks.md)) or off your verified return-URL flow (see [Handle the return](handle-return.md)). ### Auto-redirect after payment On a successful capture, the hosted page automatically redirects the buyer back to your `successUrl` — no buyer click required. The redirect fires after a short 5-second countdown ("Redirecting in N seconds…"); a return link labeled with your merchant name is also shown so the buyer can return immediately. If you didn't provide a `successUrl` (or it points back at the hosted success page), no redirect happens and the buyer simply stays on the confirmation screen. If the payment ends in a `failed` state, the buyer is automatically redirected to a dedicated **Payment Failed** screen rather than the order confirmation. ## Session Expiry If the buyer arrives at the checkout URL after the session TTL elapses (`expiresIn`, default 30 minutes, max 7 days), the request returns an HTTP `410` and they see a generic **Checkout Unavailable** error page. That error screen does **not** auto-redirect — if you provided a `cancelUrl`, a "Return to store" link points the buyer back to your store. ## Cancel If the buyer clicks "back" or closes the page without paying, no redirect happens. If you provided a `cancelUrl`, a "Return to store" link is available on the error / expiry screen. --- ## /integration/webhook-events Source: https://docs.vonpay.com/integration/webhook-events # Webhook Event Reference Every webhook Von Payments delivers shares the same envelope. The `type` field tells you which event it is; the `data` field holds the per-event payload. Subscribe only to the event types on this page: the dashboard picker at `/dashboard/developers/webhooks` can list more than it. New event types and new `data` fields may appear without an SDK version bump — treat an unknown `type` as a no-op (return `200`, log, do not raise) and ignore unknown keys. ## Envelope Every event Von Payments delivers is wrapped in the same envelope: ```json { "id": "vp_evt_live_8x4n2pq7m1", "type": "charge.succeeded", "created": 1728936000, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "...": "per-event payload — see the sections below" } } ``` | Field | Type | Notes | |---|---|---| | `id` | string | Unique per outbound event. Use this for idempotent processing — the same event ID is never delivered twice with different payloads. | | `type` | string | Event type from the catalog below. | | `created` | integer | Unix seconds when Von Payments emitted the event. For replay-window enforcement, compare against the `t=` field inside the `x-vonpay-signature` header (there is no separate timestamp header). | | `livemode` | boolean | `true` for events on a live key; `false` for every event on a test key, real test payments included. It does not tell you an event came from **Send test event**: check `test_event` for that. | | `test_event` | `true`, or absent | Present only on an event from **Send test event** (the dashboard button, or [`POST /v1/webhook_subscriptions/{id}/send_test_event`](../reference/api.md)), and then always `true`. Absent, never `false`, on every real event. **Check it first, before any lookup or write, and return `2xx` without doing anything else:** a test event sent for a session carries that session's real ids, a live session's included, so the rest of the payload can look like a real payment. | | `merchant_id` | string | The merchant the event belongs to (a UUID). On a multi-tenant platform integrator, route on this value. | | `data` | object | Per-event payload. Shape is fixed per `type`; see sections below. | All amounts in minor units (cents for USD, pence for GBP, etc.). All currency codes uppercase ISO-4217. All timestamps as unix seconds. ## Charge events The `charge.*` family fires for the card-charge lifecycle when a session reaches settlement (or a direct `/v1/payment_intents` charge succeeds without a session wrapper). ### `charge.succeeded` A charge completed successfully. Fires after the buyer finishes checkout and the processor confirms the charge. ```json { "id": "vp_evt_live_8x4n2pq7m1", "type": "charge.succeeded", "created": 1728936000, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": "vp_cs_live_kJq7Lp...", "payment_intent_id": "vpi_live_9f2ndx7k...", "transaction_id": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e", "vp_tx_id": "vp_tx_live_9f2nd...", "amount": 1499, "currency": "USD", "card": { "brand": "visa", "last4": "4242" }, "payment_method_id": null } } ``` | `data` field | Type | Description | |---|---|---| | `session_id` | string \| null | The session this charge belongs to. Null for direct-charge flows that do not go through `/v1/sessions`. | | `payment_intent_id` | string \| null | Payment intent (`vpi_*`) this charge settled. Null on hosted-direct flows that never minted an intent. `POST /v1/refunds` takes it as `payment_intent`. | | `vp_tx_id` | string \| null | **The Von transaction id. Reconcile and refund on this.** It is the value the SDK's submit result returns as [`transactionId`](../embedded-fields/elements.md#submitresult-shape), the id your dashboard's transaction list shows, and what `POST /v1/refunds` takes as `transaction`. `null` on a vault-forward charge (its settlement row cannot be named) and whenever the settlement row could not be read at delivery time; the event is still sent. When it is `null`, key on `payment_intent_id` instead. | | `transaction_id` | string \| null | **The card processor's reference for the charge, not a Von id.** On Embedded Fields, hosted checkout and every asynchronously settled payment it is the processor's transaction id (a UUID on one connection, a `pi_…` reference on another); in the sandbox it is `sbx_pi_*`. Every event about one charge carries the same value, so it correlates events with each other. It is **not** the `vp_tx_*` your dashboard shows: never match it against your transaction list or pass it to a refund. Use `vp_tx_id`. | | `amount` | integer | Amount charged, in minor units. | | `currency` | string | ISO-4217 uppercase. | | `card` | object \| null | PCI-safe card presentation — `{ "brand": ..., "last4": ... }`. `brand` is one of `visa`, `mastercard`, `amex`, `discover`, `diners`, `jcb`, `unionpay`, `unknown`; `last4` is 4 digits. `null` until card enrichment is active for the merchant. No PAN, BIN, or fingerprint is ever included. | | `avs_result_code` | string \| null | Normalized [Address Verification (AVS)](../guides/address-verification.md) result — `match`, `partial_postal`, `partial_address`, `no_match`, `unavailable`, or `not_supported`. `null` when no billing address was sent or no result was returned. | | `cvv_result_code` | string \| null | Normalized card security-code result — `match`, `no_match`, `not_provided`, or `unavailable`. | | `payment_method_id` | string \| null | The saved card (`vp_pmt_*`) this payment stored, to charge again with `POST /v1/payment_intents` `payment_method: { id }`. `null` when nothing was saved, for example a guest payment. | ### `charge.failed` A charge attempt failed. Includes a `failure_reason` describing the decline. ```json { "id": "vp_evt_live_3x8m1q4r5s", "type": "charge.failed", "created": 1728936010, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": "vp_cs_live_pK7nLm...", "payment_intent_id": "vpi_live_8d4nex2p...", "transaction_id": "d2e5b7c1-3a9f-4e08-b6c4-1f0a2d3e4b5c", "vp_tx_id": null, "amount": 1499, "currency": "USD", "failure_reason": "Your card was declined.", "failure_code": "card_declined", "decline_code": "card_declined", "action": "generic", "decline_message": "The card was declined. Please use a different payment method.", "network_decline_code": "05", "rule_code": null, "retry": { "allowed": true, "attempts_remaining": 2 }, "card": { "brand": "visa", "last4": "4242" } } } ``` `failure_code` is the normalized decline code (`card_declined`, `insufficient_funds`, `expired_card`, `fraudulent`, `processing_error`, …) you should branch on; `decline_code` carries the same value under the API response's name, so one handler can read both surfaces by one key. `action` is the retry classification — `soft` / `hard` / `fix_and_retry` / `generic` — from the same published mapping the API response uses. `network_decline_code` is the raw issuer code (`05`, `51`, …); `rule_code` names which of **your own** provider rules refused the charge, and is `null` unless `failure_code` is `blocked_by_rule`. All seven are `string | null`. The five derived from the normalized decline code — `failure_code`, `decline_code`, `action`, `decline_message` and `rule_code` — are populated or `null` **together**, so handle the whole block being absent rather than testing one field. `card` carries the PCI-safe `{ brand, last4 }` (or `null` until card enrichment is active) — same shape as on `charge.succeeded`. `vp_tx_id` is always `null` here: a declined charge writes no settlement row, and the key is present so the `charge.*` payloads share one shape. `transaction_id` is the processor's reference for the attempt, when it reported one. **Show `decline_message` to the buyer; keep `failure_reason` for your logs.** `failure_reason` is written for you, and for `fraudulent`, `stolen_card` and `lost_card` it states the signal outright; `decline_message` gives those the ordinary decline copy. See [Showing a decline to the buyer](../reference/error-codes.md#buyer-facing-copy) and [rule declines](../guides/rule-declines.md). #### A failed charge is not always the end of the checkout {#retry-after-decline} **Before you cancel an order, release stock or email the buyer on a failure event, read `retry`.** On accounts with retry on the same session switched on, a declined buyer can try another card on the same checkout session, including after the bank refuses the payment on its 3-D Secure page. A failure event is sent for **each declined attempt**, so a buyer who tries a second card and succeeds produces a `charge.failed` followed by a `charge.succeeded`. `retry` is on `charge.failed`, `payment_intent.failed` and `session.failed`, with the same meaning on all three. It describes the session after this decline was handled, and agrees with `retry` on [`GET /v1/sessions/{id}`](../reference/session-object.md): | `retry` | What it means | What to do with the order | |---|---|---| | `{ "allowed": true, "attempts_remaining": n }` | The session is open again and the buyer can pay with another card. | Keep it open. If the buyer pays, `charge.succeeded` follows. If they leave, nothing more is sent: close the session (below) before you cancel. | | `{ "allowed": false, "attempts_remaining": n }`, `n` above 0 | Open again; the form for the next card is still being prepared (about a second). | Keep it open, as above. | | `{ "allowed": false, "attempts_remaining": 0 }` | No more cards can be tried on this session. | Close the session (below) before you cancel. | | `null` | This field does not decide it: retry on the same session is not switched on for your account, the session is not `elements` + `chargeAtSubmit`, the decline handed the session back without using up an attempt (it can still be paid), the event is about an earlier attempt, or the session has since expired (a bank check that timed out: the session reads `expiredFrom: "failed"`, which is final). | Not final by itself. Read [`GET /v1/sessions/{id}`](../reference/session-object.md) before you cancel; a session can still take a payment until it succeeds or expires ([session rules](../reference/session-object.md#rules)). | **Close the session before you cancel the order.** Call [`POST /v1/sessions/{id}/expire`](../reference/api.md#expire-session). A `200` means no payment is in progress on it and none can start, so cancelling is safe. A `409` means not yet: with `reason: "recent_activity"`, call again after `retryAfterSeconds`; with `payment_may_be_in_flight`, do not cancel, and wait for the payment webhook. The same event can arrive more than once with the same `id`, so deduplicate on `id` as usual. ### `charge.refunded` A charge was refunded — full or partial. Fires once per refund record. A fully-refunded charge that was issued in two partial refunds fires this event twice. ```json { "id": "vp_evt_live_7t6r5w4v3u", "type": "charge.refunded", "created": 1729022400, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": "vp_cs_live_kJq7Lp...", "payment_intent_id": "vpi_live_9f2ndx7k...", "transaction_id": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e", "vp_tx_id": "vp_tx_live_9f2nd...", "refund_id": "vpr_live_3kQpL2nM...", "amount": 500, "currency": "USD", "reason": "customer_request", "is_partial": true, "original_charge_amount": 1499, "card": { "brand": "visa", "last4": "4242" } } } ``` | `data` field | Type | Description | |---|---|---| | `payment_intent_id` | string \| null | Payment intent (`vpi_*`) of the original charge. | | `vp_tx_id` | string \| null | The Von transaction id of the refunded charge, the same value its `charge.succeeded` carried. Match this refund to the charge on it. `transaction_id` is the processor's reference, as on every event. | | `refund_id` | string \| null | Refund record id (`vpr_*`) — identifies WHICH refund in a multi-partial sequence this event references. | | `amount` | integer | ⚠️ **Meaning varies by processor connection — do not sum it.** On some connections it is *this* refund; on others it is the *running total* refunded so far. They agree only on the first refund. Kept for backward compatibility; use `refund_amount` / `amount_refunded_total` below. | | `refund_amount` | integer \| null | **This refund only**, unambiguously, on every connection. `null` means the connection could not derive it — which is different from `0`. | | `amount_refunded_total` | integer \| null | **Cumulative refunded so far** for the charge, including this refund, on every connection. Failed refunds are never counted; a refund still processing on the same charge is. Compare this against `original_charge_amount` to decide "fully refunded". `null` only in the rare case our records could not be read when the event was built: treat it as unknown, never as zero, and look the payment up through the API rather than rejecting the event. | | `reason` | string \| null | Refund reason — `customer_request` / `duplicate` / `fraudulent` / merchant free-form. | | `is_partial` | boolean \| null | Whether this refund was for less than the full charge. ⚠ **Do not use it to decide whether a charge is now fully refunded** — see the note below. | | `card` | object \| null | PCI-safe `{ brand, last4 }` of the refunded card (or `null` until card enrichment is active) — same shape as on `charge.succeeded`. | | `original_charge_amount` | integer \| null | Full charge total before any refund — lets you compute the remaining refundable balance without a round-trip. | :::warning Work out "fully refunded" from `amount_refunded_total`, never from `is_partial` or by summing `amount` `is_partial` describes this one refund and is derived differently across providers — a second refund that completes the total can still report `is_partial: true`. `amount` is *this* refund on some connections and the running total on others, so summing it over-counts: a `100` charge refunded `60` then `30` can sum to `150`. Compare **`amount_refunded_total`** on the latest event against `original_charge_amount`; if you need a delta, use `refund_amount`. ::: ### `refund.failed` A refund you issued **did not complete**. The buyer has not been paid back, and the refund needs attention. **Switched on for you:** like the dispute events, `refund.failed` is delivered to **every active subscription** whether or not you selected it. Listing it in `enabledEvents` is still accepted and changes nothing. Handle it (a `2xx` is enough) rather than rejecting an event type you did not subscribe to. A processor may report that outcome under more than one name. A refund that *failed* and a refund that was *declined* are both terminal, both mean the same thing to you (no money went back), and both arrive here. **There is no separate declined event to subscribe to.** This is the failure twin of `charge.refunded` and deliberately mirrors its shape (including `vp_tx_id`, the Von transaction id of the charge), so you can reuse the same row mapping. It adds two fields: `reason_code` and `retry_available`. `amount` here is the reversal amount that **failed to move** — no money moved on this event. ```json { "id": "vp_evt_live_2k9m4x7p1q", "type": "refund.failed", "created": 1729022400, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": "vp_cs_live_kJq7Lp...", "payment_intent_id": "vpi_live_9f2ndx7k...", "transaction_id": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e", "vp_tx_id": "vp_tx_live_9f2nd...", "refund_id": "vpr_live_3kQpL2nM...", "amount": 500, "currency": "USD", "reason": "requested_by_customer", "reason_code": "refund_declined", "is_partial": true, "original_charge_amount": 1499, "retry_available": true, "card": { "brand": "visa", "last4": "4242" } } } ``` | `data` field | Type | Description | |---|---|---| | `refund_id` | string \| null | Refund record id (`vpr_*`) — identifies **which** refund failed. | | `amount` | integer \| null | The reversal amount that failed to move, in minor units. Nothing was transferred. | | `reason` | string \| null | The refund reason originally recorded, carried through from the refund itself. | | `reason_code` | string \| null | **Why it failed.** Branch on this — see below. | | `retry_available` | boolean \| null | Whether retrying is a supported action for this failure. `false` when the payment was disputed: the buyer already has the money back through the chargeback, so do not retry or refund another way. | | `is_partial` | boolean \| null | Whether this refund was for less than the full charge. ⚠ **Do not use it to decide whether a charge is now fully refunded** — see the note under [`charge.refunded`](#chargerefunded). | | `original_charge_amount` | integer \| null | Full charge total before any refund. | | `card` | object \| null | PCI-safe `{ brand, last4 }` — same shape as on `charge.refunded`. | ### Branch on `reason_code` {#refund-reason-codes} | `reason_code` | What happened | What is safe to do | |---|---|---| | `refund_declined` | We attempted the reversal and the provider refused it outright. | Retrying unchanged will fail the same way. Find out why before retrying. | | `refund_unresolved` | We attempted the reversal and **never got an answer**. The money may or may not have moved. | ⛔ **Do not retry blind** — reissuing a refund that silently succeeded pays the buyer twice. Contact support to establish what happened before you retry. | `refund_declined` is the code the live path emits. An ambiguous refund is reported to you as a synchronous `provider_unavailable` on your `POST /v1/refunds` call, not as this event — treat that response as "state unknown" in your own records and settle it with support. The vocabulary is an open set: handle an unrecognised value as "this refund needs a human" — surface it, do not auto-retry. ## Connected-platform events ### `mirror.order.created` Fires when a mirrored payment has created the corresponding order in your connected store. Selectable in the dashboard picker under **Connected platforms**. | Field | Type | Description | |---|---|---| | `payment_intent_id` | string \| null | The payment this order was mirrored from — use it to correlate with `charge.succeeded`. | | `order_number` | string \| null | The number the shopper and your store staff see: on Shopify the order name (for example `#1001`), elsewhere the platform's order number. A Shopify order created before 28 September 2026, or one recovered by an automatic retry, can carry the store's internal numeric id instead. | | `order_status_url` | string \| null | Where the buyer can view the order in your store. **A private link**: anyone holding it can see that order, including the shopper's name and address, so show it only to that shopper and do not log, store or share it. | A mirror can fail without this event firing, and a failed mirror never reverses the charge — do not treat its absence as "the payment failed". To check one charge, poll the mirror-order endpoint for the flow you used; see [Connected Platforms](../connected-platforms/index.md). ## Payment intent events The `payment_intent.*` family fires for the discrete-lifecycle API (`POST /v1/payment_intents`). If your integration uses the hosted-checkout sessions flow only, you can ignore these. If you call `paymentIntents.create` directly via the SDK, these are the events that confirm terminal state. ### `payment_intent.succeeded` A payment intent reached terminal `succeeded` status. ```json { "id": "vp_evt_live_5p3q2t1u8v", "type": "payment_intent.succeeded", "created": 1728936000, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": null, "payment_intent_id": "vpi_live_abc123x7...", "transaction_id": "a4c8e1f3-7b2d-4d95-8e60-5c1b9a7d2f3e", "amount": 1499, "currency": "USD", "payment_method_id": null } } ``` `session_id` is null for payment intents created outside the hosted-checkout flow. `payment_method_id` is the same saved-card id `charge.succeeded` carries, `null` when nothing was saved. ### `payment_intent.failed` A payment intent reached terminal `failed` status. ```json { "id": "vp_evt_live_9w8x7y6z1a", "type": "payment_intent.failed", "created": 1728936010, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": null, "payment_intent_id": "vpi_live_xyz789k2...", "transaction_id": "e9b1d4a7-2c6f-4a13-9d58-7f3e0c2b1a6d", "amount": 1499, "currency": "USD", "failure_reason": "Your card was declined.", "failure_code": "card_declined", "network_decline_code": "05", "rule_code": null, "retry": null } } ``` It carries the same decline fields as [`charge.failed`](#chargefailed), including [`retry`](#retry-after-decline): it is not final unless `retry` is `{ allowed: false, attempts_remaining: 0 }`, and even then [close the session](#retry-after-decline) before you cancel. ### `payment_intent.authorized` The card was authorised on a manual-capture payment and the money is **held, not taken**. It is the only notification that a hold exists, and it never fires for an automatic-capture payment. ```json { "id": "vp_evt_live_7k6j5h4g3f", "type": "payment_intent.authorized", "created": 1728936005, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": null, "payment_intent_id": "vpi_live_ghi012p4...", "transaction_id": "f2a9c6e1-3b7d-4e58-9a14-6d0c8b5e2f7a", "amount": 1499, "currency": "USD", "capture_required": true } } ``` `capture_required` is always `true`, and `amount` is what **may** be captured, not what has been. There is no `payment_method_id` yet: a saved card is stored at capture and arrives on `payment_intent.succeeded`. [Capture](payment-intents.md#capture-an-authorized-intent) or void the hold before it lapses at the card's bank (roughly 7 days on Mastercard, American Express and Discover, about 5 days on a Visa merchant-initiated payment, and as little as 2 days for a card-present payment). Treat the shortest of those as your deadline unless you know the card brand. It is sent once per payment, when the hold is placed (directly, or after the buyer completes 3-D Secure), for a manual-capture payment made through `POST /v1/payment_intents`, the hosted checkout page on a `captureMethod: "manual"` session, the embedded payment fields in `elements` mode, or a wallet payment on those fields. Some payment connections do not send it yet; `GET /v1/payment_intents/{id}` still shows `status: "authorized"` on those. ### `payment_intent.cancelled` A payment intent was cancelled before reaching a terminal state. Typically fires when an authorized intent is voided before capture. ```json { "id": "vp_evt_live_2b1c4d3e5f", "type": "payment_intent.cancelled", "created": 1728936020, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": null, "payment_intent_id": "vpi_live_def456m9...", "transaction_id": "c3f7a2d9-5e1b-4c84-a7d2-9b0e6f4a3c1d", "amount": 1499, "currency": "USD", "cancellation_reason": "buyer_abandoned" } } ``` ## Session events The `session.*` pair is about a hosted-checkout session only, never a payment made directly through `POST /v1/payment_intents`. Use them when you track the buyer's checkout session rather than the charge underneath it. ### `session.succeeded` The buyer completed the hosted checkout. Delivered once per session, even if the confirmation is retried. ```json { "id": "vp_evt_live_4n3m2l1k9j", "type": "session.succeeded", "created": 1728936030, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": "vp_cs_live_k7x9m2n4p3", "payment_intent_id": null, "transaction_id": "a4c8e1f3-7b2d-4d95-8e60-5c1b9a7d2f3e", "amount": 1499, "currency": "USD" } } ``` It carries the core of `charge.succeeded` without the card details, the saved card or `vp_tx_id`. Fulfil and refund against the matching [`charge.succeeded`](#chargesucceeded), which carries those. `payment_intent_id` is `null` when the session made no payment intent. ### `session.failed` A payment attempt on the session was declined. Sent **once per declined attempt**, so a buyer who tries two cards produces two (and, like every event, it can be redelivered with the same `id`). The fields match [`charge.failed`](#chargefailed) (the decline reason, `decline_code`, `action`, `decline_message`, `network_decline_code`, `rule_code` and [`retry`](#retry-after-decline)) without `vp_tx_id`. A `session.failed` does not mean the checkout failed unless `retry` is `{ allowed: false, attempts_remaining: 0 }`; otherwise the buyer can still pay on it. [Close the session](#retry-after-decline) before you cancel. ## Dispute events ### `dispute.created`, `dispute.won`, `dispute.lost` **Switched on for you:** delivered to **every active subscription** whether or not you selected them — they are not in the dashboard picker, and an endpoint registered for `charge.*` alone still receives them, so handle them like any type you did not ask for. A subscription is still required. `created` fires when the processor notifies us that a cardholder has disputed a payment; `won` and `lost` as the dispute resolves. All three share one payload: | Field | Type | Description | |---|---|---| | `dispute_id` | string \| null | The dispute, as the processor identifies it. | | `transaction_id` | string \| null | The processor's reference for the disputed charge, the same value the `charge.*` events for that charge carried. Not a `vp_tx_*` id; see [`charge.succeeded`](#chargesucceeded). | | `payment_intent_id` | string \| null | The disputed payment (`vpi_*`). `null` when the dispute cannot be matched to exactly one payment. | | `session_id` | string \| null | The checkout session the payment belongs to. `null` when unmatched, or when the payment had no session. | | `buyer_id` | string \| null | Your customer reference for the buyer. Not filled in on every payment connection, so it can be `null` even when a buyer was attached: match on `payment_intent_id`. | | `amount` | integer \| null | The disputed amount, in minor units. | | `currency` | string \| null | ISO 4217. | Match a dispute to your order with `payment_intent_id`, never `buyer_id`. Treat `null` as unmatched and never guess: fall back to `transaction_id`, which every charge event also carries with the same value. There is no `vp_tx_id` on this family. The events are raised from the processor's own dispute notification, so they arrive only on connections that send us one: check [`dispute_reporting`](../reference/capabilities.md#dispute-reporting) before relying on them as your only chargeback detector. ## What is delivered to a subscription {#forward-compatibility} **Every event except the dispute family and `refund.failed` is delivered only if its exact `type` is in your subscription's `enabledEvents`**, filtered per endpoint. The events documented here that you subscribe to are **Charges** (`charge.succeeded`, `charge.failed`), **Refunds** (`charge.refunded`; `refund.failed` arrives whether or not you list it), **Payment Intents** (`payment_intent.succeeded`, `payment_intent.failed`, `payment_intent.cancelled`, `payment_intent.authorized`), **Sessions** (`session.succeeded`, `session.failed`) and **Order mirroring** (`mirror.order.created`). To match them to one order, store every id a payment's first event carries (`session_id`, `payment_intent_id`, `vp_tx_id`) and match later events on any of them: which are null depends on how the payment was taken and on the event, and a refund made through the API carries no `session_id`. **Event names come from this catalog.** A create or update whose list contains **any** name not in it, such as `session.expired` (there is no such event) or a misspelling like `charge.suceeded`, is refused with [`400 validation_error`](../reference/error-codes.md#validation_error) ("verify the URL and that every event type is a recognized event"), even when the other names are valid. Nothing is stored and no signing secret is issued. The message does not name the bad entry, so check each name against this page. A name that fails the format (lowercase, dotted) is refused with `400` before the catalog is consulted. ## Related - [Webhooks](webhooks.md) — overview: how the surface works, registering endpoints, retry behavior - [Webhook Verification](webhook-verification.md) — HMAC-SHA256 verification across languages - [Webhook Signing Secrets](webhook-secrets.md) — create, view-once, rotate --- ## /integration/webhook-retries Source: https://docs.vonpay.com/integration/webhook-retries # Webhook Retries When a webhook delivery fails, Von Payments retries it on a fixed schedule. ## Retry schedule — 8 attempts over ~79 hours | Attempt | Delay before this attempt | Cumulative time (approx.) | |---|---|---| | 1 | immediate | 0s | | 2 | 30s | 30s | | 3 | 2 min | ~2.5 min | | 4 | 10 min | ~12 min | | 5 | 1 hour | ~1h 12 min | | 6 | 6 hours | ~7h 12 min | | 7 | 24 hours | ~31h | | 8 | 48 hours | ~79h | | (none) | dead | — | After attempt 8 fails, the delivery is marked dead. The original event row stays in the dashboard's delivery history with `status: dead` and a final response code; no further attempts are made. ### Full-jitter delays The "delay before this attempt" column is the **base** delay; the actual delay is uniform in `[0, base]` (full jitter), so the cumulative column is an upper bound. ## How response codes drive retry | Outcome | What it means | What happens next | |---|---|---| | `200`–`299` | Delivered | Terminal. No further attempts. | | `410 Gone` | Endpoint declared the URL permanently dead | Subscription is **disabled**; row marked dead. No new events are enqueued for it. | | `404`, `408`, `425`, `429` | The route is missing (for example mid-deploy), the endpoint timed out, or it is shedding load | **Retry** with backoff per the schedule above. A `Retry-After` header can move the next attempt later (up to 1 hour), never earlier. | | Other `4xx` (`400`, `401`, `403`, `409`, `422`, …) | Endpoint rejected the request | Row marked **dead immediately**. No retry: the same request gets the same answer. | | `5xx` (`500`, `502`, `503`, `504`) | Endpoint had a transient error | **Retry** with backoff per the schedule above. | | Network error / timeout (no response) | Connection refused, DNS failed, or your handler took longer than 10 seconds | **Retry** with backoff per the schedule above. | The 10-second timeout is hard: persist the event id, return `200`, and do the work in a background job. **Any other 4xx is dead on the first attempt**: a rejected signature or a body your handler refuses will not change between attempts. A `401` you return during a signing-secret switch-over is lost the same way; see [Rotating a signing secret](./webhook-secrets.md#3-rotate). ## Per-endpoint circuit breaker Layered on top of the retry schedule is a **per-endpoint circuit breaker** that pauses delivery to an endpoint repeatedly returning 5xx. ### State machine ``` closed ──5 failures within a 60s window──▶ open ▲ │ │ │ cooldown elapsed │ success ▼ └──────────────────────────── half-open ──5xx──▶ open (longer cooldown) ``` - **Closed** — normal delivery. Every attempt goes through. - **Open** — delivery to this endpoint is paused. No HTTP attempts are made; rows wait. After a cooldown, the breaker moves to half-open. - **Half-open** — one probe attempt goes through. If it succeeds, the breaker closes. If it fails, the breaker re-opens with a longer cooldown. ### Cooldown progression The cooldown doubles on each re-open until it caps at 5 minutes: | Re-open count | Cooldown | |---|---| | 1 (first open) | 30s | | 2 | 60s | | 3 | 120s (2 min) | | 4 | 240s (4 min) | | 5+ | 300s (5 min) | A streak of successful deliveries resets the counter; the next open starts over at 30s. ### What this looks like in the delivery log The circuit breaker is **per-subscription**, not global. A breaker opening on Subscription A has no effect on Subscription B, even on the same merchant. ## Automatic pause after repeated failures {#auto-pause} Separate from the circuit breaker, a subscription is **paused automatically after 10 failed delivery attempts in a row**, counted across all its events. Retries of one event count: one event's 8 attempts can carry the count most of the way, and a failure on the next event finishes it. A successful delivery before the tenth resets the count. A paused subscription **does not resume by itself**. Nothing is sent to it while it is paused, so no delivery can succeed and clear the pause. It clears only when you set the subscription back to active: click **Resume** on the webhooks page of your dashboard, or send `PATCH /v1/webhook_subscriptions/{id}` with `{"status": "active"}`. Fix the endpoint first, or the next 10 failures pause it again. **Its `status` still reads `active` while it is paused**, and the API does not report the pause. The dashboard does: the endpoint is marked **Auto-paused**. If your endpoint stops receiving events, check the webhooks page. Events raised during the pause are held, not dropped. Resume within an event's retry window (about 79 hours from when it was raised) and it is delivered; after that it is dead-lettered like any other undeliverable event. Every delivery, including a held one, goes to the endpoint address the subscription has **when it is sent**, so you can change the address while paused and resume. ## Dead-letter queue When a delivery hits a terminal state — all 8 attempts exhausted, a `4xx` that is not retried marked it dead, or a `410 Gone` disabled the subscription — the row moves to the dead-letter queue (DLQ). DLQ rows are kept for at least **30 days**, with the final response code, the error-message excerpt (your response body or the network error) and the full request payload. :::note Your endpoint's response body is stored The error-message excerpt is the response body your endpoint returned on the failing attempt, retained for 30 days and visible to anyone with dashboard access to your account. Keep error responses short and free of PII. ::: ## Where to see delivery state `/dashboard/developers/events` lists every event with its delivery attempts — each attempt's timestamp, response code, latency and response-body excerpt — and a **Resend** button for the whole event or one subscription. Dead deliveries are resent from there once you have fixed the handler; the endpoint card at `/dashboard/developers/webhooks` can bulk-resend an endpoint's dead deliveries. A test event sent from the endpoint card or the CLI ([Test your handler](./webhooks.md#test-your-handler)) is a single signed POST — it is not retried and never enters the DLQ. ## Two invariants to code against - **The same `id` can arrive more than once** — a previous attempt delivered but you answered 5xx, or a manual DLQ retry. Guard on `id` and return `200` for an id you have already processed. - **Order is not guaranteed.** Of two events emitted close together, the second can arrive first if the first is retrying. Where ordering matters, read state from the API by id rather than from the payload alone. ## Related - [Webhooks](./webhooks.md) — registering endpoints, the envelope, testing a handler - [Webhook Event Reference](./webhook-events.md) — the event catalog - [Webhook Signature Verification](./webhook-verification.md) — the algorithm and reference verifiers - [Webhook Signing Secrets](./webhook-secrets.md) — create, view-once, rotate --- ## /integration/webhook-secrets Source: https://docs.vonpay.com/integration/webhook-secrets # Webhook Signing Secrets Each endpoint you register — in the dashboard at `/dashboard/developers/webhooks` or with `POST /v1/webhook_subscriptions` (secret-key auth, [REST API reference](../sdks/rest-api.md#webhook-subscriptions)) — gets its own `whsec_*` signing secret. Every delivery to that endpoint is signed with it (`x-vonpay-signature: t=,v1=`, HMAC-SHA256 of `${t}.${rawBody}` keyed by the raw secret string, prefix included); your merchant API key is never used. The verifier is on [Webhook Signature Verification](webhook-verification.md). ## Lifecycle ### 1. Create **Dashboard** — **Add endpoint** → URL + event types → **Create**. **API** — `POST /v1/webhook_subscriptions`: ```json { "url": "https://your-app.example.com/webhooks/vonpay", "enabledEvents": ["charge.succeeded", "charge.refunded", "payment_intent.succeeded"], "description": "Production order-fulfillment hook" } ``` Response (`201`): ```json { "id": "b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13", "object": "webhook_subscription", "url": "https://your-app.example.com/webhooks/vonpay", "enabledEvents": ["charge.succeeded", "charge.refunded", "payment_intent.succeeded"], "status": "active", "signingSecret": "whsec_NbR4mP9k2qL7vX1tWj3...", "apiVersion": "2026-05-04", "createdAt": "2026-05-04T18:30:00Z" } ``` See the [REST API reference](../sdks/rest-api.md#webhook-subscriptions) for the full field list and the other endpoints. ### 2. View-once The full `signingSecret` appears in the **create** and **rotate** responses only. Store it server-side immediately: the dashboard shows only a truncated prefix afterwards, `GET /v1/webhook_subscriptions/{id}` never returns it, and there is no "show me the secret again" path. If you have lost it, rotate. ### 3. Rotate Rotating mints the new secret — you cannot load it into your handler beforehand — and within moments of the call every delivery is signed with only the new one, a single `v1=` entry. There is **no** dual-signature grace window on our side, and **a delivery you answer with `400`, `401` or `403` is not retried automatically**: a handler still holding only the old secret rejects every event delivered between the rotation and its redeploy, and nothing alerts you; those deliveries can be resent from the dashboard afterwards, but only if you notice. So during your switch-over your handler must accept both secrets — call the verifier once per secret and accept if either passes — and drop the old one as soon as the new one verifies: past that point the only party who can still produce a signature it accepts is someone else holding that secret. **Dashboard** — `/dashboard/developers/webhooks` → the endpoint's **Rotate signing secret** action. **API**: ```http POST /v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13/rotate_signing_secret Authorization: Bearer vp_sk_live_… ``` The response has the same shape as **Create**, with the new `signingSecret`. Have your deploy ready before you make the call. **Deploy sequence:** 0. **First**, ship a handler that reads a second secret from a separate variable and, when it is set, runs the verifier against each secret in turn. Nothing has changed yet — the second variable is empty. 1. Rotate (dashboard or `POST /rotate`) and capture the new secret. 2. Put the new secret in that second variable and redeploy. 3. Confirm at least one inbound webhook verifies under the new secret. 4. **Remove the old secret** and redeploy again. Steps 1 → 2 are the window; keep it as short as your deploy takes. If the old secret **leaked**, do not accept it even briefly — use the [compromise path](#compromise-path), which closes the gap with no window at all. ### 4. Revoke Revoking disables the endpoint permanently: its record is retained for audit, its secret can never be reissued, and it cannot be re-activated — create a fresh endpoint to resume delivery. Use it when decommissioning a handler, consolidating endpoints, or retiring the original after a compromise response. **Dashboard** — `/dashboard/developers/webhooks` → the endpoint's **Delete endpoint** action. **API**: ```http DELETE /v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13 Authorization: Bearer vp_sk_live_… ``` Returns `204 No Content`. Nothing new is enqueued for a deleted endpoint. To stop deliveries temporarily instead, `PATCH` the subscription to `status: "paused"` (`active` and `paused` are the only caller-settable statuses), or use the endpoint's **Pause endpoint** action in the dashboard. ### Compromise path If a `whsec_*` is exposed (committed to a public repo, leaked in a log, captured in a bug report), stand up a fresh endpoint rather than rotating in place — it gives you a clean audit boundary between pre- and post-compromise events, which one URL cannot: 1. **Create a new endpoint** at a new URL (a path suffix or a fresh subdomain). Every event at the new URL is post-compromise; every event at the old URL is untrusted. 2. **Point your configuration** at the new endpoint. 3. **Audit events received at the old endpoint** between the leak and the new endpoint going live: reconcile each against the API (`GET /v1/sessions/{sessionId}` for hosted sessions, `GET /v1/payment_intents/{id}` for payment intents) before acting on any business-state change. 4. **Revoke the old endpoint** once the new path is healthy. 5. **Rotate any credential adjacent in the leak** — API keys, database passwords, webhook URLs carrying endpoint ids. ## Related - [Webhook Event Reference](webhook-events.md) - [Webhook Signature Verification](webhook-verification.md) - [API Key Types](../reference/api-keys.md) --- ## /integration/webhook-verification Source: https://docs.vonpay.com/integration/webhook-verification # Webhook Signature Verification Every webhook Von Payments delivers is signed with HMAC-SHA256, keyed with the per-endpoint `whsec_*` secret — returned once by the dashboard or by `POST /v1/webhook_subscriptions` ([creating, rotating and revoking one](webhook-secrets.md)). Verify the signature before you process anything. ## Header format (v1) ``` x-vonpay-signature: t=1714406400,v1=abc123def456... ``` - `t` — unix timestamp (seconds, integer) of when the signature was generated - `v1` — lowercase hex-encoded HMAC-SHA256 over the signed payload **There is exactly one `v1=` entry, and no rotation grace window on our side.** Rotating mints a new secret; within moments every delivery is signed with only that one, and a delivery you reject with `400`, `401` or `403` is **not retried**. During your switch-over run the verifier once per secret (each takes one) and accept if either passes, then drop the old one as soon as the new one verifies — sequence on [Rotating a signing secret](webhook-secrets.md#3-rotate). Outside a rotation, compare against your current secret only. ## Signed payload The HMAC input is the concatenation of the timestamp, a literal period, and the **raw** request body: ``` signed_payload = t + "." + raw_body ``` - `t` — the exact same value from the header - `raw_body` — the HTTP request body as received, byte-for-byte, before any parsing or whitespace normalization **HMAC the raw bytes, not the parsed JSON.** If you re-serialize the JSON object before HMAC'ing, the signature will not match — JSON serializers normalize whitespace and key order differently across languages. ## The algorithm ``` v1 = lowercase_hex(HMAC_SHA256(key=signing_secret, message=signed_payload)) ``` - **Algorithm:** HMAC-SHA256 - **Key:** the raw signing secret string as **UTF-8 bytes**, including the `whsec_` prefix. Do not base64-decode and do not strip the prefix — pass the secret verbatim to your HMAC library's key parameter. - **Encoding:** lowercase hex ## Verification steps A conforming verifier must: 1. **Parse the header.** Extract `t` and every `v1=…` value. One is sent today; the format allows up to two, and more than two is malformed — reject with 401. 2. **Reject stale timestamps.** The replay window is **asymmetric** — reject if `now - t > 300` (more than 5 minutes old) OR `t - now > 30` (more than 30 seconds in the future). A future timestamp should never happen under normal flow; 30 seconds only covers minor receiver-clock skew. 3. **Recompute the HMAC.** Form `signed_payload = t + "." + raw_body`. Compute `expected = lowercase_hex(HMAC_SHA256(signing_secret, signed_payload))`. 4. **Constant-time compare, with no length-based early exit.** For each `v1` from the header, constant-time compare against `expected`; a length mismatch goes through the same path (wrap the timing-safe compare in try/catch and treat a throw as no-match). Accept if any compare returns true, otherwise reject with 401. Never `==` / `===`; use a constant-time helper: - Node: `crypto.timingSafeEqual` (requires equal-length buffers — wrap in try/catch) - Python: `hmac.compare_digest` (constant-time regardless of length) - Go: `subtle.ConstantTimeCompare` - Ruby: `Rack::Utils.secure_compare` ## Replay window (asymmetric) Past **5 minutes**, future **30 seconds**. Every retry is re-signed at delivery time, so a legitimate delivery is always fresh; the future allowance covers receiver clock skew only. ## Idempotency Events carry an `id` on the envelope (e.g. `vp_evt_live_V1StGXR8Z5jdHi6B`). The same `id` is redelivered after your 5xx or a manual resend — and a retry after a rotation is re-signed with the new secret under the same `id` — so guard on `id`, not on the signature, and return 200 for an id you have already processed. ## Code examples ### Node ```javascript const crypto = require('crypto'); function verifyVonPaySignature(rawBody, headerValue, secret) { if (!headerValue) return false; const parts = headerValue.split(',').map((p) => p.trim()); const tPart = parts.find((p) => p.startsWith('t=')); if (!tPart) return false; const t = parseInt(tPart.slice(2), 10); if (!Number.isFinite(t)) return false; const now = Math.floor(Date.now() / 1000); if (now - t > 300) return false; // > 5 min old if (t - now > 30) return false; // > 30 sec in future const v1Parts = parts.filter((p) => p.startsWith('v1=')); if (v1Parts.length === 0 || v1Parts.length > 2) return false; const signed = `${t}.${rawBody}`; const expected = crypto.createHmac('sha256', secret).update(signed).digest('hex'); const expectedBuf = Buffer.from(expected, 'utf8'); for (const part of v1Parts) { const candidateBuf = Buffer.from(part.slice(3), 'utf8'); try { // timingSafeEqual requires equal lengths. A length mismatch throws and is // treated as no-match. No length short-circuit — all comparisons go // through a constant-time path. if (crypto.timingSafeEqual(candidateBuf, expectedBuf)) return true; } catch { // length mismatch — continue to next v1 } } return false; } ``` ### Python ```python import hashlib import hmac import time def verify_vonpay_signature(raw_body: bytes, header_value: str, secret: str) -> bool: if not header_value: return False parts = [p.strip() for p in header_value.split(",")] t_part = next((p for p in parts if p.startswith("t=")), None) if not t_part: return False try: t = int(t_part[2:]) except ValueError: return False now = int(time.time()) if now - t > 300: # > 5 min old return False if t - now > 30: # > 30 sec in future return False v1_parts = [p for p in parts if p.startswith("v1=")] if not v1_parts or len(v1_parts) > 2: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() for part in v1_parts: # hmac.compare_digest is constant-time regardless of length if hmac.compare_digest(part[3:], expected): return True return False ``` ### Ruby ```ruby require "openssl" require "rack/utils" def verify_vonpay_signature(raw_body, header_value, secret) return false if header_value.nil? || header_value.empty? parts = header_value.split(",").map(&:strip) t_part = parts.find { |p| p.start_with?("t=") } return false unless t_part t = Integer(t_part[2..]) rescue (return false) now = Time.now.to_i return false if now - t > 300 # > 5 min old return false if t - now > 30 # > 30 sec in future v1_parts = parts.select { |p| p.start_with?("v1=") } return false if v1_parts.empty? || v1_parts.size > 2 signed = "#{t}.#{raw_body}" expected = OpenSSL::HMAC.hexdigest("SHA256", secret, signed) v1_parts.each do |part| return true if Rack::Utils.secure_compare(expected, part[3..]) end false end ``` ### PHP ```php function verify_vonpay_signature(string $raw_body, string $header_value, string $secret): bool { if ($header_value === "") return false; $parts = array_map("trim", explode(",", $header_value)); $t_part = null; foreach ($parts as $p) { if (str_starts_with($p, "t=")) { $t_part = $p; break; } } if ($t_part === null) return false; if (!ctype_digit(substr($t_part, 2))) return false; $t = (int)substr($t_part, 2); $now = time(); if ($now - $t > 300) return false; // > 5 min old if ($t - $now > 30) return false; // > 30 sec in future $v1_parts = array_values(array_filter($parts, fn($p) => str_starts_with($p, "v1="))); if (count($v1_parts) === 0 || count($v1_parts) > 2) return false; $signed = $t . "." . $raw_body; $expected = hash_hmac("sha256", $signed, $secret); foreach ($v1_parts as $p) { if (hash_equals($expected, substr($p, 3))) return true; } return false; } ``` ### Go ```go import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" ) func VerifyVonPaySignature(rawBody []byte, headerValue, secret string) bool { if headerValue == "" { return false } parts := strings.Split(headerValue, ",") for i, p := range parts { parts[i] = strings.TrimSpace(p) } var t int64 var tFound bool for _, p := range parts { if strings.HasPrefix(p, "t=") { v, err := strconv.ParseInt(p[2:], 10, 64) if err != nil { return false } t = v tFound = true break } } if !tFound { return false } now := time.Now().Unix() if now-t > 300 { return false } if t-now > 30 { return false } var v1Parts []string for _, p := range parts { if strings.HasPrefix(p, "v1=") { v1Parts = append(v1Parts, p[3:]) } } if len(v1Parts) == 0 || len(v1Parts) > 2 { return false } signed := strconv.FormatInt(t, 10) + "." + string(rawBody) mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(signed)) expected := hex.EncodeToString(mac.Sum(nil)) for _, v := range v1Parts { if hmac.Equal([]byte(expected), []byte(v)) { return true } } return false } ``` ### Shell (curl / openssl) — sanity check only For ad-hoc verification — confirming a captured payload against its signature from a terminal. **Not for a production handler**: shell `=` is variable-time and the replay window is a manual `date` compare. Use one of the verifiers above for the real receiver. Given three captured values — the raw request body, the full `x-vonpay-signature` header, and your `whsec_*` secret — verify like this: ```bash RAW_BODY='{"id":"vp_evt_live_V1StGXR8Z5jdHi6B","type":"charge.succeeded","created":1728936000,"livemode":true,"merchant_id":"b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce","data":{"amount":1499}}' HEADER='t=1728936000,v1=abc123def456...' SECRET='whsec_REPLACE_WITH_YOUR_ENDPOINT_SECRET' T=$(printf '%s' "$HEADER" | tr ',' '\n' | grep '^t=' | head -1 | cut -d= -f2) V1=$(printf '%s' "$HEADER" | tr ',' '\n' | grep '^v1=' | head -1 | cut -d= -f2) [ -z "$T" ] && { echo "could not parse t= from header"; exit 1; } [ -z "$V1" ] && { echo "could not parse v1= from header"; exit 1; } SIGNED="${T}.${RAW_BODY}" EXPECTED=$(printf '%s' "$SIGNED" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $NF}') [ "$V1" = "$EXPECTED" ] && echo "signature OK" || echo "signature MISMATCH" # Optional: enforce the replay window (T was validated above) NOW=$(date +%s) if [ "$((NOW - T))" -gt 300 ] || [ "$((T - NOW))" -gt 30 ]; then echo "timestamp outside replay window" fi ``` Notes: - `printf '%s'` (not `echo`) — `echo` adds a trailing newline that breaks the HMAC input. - `awk '{print $NF}'` takes the last whitespace-delimited field, which is always the hex digest regardless of how openssl formats the prefix label (`(stdin)= `, `HMAC-SHA2-256(stdin)= `, or just `= ` on older LibreSSL). - The body must be the **raw** bytes as received, byte-for-byte. If you captured the payload via `jq` or any tool that re-serializes, the HMAC will not match. - **Bodies containing a single quote** break the `RAW_BODY='...'` assignment. Use a here-doc instead — `RAW_BODY=$(cat <<'ENDBODY'` … `ENDBODY)` — or write the body to a temp file and `printf '%s' "$T." > tmp.signed && cat tmp.body >> tmp.signed && openssl dgst -sha256 -hmac "$SECRET" < tmp.signed | awk '{print $NF}'`. - The secret sits in process args and shell history for the duration of the command — run it where other local users cannot observe `ps`, and keep the `SECRET=` line out of history. To capture a real event for replay-verification: open `/dashboard/developers/events`, open the event, and copy the payload and the attempt's response details shown there. ## Rejection response codes | Condition | Response | |---|---| | Header missing | 401 | | Header malformed (no `t=`, no `v1=`, non-integer `t`) | 401 | | More than 2 `v1=` entries | 401 (malformed) | | `now - t > 300` (stale) OR `t - now > 30` (future-skew) | 401 | | No `v1` HMAC matches | 401 | | Duplicate `id` already processed | 200 (idempotent no-op) | ## Common mistakes | Mistake | Fix | |---|---| | Using `==` to compare signatures | Use a constant-time compare | | Length-based early return before the compare | Always go through the constant-time path (wrap in try/catch) | | HMAC'ing the **parsed** JSON object | HMAC the **raw** request body bytes | | Accepting stale `t` | Enforce asymmetric window (past 5 min, future 30 sec) | | Accepting >2 `v1=` entries | Reject as malformed | | Base64-decoding the `whsec_` secret | Use the raw string as UTF-8 bytes | ## Porting a verifier from another provider The header shape (`t=…,v1=…`, HMAC-SHA256 over `t.payload`) resembles other widely-used schemes, with four differences that bite a verifier copied verbatim: - **Replay window:** we reject past > 5 min **and** future > 30 sec. Many schemes reject past only, with no future tolerance. - **Multi-`v1=` cap:** we reject headers with more than 2 `v1=` entries. - **Header name:** `x-vonpay-signature` (lowercase, hyphenated). - **Key encoding:** use the raw `whsec_…` string as UTF-8 bytes. ## Related - [Webhooks](webhooks.md) — overview: registering endpoints, retry behavior, code examples - [Webhook Event Reference](webhook-events.md) — event catalog and payload schemas - [Webhook Signing Secrets](webhook-secrets.md) — creating, rotating, and revoking endpoint secrets --- ## /integration/webhooks Source: https://docs.vonpay.com/integration/webhooks # Webhooks Von Payments delivers signed `POST` requests for payment events to endpoints you register at `/dashboard/developers/webhooks` — your URL plus at least one event type it should receive. Each endpoint gets its own `whsec_*` signing secret, shown once at create time. Subscribe only to the event types on [Webhook Events](./webhook-events.md), which also has their payloads; the dashboard picker can list more than that page. ## Envelope Every event ships in the same envelope; `data` is per event type. ```json { "id": "vp_evt_live_8x4n2pq7m1", "type": "charge.succeeded", "created": 1728936000, "livemode": true, "merchant_id": "b6b8d25f-80d5-4b31-8ac6-fd3c5727c4ce", "data": { "session_id": "vp_cs_live_kJq7Lp...", "transaction_id": "7c1f4e2a-9b3d-4f60-8e5a-2d1c0b9a8f7e", "vp_tx_id": "vp_tx_live_9f2nd...", "amount": 1499, "currency": "USD" } } ``` The `id` is unique per outbound event — deduplicate on it. The same `id` is never delivered with a different payload, but it is redelivered after a 5xx from your handler or a manual resend from the dashboard. ## Headers | Header | Description | |---|---| | `x-vonpay-signature` | `t=,v1=` — see [Webhook Verification](./webhook-verification.md). The signing timestamp is the `t=` field inside this header; there is no separate timestamp header. Exactly one `v1=` entry is sent. | | `Content-Type` | `application/json` | | `User-Agent` | `Von-Pay-Webhooks/1.0` | ## Signature verification Signatures are HMAC-SHA256 over `t.`, keyed with the endpoint's `whsec_*` secret. Reference verifiers in Node, Python, Go, Ruby and PHP are on [Webhook Verification](./webhook-verification.md). The rules that break most ports: - **HMAC the raw request body bytes**, not the parsed JSON — re-serialization changes whitespace and key order. - **Use a constant-time compare** (`crypto.timingSafeEqual`, `hmac.compare_digest`, …). - **Enforce the replay window**: reject if `now - t > 300` or `t - now > 30`. - The `whsec_*` secret is the raw UTF-8 string — do not base64-decode it, do not strip the prefix. ## Test your handler {#test-your-handler} Send a fully-signed synthetic event to your endpoint — including `localhost`, no tunnel needed — with the CLI: ```bash npm install -g @vonpay/checkout-cli vonpay checkout login vonpay checkout trigger payment_intent.succeeded --url http://localhost:3000/webhooks/vonpay ``` The CLI signs with the same algorithm and header format as live delivery but keyed with **your API key**, not a `whsec_*` secret — point your verifier at the API key for CLI tests. It can fire every `charge.*` and `payment_intent.*` event, `refund.failed`, `mirror.order.created`, and `session.succeeded` / `session.failed`; the synthetic ids are prefixed `vp_evt_test_…` / `vp_tx_test_…` / `vp_cs_test_…`, and the `User-Agent` is `VonPay-Webhook/1.0 (CLI trigger)`. A test event is a single signed POST — it is not retried. See the [CLI reference](../sdks/cli.md#vonpay-checkout-trigger). To test a registered endpoint with its real `whsec_*` secret, send a test event through the real delivery path from your server. The outcome comes back in the same call: ```typescript const outcome = await vonpay.webhookSubscriptions.sendTestEvent(subscriptionId, { eventType: "charge.succeeded", }); // outcome.delivered is true when your endpoint answered 2xx ``` In Python it is `client.webhook_subscriptions.send_test_event(subscription_id, event_type="charge.succeeded")`; both need SDK 3.6.0 or later and a secret key. The event carries `test_event: true`, and when you pass a session id it carries that session's real ids, so your handler must check `test_event` before touching an order. See [`sendTestEvent`](../sdks/node-sdk.md#vonpaywebhooksubscriptionssendtesteventid-params). ## Code examples ### Node.js (Express) The SDK's `constructEvent` verifies the signature, enforces the replay window, and returns a typed event in one call. ```typescript const vonpay = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY); const endpointSecret = process.env.VON_PAY_WEBHOOK_SECRET; // whsec_* // Scope express.raw() to this route, and register it BEFORE any app-wide // express.json() — a global body parser consumes the body first, and // constructEvent verifies against the RAW bytes, so verification would fail. app.post("/webhooks/vonpay", express.raw({ type: "application/json" }), async (req, res) => { try { const event = vonpay.webhooks.constructEvent( req.body, // raw body (Buffer) req.headers["x-vonpay-signature"] as string, // signature header endpointSecret, // whsec_* — per-endpoint secret ); // A "Send test event" delivery can carry a real session's ids: acknowledge it and stop. if (event.test_event === true) { res.status(200).json({ received: true }); return; } switch (event.type) { case "charge.succeeded": // Store vp_tx_id with the order: it is the id you reconcile and refund // on, and the id a later charge.refunded carries to match the charge. await fulfillOrder(event.data.session_id, event.data.payment_intent_id, event.data.vp_tx_id ?? null); break; case "charge.failed": await handleFailure(event.data.session_id, event.data.failure_reason); break; case "charge.refunded": { // Match the refund to its charge on vp_tx_id, the same value the // charge.succeeded carried; a refund is not keyed on the session. const refund = event.data; // amount_refunded_total is the cumulative figure and means the same // thing on every connection. Do not read `amount` here: on some // connections it is this refund, on others the running total. Null // means the connection could not derive it: review it, never treat // it as zero. if (refund.amount_refunded_total == null) { await flagRefundForReview(refund.vp_tx_id, refund.refund_id); break; } await syncRefundTotal(refund.vp_tx_id, refund.amount_refunded_total); break; } // Unknown event types: return 200, log for inspection, do not raise. // New types may be added without an SDK bump. } res.status(200).json({ received: true }); } catch (err) { res.status(400).json({ error: "Invalid signature" }); } }); ``` ### Python (Flask) ```python import os from flask import Flask, request, jsonify from vonpay.checkout import VonPayCheckout app = Flask(__name__) vonpay = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"]) endpoint_secret = os.environ["VON_PAY_WEBHOOK_SECRET"] # whsec_* @app.route("/webhooks/vonpay", methods=["POST"]) def webhook(): try: event = vonpay.webhooks.construct_event( request.data, # raw body request.headers.get("x-vonpay-signature"), # signature header endpoint_secret, # whsec_* ) # A "Send test event" delivery can carry a real session's ids: acknowledge it and stop. if event.test_event: return jsonify(received=True), 200 if event.type == "charge.succeeded": # Store vp_tx_id with the order: a later charge.refunded matches on it. fulfill_order(event.data["session_id"], event.data["payment_intent_id"], event.data.get("vp_tx_id")) elif event.type == "charge.failed": handle_failure(event.data["session_id"], event.data.get("failure_reason")) elif event.type == "charge.refunded": # Match the refund to its charge on vp_tx_id, the value the # charge.succeeded carried. amount_refunded_total is cumulative and # unambiguous; "amount" is not. Null means the connection could not # derive it: review, never treat as zero. if event.data.get("amount_refunded_total") is None: flag_refund_for_review(event.data.get("vp_tx_id"), event.data.get("refund_id")) else: sync_refund_total(event.data["vp_tx_id"], event.data["amount_refunded_total"]) return jsonify(received=True), 200 except Exception as e: return jsonify(error=str(e)), 400 ``` ### Manual verification (any language) The algorithm, reference implementations in five languages, and a shell/openssl sanity check (debugging only) are on [Webhook Verification](./webhook-verification.md). ## Retries {#retry-behavior} A 5xx, a timeout (10 seconds) or a refused connection is retried on an exponential schedule — **8 attempts** at roughly 0 / 30s / 2m / 10m / 1h / 6h / 24h / 48h, each jittered. `404`, `408`, `425` and `429` are retried the same way; any other 4xx marks the event dead immediately; `410 Gone` disables the endpoint; a per-endpoint circuit breaker pauses delivery to a URL that keeps returning 5xx. Schedule, response-code semantics and dashboard inspection: [Webhook Retries](./webhook-retries.md). Respond `200` quickly and process asynchronously; deduplicate on `id`; subscribe only to the events you handle; rotate signing secrets on a schedule — [Webhook Signing Secrets → Rotate](./webhook-secrets.md#3-rotate) has the zero-downtime sequence. ## No abandonment event Every event follows a payment attempt. [`session.succeeded` and `session.failed`](webhook-events.md#session-events) report a hosted-checkout session's outcome, but fulfil on [`charge.succeeded`](webhook-events.md#chargesucceeded), which carries the saved card and the id you refund against. There is no `session.expired` event; a buyer who closes the hosted page without paying produces nothing. For abandoned-cart signals, poll `GET /v1/sessions/:id` after the session's TTL (30 minutes by default) elapses. ## Related - [Webhook Event Reference](./webhook-events.md) — full catalog, per-event payload shapes - [Webhook Signature Verification](./webhook-verification.md) — algorithm + reference verifiers - [Webhook Signing Secrets](./webhook-secrets.md) — create, view-once, rotate, revoke - [Webhook Retries](./webhook-retries.md) — schedule, response-code semantics, circuit breaker - [Reconciliation](./reconciliation.md) — redirect-signal vs webhook-signal interplay --- ## /integration/what-reaches-your-provider Source: https://docs.vonpay.com/integration/what-reaches-your-provider # What reaches your payment provider Not everything you send on a charge is forwarded to the provider that processes it, and **what is forwarded is a property of your connection, not of the API** — a field you rely on as chargeback evidence may never arrive, and a field you assumed was private may be passed on in full. :::warning What we forward is a property of YOUR connection — ask us before you design around it The table below is true of the connection most merchants are on, not all of them. On some connections the billing block is not assembled at all — `billing_address` and `buyer_email` are accepted, stored, and go no further — while the whole `metadata` map goes to the processor instead. No field on the API tells you which you are on; ask us, and design your dispute process around the answer. ::: **Can reach your provider** | Field | Why it matters there | |---|---| | [`billing_address`](./getting-a-payment-method-token.md#billing-address--avs) | Where the billing block is assembled, this is what runs Address Verification and is the strongest evidence when defending an "I did not authorise this" chargeback. ⚠️ [AVS activates per gateway and is opt-in](../guides/address-verification.md) — on a connection that does not assemble the block, sending it does nothing at all. | | `buyer_email` | Forms part of that same billing block, so it travels only where the block does. | | [`buyer_ip_address`](./getting-a-payment-method-token.md#buyer-ip-address) | The shopper's own address, so the processor's velocity limits, IP blocklists and regional filters judge your buyer rather than our servers. Server-to-server charges only: on Embedded Fields and hosted checkout you do not send it yourself. Not asserted on a merchant-initiated charge, and the create response's `buyer_ip_address_forwarded` tells you whether it went. | | [`rule_tags`](./payment-intents.md#rule-tags) | Matched by your provider's own rules. Up to 5 short labels, no personal data — values shaped like an email or key are rejected. Honoured only where your provider supports orchestration rules; elsewhere the charge is refused pre-flight with `capability_not_supported`. | | **One** order reference | Read from `metadata`, first match wins across `order_id`, `reference` and `merchant_reference`. | **Never forwarded** - `order` / `lineItems` - the buyer's phone and shipping address **Depends on your connection — do not assume either way** - the rest of `metadata` — see below. ### `metadata` is your record — do not rely on it reaching, or not reaching, the processor {#metadata-not-evidence} `metadata` is the system-of-record: echoed on the create response and read back from `GET /v1/payment_intents/{id}` ([Metadata](../reference/metadata.md) has the per-endpoint caps and what is stripped). How much of it travels depends on the endpoint and the connection: the hosted-checkout path forwards the whole map; on the discrete charge path most connections forward only the single order reference, and at least one forwards every non-reserved key. So it is **neither dependable evidence nor reliably private** — evidence for a dispute has to travel as `billing_address`, `buyer_email` or a `rule_tags` label, and nothing goes in `metadata` that you would not send to a third party. Send `billing_address` on card charges: on a connection that uses it, a charge with no address gets no Address Verification, with no warning either way, unless the saved card already holds one ([stored billing address](../reference/tokens.md#update-billing-address)). One connection cannot carry an address on the charge and refuses a charge that includes it with `422 capability_not_supported`, so check [Address verification](../guides/address-verification.md) if a charge is refused. Send the buyer's name as `buyer.first_name` and `buyer.last_name` — structured, and they take priority over every other source when the billing block is built; a single free-text name is never split and does not reach the billing block. They belong to the charge, not the buyer profile, so send them on every charge ([Putting a name on the payment](../reference/buyers.md#buyer-name)). Forwarding of `buyer_email` and the billing block is an account-level setting, so a successful response does not by itself confirm the block was sent — ask us if you need it enabled. --- ## Platform integration runbook Source: https://docs.vonpay.com/platforms/gateway-integration-runbook # Adding Vora as a payment gateway For the engineering team at a SaaS platform — CRM, cart, subscription billing, marketplace — adding Von Payments to the gateway list its merchants choose from. [Integrate VORA as a Payment Gateway](./index.md) maps the API surface onto a gateway-adapter contract; this runbook is the sequence, the decisions, the settings screen your merchants touch, and the evidence we need before we list you. The samples are TypeScript on Node, matching the [reference adapter](https://github.com/Von-Payments/vonpay-samples/tree/main/platform-integrator-nextjs), plus cURL for the first smoke test and PHP for webhook verification; the client written in step 2 is the client used in steps 3 through 7. ```text src/vonpay/ client.ts HTTP client — headers, idempotency, retries, typed errors [step 2] gateway.ts the adapter — authorize / capture / sale / void / refund [step 2] cards.ts card capture + saved-card charging [step 3] webhooks.ts signature verification, event routing, registration [step 4] connect.ts connection test, save, disconnect [step 5] copy.ts error code → merchant-facing message [step 5] src/settings/ GatewaySettings.tsx the settings screen [step 5] scripts/ certify.ts the certification suite [step 6] ``` ## Contents {#contents} | Step | What happens | Who | Typical elapsed | |---|---|---|---| | [0](#step-0) | Get a sandbox, prove the key works | You | 1 hour | | [1](#step-1) | Choose the integration shape | You + us | 1 call | | [2](#step-2) | Build the six adapter operations | You | 3–10 days | | [3](#step-3) | Card collection + saved cards | You | 2–5 days | | [4](#step-4) | Webhooks | You | 1–2 days | | [5](#step-5) | **The gateway settings page** | You | 2–4 days | | [6](#step-6) | Certification in sandbox | You + us | 1 week | | [7](#step-7) | Go live, staged | Us + you | 2 weeks | --- ## Step 0 — Get a sandbox {#step-0} 1. Go to **[vonpay.com/developers](https://vonpay.com/developers)** and sign in with your work email (one-time code, no password to manage). 2. **[Start an application](https://app.vonpay.com/merchant/apply).** A test sandbox belongs to a business, and starting the application sets yours up. Nothing has to be approved before you can build. 3. Click **Activate Evaluation Sandbox** in Developer Tools. Your keys appear at **[app.vonpay.com/dashboard/developers/api-keys](https://app.vonpay.com/dashboard/developers/api-keys)**. You now have a sandbox merchant attached to your business, with a test secret key (`vp_sk_test_…`), a test publishable key (`vp_pk_test_…`) and its own processor test account. No money moves. See [Sandbox & test mode](../guides/sandbox.md#get-sandbox-keys). ### Key anatomy {#key-anatomy} ```text vp_ sk _ live _ a1b2c3d4e5… ─┬─ ─┬── │ └── the only thing that selects the environment (live | test) └──────── sk = secret, server only · pk = publishable, browser-safe ``` Both modes use the **same host**. There is no separate sandbox hostname. ### The four-call smoke test {#smoke-test} Run this before you write a line of adapter code. Every call is read-only or reversible. ```bash export VP_KEY="vp_sk_test_…" export VP_HOST="https://checkout.vonpay.com" # 1 — Does the key authenticate, and what can this merchant actually do? curl -sS "$VP_HOST/v1/capabilities" \ -H "Authorization: Bearer $VP_KEY" | jq # 2 — Is it genuinely a SECRET key? # Call 1 accepts publishable keys too, so on its own it cannot tell you. # 200 = secret key. 403 auth_key_type_forbidden = you pasted the publishable one. curl -sS -o /dev/null -w '%{http_code}\n' "$VP_HOST/v1/webhook_subscriptions" \ -H "Authorization: Bearer $VP_KEY" # 3 — Validate a real request body without creating anything. curl -sS -X POST "$VP_HOST/v1/sessions?dry_run=true" \ -H "Authorization: Bearer $VP_KEY" \ -H "Content-Type: application/json" \ -d '{"amount":1499,"currency":"USD","country":"US", "successUrl":"https://example.com/thanks"}' | jq # → { "valid": true, "warnings": ["This merchant is in sandbox mode — …"] } # 4 — Create one for real. This is your first end-to-end proof. # NOTE the key is a FIXED literal, not $(date +%s). Re-running this script # therefore returns the SAME session rather than minting a new one — which is # the behaviour you want to see. Do not copy a per-invocation key into a # charging call: a key that changes per attempt is exactly what turns a # timeout into a second charge. curl -sS -X POST "$VP_HOST/v1/sessions" \ -H "Authorization: Bearer $VP_KEY" \ -H "Content-Type: application/json" \ -H "Von-Pay-Version: 2026-04-14" \ -H "Idempotency-Key: smoke-session-1" \ -d '{"amount":1499,"currency":"USD","country":"US", "successUrl":"https://example.com/thanks"}' | jq # → { "id": "vp_cs_test_…", "checkoutUrl": "https://…", "expiresAt": "…" } ``` Open the `checkoutUrl` in a browser and pay with a test card. On your sandbox the processor's test environment decides the outcome by order total: an ordinary total approves, and the totals listed in [Test mode](../reference/test-cards.md#processor-test-account) decline. Machine-readable discovery, if your build wants to pin endpoints rather than hardcode them: ```bash curl -sS "$VP_HOST/.well-known/vonpay.json" | jq '.api_version, .endpoints, .scripts' ``` ### Read these, in this order {#reading-order} | Page | Why | |---|---| | [Choose your integration](../integration-paths.md) | The paths side by side, with a decision tree | | [Integrate VORA as a payment gateway](./index.md) | The adapter-contract mapping | | [Sandbox & test mode](../guides/sandbox.md) | What the sandbox does and does not simulate | | [OpenAPI spec](https://checkout.vonpay.com/openapi.yaml) | Hand this to your engineers; source of truth for every shape | :::warning There are two kinds of sandbox and they live on different hosts The sandbox above runs on the production host. If we instead board you onto a **processor sandbox** during a partnership, we will give you a different host. Use exactly the host you were given, and set it in **both** your server code and your browser code — it is never inferred from the key. Setting it server-side only is the most common onboarding failure: your server calls succeed, every browser call returns `401`, and it looks like a bad key. ::: --- ## Step 1 — Choose the integration shape {#step-1} We expose two ways to collect a card and one engine that settles them. Pick per product, not per merchant — your merchants should not have to understand this. Payment intents is the server-side engine both front-end paths settle through, and the one you call directly for anything after the first charge. | | Buyer sees | Connector build | Choose it when | |---|---|---|---| | **Hosted checkout** *(front end)* | Redirect to our page, then back | ~half a day | You want the shortest path, and leaving your product briefly is acceptable | | **Embedded fields** *(front end)* | Card fields inside your page | 2–5 days | Your branding matters and the buyer must stay in your app | | **Payment intents** *(engine)* | *(server-side; no UI)* | 3–10 days | Always — it is what capture, void, refund and every rebill go through | The build estimates are for a **multi-tenant connector**: per-merchant credential storage, a settings screen, webhook routing across merchants. A single merchant integrating their own checkout is much faster — see [Choose your integration](../integration-paths.md) for those figures. These numbers are for your planning, not for a merchant-facing quote. ### The three surfaces, in code {#three-surfaces} **Hosted checkout** — one server call, then a redirect. ```ts const session = await vp.request<{ id: string; checkoutUrl: string; expiresAt: string }>( "POST", "/v1/sessions", { body: { amount: 1499, // minor units — $14.99 currency: "USD", country: "US", successUrl: `https://app.example.com/orders/${orderId}/thanks`, cancelUrl: `https://app.example.com/orders/${orderId}`, buyerId: customer.id, // YOUR customer reference buyerEmail: customer.email, lineItems: [{ name: "Pro plan — March", quantity: 1, unitAmount: 1499 }], }, idempotencyKey: `acme_${orderId}_session`, }, ); res.redirect(session.checkoutUrl); // Outcome arrives on your webhook. Do not treat the redirect as authoritative. ``` **Embedded fields** — same server call with two extra fields, then the browser SDK. ```ts // server const session = await vp.request("POST", "/v1/sessions", { body: { amount: 1499, currency: "USD", country: "US", successUrl: `https://app.example.com/orders/${orderId}/thanks`, buyerId: customer.id, integrationMode: "elements", // discrete fields you lay out yourself chargeAtSubmit: true, // charge when the buyer submits }, idempotencyKey: `acme_${orderId}_session`, }); ``` ```html
``` **Payment intents** — no UI; charges a card you already hold. ```ts const intent = await vp.request("POST", "/v1/payment_intents", { body: { amount: 4999, currency: "USD", capture_method: "automatic", // or "manual" for authorize-only payment_method: { id: "vp_pmt_live_…" }, buyer_id: customer.id, return_url: "https://app.example.com/3ds/return", }, idempotencyKey: `acme_${orderId}_charge`, }); // intent.status → succeeded | authorized | requires_action | failed | voided | captured ``` ### Our recommendation for a CRM or subscription platform {#recommendation} **Embedded fields for collecting the card, payment intents for every charge after that.** - **Payment intents is the only shape whose lifecycle matches a gateway-adapter contract.** `authorize` / `capture` / `void` / `refund` are discrete calls that map 1:1 onto the methods your adapter interface almost certainly already has. - **Recurring and card-on-file charges are server-initiated** and need a saved card — so embedded fields (or hosted checkout) for the first interaction only. - **Hosted checkout is the universal fallback.** It works for every merchant regardless of which processor they are on. Embedded fields availability is per-merchant. Build the fallback even if you lead with embedded fields — see [§5.5](#s55-save). --- ## Step 2 — Build the adapter {#step-2} ### 2.1 The operation map {#s21-operation-map} | Your adapter method | Our call | Reaches status | Watch out | |---|---|---|---| | `authorize` | `POST /v1/payment_intents` with `capture_method: "manual"` | `authorized` | — | | `capture` | `POST /v1/payment_intents/{id}/capture` | `succeeded` | Partial via `amount_to_capture`; over-capture is refused | | `sale` (auth + capture) | `POST /v1/payment_intents` with `capture_method: "automatic"` | `succeeded` | The default | | `void` | `POST /v1/payment_intents/{id}/void` | `voided` | Only before capture. After capture, issue a refund instead | | `refund` | `POST /v1/refunds` | *(see below)* | **Four different 2xx shapes** | | `store card` | `POST /v1/public/tokens` (browser) or `POST /v1/tokens` (server) | — | Consent must be captured here — see [Step 3](#step-3) | | `status` | `GET /v1/payment_intents/{id}` | — | Also delivered by webhook | ### 2.2 The HTTP client {#s22-http-client} One place that owns headers, idempotency, retries and error shape. Everything else in this runbook calls it. ```ts // src/vonpay/client.ts export type VonPayMode = "test" | "live"; export interface VonPayCredentials { secretKey: string; // vp_sk_test_… | vp_sk_live_… publishableKey?: string; // vp_pk_…, browser only, never sent from here webhookSigningSecret?: string; // whsec_… webhookSubscriptionId?: string; merchantId?: string; // pinned from GET /v1/sessions/{id} at first session create, see connect.ts } export class VonPayError extends Error { constructor( readonly status: number, readonly code: string, message: string, readonly fix: string | null, readonly requestId: string | null, ) { super(message); this.name = "VonPayError"; } } const API_VERSION = "2026-04-14"; // Safe to replay because we always send the same Idempotency-Key. const RETRYABLE_STATUS = new Set([429, 502, 503]); // Codes that explicitly mean "nothing happened, send it again". // // ⛔ 409 `charge_in_progress` is deliberately NOT here. It means a charge for this // (merchant, Idempotency-Key) is ALREADY RUNNING or has already finished, on a route // that keeps no record of its own. Retrying it, and above all reissuing with a fresh // key, is exactly how a buyer gets charged twice on a connection where nothing can // merge the two afterwards. Check the original payment's status instead. const RETRYABLE_CODES = new Set([ "charge_reservation_unavailable", // 503, the pre-charge record failed, nothing was sent "service_unavailable", // 503, another pre-charge check failed, nothing was sent "auth_service_unavailable", // 503, auth replica blipped ]); export class VonPayClient { constructor( private readonly creds: VonPayCredentials, readonly baseUrl = "https://checkout.vonpay.com", ) {} async request( method: "GET" | "POST" | "PATCH" | "DELETE", path: string, opts: { body?: unknown; idempotencyKey?: string; maxAttempts?: number } = {}, ): Promise { const maxAttempts = opts.maxAttempts ?? 4; let last: VonPayError | undefined; for (let attempt = 1; attempt <= maxAttempts; attempt++) { const res = await fetch(`${this.baseUrl}${path}`, { method, headers: { Authorization: `Bearer ${this.creds.secretKey}`, "Content-Type": "application/json", // Pin the version. Omitting it tracks latest, which can change behind you. "Von-Pay-Version": API_VERSION, // The SAME key on every attempt. This is what makes the retry safe. ...(opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {}), }, body: opts.body === undefined ? undefined : JSON.stringify(opts.body), }); const requestId = res.headers.get("x-request-id"); const text = await res.text(); const payload = text ? JSON.parse(text) : {}; // 200, 201 and 202 are all successes. A 202 on a refund is NOT a failure. if (res.ok) return payload as T; const err = new VonPayError( res.status, payload.code ?? "unknown_error", payload.error ?? res.statusText, payload.fix ?? null, requestId, ); // GET/DELETE are idempotent by definition. A POST/PATCH is only safe to // replay if we sent a key the server can collapse it on, WITHOUT one, a // retried 502 is a second subscription, a second rotation, a second charge. // The comment above says "safe because we always send the same key"; this // is what makes that true instead of merely intended. So send a key ONLY to // an endpoint that honours one (payment intents and their capture/void, // refunds, sessions): on any other POST it would make this loop retry a // request the server then runs twice. const replayable = method === "GET" || method === "DELETE" || !!opts.idempotencyKey; const retry = replayable && (RETRYABLE_STATUS.has(res.status) || RETRYABLE_CODES.has(err.code)); if (!retry || attempt === maxAttempts) throw err; const retryAfter = Number(res.headers.get("retry-after")); const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : Math.min(2 ** attempt * 250, 8_000) + Math.random() * 250; await new Promise((r) => setTimeout(r, waitMs)); last = err; } throw last!; } } ``` ### 2.3 Idempotency — the rule that prevents double charges {#s23-idempotency} Use one stable key per **logical operation** and **reuse it on every retry**: ```ts // src/vonpay/gateway.ts /** * `{platform}_{your order id}_{operation}` * * The last segment names WHICH operation on that order this is — charge, capture, * refund, refund-2 for a deliberate second refund. It must NOT carry a retry or * attempt counter: retrying is the exact case the key exists to collapse, so a * counter there defeats it and turns a network timeout into a second real charge. */ `${PLATFORM}_${orderId}_${operation}`; ``` | Situation | What we return | What you do | |---|---|---| | Same key, same body | `200` with the original response | Treat as success | | Same key, **different** body | `422 idempotency_replay_incompatible` | Send the original body, or use a fresh key for a genuinely different operation | | Two requests race on one key | `409 charge_in_progress` | Check the original payment's status — see the box below | | The pre-charge record could not be written, or another check before charging could not complete | `503 charge_reservation_unavailable` or `503 service_unavailable` | Nothing was sent. Retry with the **same** key — no claim was written, so an identical request cannot duplicate | :::danger A fresh key after `409 charge_in_progress` charges the buyer twice A charge for this key is already running or has already finished, this request's own outcome is not yet confirmed, or an earlier charge on the same saved card has not resolved, on a route that keeps no record of its own. Do not retry and do not reissue with a new key — on a connection where nothing can merge the two afterwards, a second key for the same payment is exactly how a buyer gets charged twice. Read the original payment's status instead. ::: On refunds there is one connection type where the key is **required** — a keyless refund is refused with `400 idempotency_key_required`, because there our record is the only record a repeat call could be checked against. Send the header on every connection and you never meet the distinction. ### 2.4 The adapter {#s24-adapter} ```ts // src/vonpay/gateway.ts (continued) export type IntentStatus = | "requires_action" | "authorized" | "captured" | "succeeded" | "voided" | "failed"; export interface PaymentIntent { id: string; // vpi_live_… status: IntentStatus; amount: number; currency: string; capture_method: "automatic" | "manual"; next_action?: { type: "redirect_to_url"; redirect_to_url: { url: string } } | null; decline_code?: string | null; decline_message?: string | null; avs_result_code?: string | null; cvv_result_code?: string | null; card?: { brand: string; last4: string } | null; created_at?: string; } export interface ChargeInput { orderId: string; // YOUR order id amountMinor: number; currency: string; paymentMethodId: string; // vp_pmt_… buyerId: string; // YOUR customer id — must own the card returnUrl: string; // where the buyer lands after a 3-D Secure challenge billingAddress?: { address_line1: string; address_line2?: string; city?: string; state?: string; postal_code: string; country: string; // ISO-3166 alpha-2, uppercase }; metadata?: Record; } export class VonPayGateway { constructor(private readonly vp: VonPayClient) {} /** Authorize only — money is held, not taken. */ authorize(input: ChargeInput) { return this.createIntent(input, "manual", "authorize"); } /** Authorize and capture in one step. The common case. */ sale(input: ChargeInput) { return this.createIntent(input, "automatic", "charge"); } private createIntent( input: ChargeInput, capture_method: "automatic" | "manual", op: string, ) { return this.vp.request("POST", "/v1/payment_intents", { body: { amount: input.amountMinor, currency: input.currency, capture_method, payment_method: { id: input.paymentMethodId }, buyer_id: input.buyerId, return_url: input.returnUrl, ...(input.billingAddress ? { billing_address: input.billingAddress } : {}), ...(input.metadata ? { metadata: input.metadata } : {}), }, idempotencyKey: idem(input.orderId, op), }); } /** Capture an authorization. Omit amountMinor to capture in full. */ capture(orderId: string, intentId: string, amountMinor?: number) { return this.vp.request( "POST", `/v1/payment_intents/${intentId}/capture`, { body: amountMinor == null ? {} : { amount_to_capture: amountMinor }, idempotencyKey: idem(orderId, "capture"), }, ); } /** Release an authorization. Only valid BEFORE capture. */ void(orderId: string, intentId: string) { return this.vp.request( "POST", `/v1/payment_intents/${intentId}/void`, { body: {}, idempotencyKey: idem(orderId, "void") }, ); } retrieve(intentId: string) { return this.vp.request("GET", `/v1/payment_intents/${intentId}`); } /** Refund a captured payment. The logic, and why each outcome matters, is in `refundIntent` below. */ refund(orderId: string, intentId: string, opts?: RefundOptions) { return refundIntent(this.vp, orderId, intentId, opts); } } ``` ### 2.5 Refunds — four 2xx shapes, only one moved money {#s25-refunds} Branch on `status`, never on the HTTP code. | Response | Meaning | |---|---| | `201` + `status: "succeeded"` | Money went back | | `202` + `status: "requested"` | Accepted, still in flight. **Not a failure.** Retrying refunds the buyer twice | | `200` + `status: "canceled"` | Voided before it left — the buyer was **not** repaid | | `200` + `idempotent: true` | A replay of a refund you already issued | ```ts // src/vonpay/gateway.ts (continued) export interface RefundResponse { id: string; // vpr_live_… payment_intent: string | null; amount: number; currency: string; status: "requested" | "succeeded" | "failed" | "canceled"; reason?: string | null; idempotent?: boolean; } export type RefundOutcome = | { kind: "refunded"; refundId: string; amountMinor: number } | { kind: "pending"; refundId: string } // settle it on the webhook, do NOT retry | { kind: "canceled"; refundId: string } // buyer was NOT repaid // Refused (a provider decline, more than remains, …; `code` says which). It is // recorded as FAILED, so this key is spent: a retry on it replays the failure. | { kind: "declined"; code: string; message: string } | { kind: "replay"; refundId: string; status: RefundResponse["status"] } // A replay of a refund that already FAILED. Critically NOT the same as "replay": // the money never moved and never will on this key. | { kind: "replay_of_failed"; refundId: string }; export interface RefundOptions { amountMinor?: number; // omit for a full refund reason?: "duplicate" | "fraudulent" | "requested_by_customer" | "expired_uncaptured_charge"; /** * "refund" for the first refund of an order, "refund-2" for a deliberate second * one. NEVER a retry counter. */ refundRef?: string; } /** What `VonPayGateway.refund` runs (the adapter's one-line method delegates here). */ export async function refundIntent( vp: VonPayClient, orderId: string, intentId: string, opts: RefundOptions = {}, ): Promise { let r: RefundResponse; try { r = await vp.request("POST", "/v1/refunds", { body: { payment_intent: intentId, ...(opts.amountMinor != null ? { amount: opts.amountMinor } : {}), ...(opts.reason ? { reason: opts.reason } : {}), }, // Required on some connections, harmless on all of them. Always send it. idempotencyKey: idem(orderId, opts.refundRef ?? "refund"), }); } catch (e) { // ⛔ A provider DECLINE is not a 2xx with status:"failed" — it is a non-2xx // throw (`refund_declined`, `refund_before_settlement`, …). Without this // catch it propagates as an unhandled exception and nobody is told, which // is the outcome that most needs a human. if (e instanceof VonPayError && e.status === 422) { return { kind: "declined", code: e.code, message: e.message }; } throw e; } // ⚠ Order matters. A replay can carry status:"failed" — the first attempt hit // a transient provider error, the row was written `failed`, and every retry on // that key now returns 200 + idempotent:true + failed forever. Collapsing that // to a plain "replay" reports a refund that never happened as already handled, // and the buyer is never repaid. Check the status BEFORE the idempotent flag. if (r.idempotent) { return r.status === "failed" ? { kind: "replay_of_failed", refundId: r.id } : { kind: "replay", refundId: r.id, status: r.status }; } switch (r.status) { case "succeeded": return { kind: "refunded", refundId: r.id, amountMinor: r.amount }; case "requested": return { kind: "pending", refundId: r.id }; case "canceled": return { kind: "canceled", refundId: r.id }; // Reachable only defensively — the live 2xx shapes are the four above. case "failed": return { kind: "replay_of_failed", refundId: r.id }; } } ``` Wiring it into your own refund flow — note that `pending` stays open in *your* ledger: ```ts const outcome = await gateway.refund(order.id, order.vonpayIntentId, { amountMinor: 500, reason: "requested_by_customer", }); switch (outcome.kind) { case "refunded": await markRefunded(order, outcome.amountMinor); break; case "replay": /* already handled on a previous attempt */ break; case "pending": await markRefundInFlight(order, outcome.refundId); break; case "canceled": await notifyOps(order, "refund canceled — buyer NOT repaid"); break; // Refused. It is recorded as failed, so this key is spent (a retry on it // replays the failure). Fix the cause (settlement not reached, closed card, // gateway balance) and issue a NEW refund under a new key (refundRef "refund-2"). case "declined": await notifyOps(order, `refund declined (${outcome.code}) — buyer NOT repaid: ${outcome.message}`); break; // ⛔ The dangerous one. This key is permanently poisoned: the row is `failed` // and every retry on it returns the same dead result with idempotent:true. // Retrying this key can NEVER repay the buyer. A human must issue a fresh // refund under a NEW key (e.g. refundRef "refund-2") once the cause is fixed. case "replay_of_failed": await notifyOps(order, "refund key is spent on a FAILED attempt — buyer NOT repaid; reissue under a new key"); break; } ``` ### 2.6 Field casing differs by surface — and unknown fields are refused {#s26-casing} This is the most common integration bug, and it fails loudly, which is the point. | Surface | Casing | Example | |---|---|---| | `POST /v1/sessions` | **camelCase** | `successUrl`, `lineItems`, `buyerId` | | `POST /v1/payment_intents`, `/v1/refunds`, `/v1/tokens` | **snake_case** | `capture_method`, `payment_method`, `buyer_id` | Every request body is strict. A field we do not recognise returns `400 validation_unknown_field` naming the offending field, with a `suggestions` map when the cause is casing. We refuse rather than ignore deliberately: silently dropping a field means your request looks accepted while the value it carried is lost. ### 2.7 Errors {#s27-errors} Every error carries the same envelope plus an `X-Request-Id` header: ```json { "error": "human-readable message", "code": "machine_readable_code", "fix": "actionable fix", "docs": "https://docs.vonpay.com/reference/error-codes#…", "selfHeal": { "retryable": false, "nextAction": "fix_request", "llmHint": "…" } } ``` Branch on `code`, surface `fix`, log `X-Request-Id`. An adapter that handles the `auth_*`, `merchant_*`, `validation_*` and `provider_*` families idiomatically is structurally complete. Full catalogue: [Error codes](../reference/error-codes.md). ```ts // A decline is not an exception in your domain — it is a business outcome. // Keep the two apart at the boundary. try { const intent = await gateway.sale(input); if (intent.status === "requires_action") { // 3-D Secure. Send the buyer to the challenge as a TOP-LEVEL navigation — // banks block it inside an iframe. The outcome arrives on your webhook. return { kind: "challenge", url: intent.next_action!.redirect_to_url.url }; } if (intent.status === "failed") { return { kind: "declined", code: intent.decline_code, message: intent.decline_message }; } return { kind: "paid", intentId: intent.id }; } catch (e) { if (e instanceof VonPayError) { log.warn({ code: e.code, requestId: e.requestId }, "vonpay call failed"); // 501 endpoint_not_implemented is a PROVISIONING state, not a crash — the // merchant's connection is not activated for discrete charges yet. if (e.code === "endpoint_not_implemented") return { kind: "not_provisioned" }; return { kind: "error", merchantMessage: merchantCopy(e.code) }; // step 5 } throw e; } ``` --- ## Step 3 — Card collection and saved cards {#step-3} ### 3.1 Never let a card number reach your servers {#s31-pci} Card fields render inside an iframe we host, so a card number never reaches your servers through this integration (SAQ-A for this path). Load the browser SDK from the CDN — `vora.js` is not on npm: ```html ``` The script URL is the same in every environment. **The environment comes from the `apiBaseUrl` option you pass to the constructor, and it defaults to production.** Pass the same host your server used to create the session. ### 3.2 The ordering dependency — read this before you design anything {#s32-ordering} :::danger Permission to charge a card again is recorded when the card is saved, and no endpoint adds it later If you save a card without it, the first charge succeeds and every renewal after that fails with `422 payment_method_consent_missing`. The only fix is to make the customer enter the card again. For a subscription platform that is a churn event, discovered weeks later. ::: So, when the card is saved, send **all three** of these: | Field | Values | What it means | |---|---|---| | `setup_for_future_use` | `"off_session"` \| `"on_session"` | Permission to **charge** the card later. `off_session` is required for any server-initiated charge | | `allow_redisplay` | `"always"` \| `"limited"` \| `"unspecified"` | Permission to **show** the card back to the buyer. Only `always` lists it in their saved cards | | `storedCredentialUse` (session) / `stored_credential_use` (token) | `"recurring"` \| `"installment"` \| `"unscheduled"` | The declaration to the card networks about what the arrangement *is* | `storedCredentialUse` is sent at session create and is **secret-key only** — a publishable key gets `403 auth_key_type_forbidden`. Omitting it declares nothing; nothing is inferred from your product's behaviour. Whatever you send is recorded as the consent, and nothing checks that anyone ticked anything. Show a real checkbox and capture the buyer's permission genuinely. ### 3.3 Collecting the card {#s33-collecting} :::danger Pick ONE of these two, per order — doing both charges the buyer twice The sample below uses `chargeAtSubmit: true`, which means **the card is charged when the buyer submits**, before any server call of yours. If you use it, that order is already paid: do not also call `POST /v1/payment_intents` for it. Confirm the outcome on the webhook. If you would rather follow [§1](#recommendation) literally — payment intents for *every* charge, including the first — set `chargeAtSubmit: false`. Submit then only vaults the card, and your server charge is the single charge. Nothing stops you from doing both. The server-side duplicate guard only engages when you pass `session_id` on the charge, and the adapter types in [§2.4](#s24-adapter) deliberately have no such field — so if you mix the two patterns, the two charges are keyed differently and nothing merges them. ::: ```ts // src/vonpay/cards.ts export type BillingArrangement = "recurring" | "installment" | "unscheduled"; export interface CardCaptureSession { id: string; // vp_cs_live_… checkoutUrl: string; // the hosted fallback — always usable expiresAt: string; integrationMode: "elements" | "embed"; chargeAtSubmit: boolean; storedCredentialUse: BillingArrangement | null; // echoed back — assert on it } /** * One session that charges the first payment AND saves the card for later billing. * * `storedCredentialUse` cannot be added to a card after the fact, so it is set * here or never. The response echoes it back — assert on that echo rather than * assuming the field took effect. */ export async function createCardCaptureSession( vp: VonPayClient, tenantId: string, input: { orderId: string; amountMinor: number; currency: string; country: string; buyerId: string; // required — a saved card needs an owner buyerEmail?: string; returnUrl: string; arrangement: BillingArrangement; }, ): Promise { const session = await vp.request("POST", "/v1/sessions", { body: { amount: input.amountMinor, currency: input.currency, country: input.country, successUrl: input.returnUrl, buyerId: input.buyerId, ...(input.buyerEmail ? { buyerEmail: input.buyerEmail } : {}), integrationMode: "elements", chargeAtSubmit: true, storedCredentialUse: input.arrangement, }, idempotencyKey: idem(input.orderId, "session"), }); if (session.storedCredentialUse !== input.arrangement) { // No echo means the declaration did not take effect. Fail loudly here rather // than discovering it when the first renewal declines, weeks from now. throw new Error( `arrangement not recorded: asked for ${input.arrangement}, ` + `got ${session.storedCredentialUse ?? "null"}`, ); } // Learn which Von merchant these credentials belong to, from our API rather // than from a webhook, the first time a session exists to ask about. The // receiver (src/vonpay/webhooks.ts) compares every delivery to this pin. await pinMerchantIdFromSession(tenantId, vp, session.id); return session; } ``` Browser side: ```html
``` :::note On the consent checkbox The SDK also exposes a mountable consent element that reports `setup_for_future_use` on submit. That is the path we recommend, because it keeps the recorded consent and the thing the buyer saw in one place. It needs a session that already carries a `buyerId`. Confirm the element name and required session shape against the [Elements reference](../embedded-fields/elements.md) — and with us — before you build recurring on top of it. ::: ### 3.4 Charging the saved card later {#s34-charging-saved} ```ts // src/vonpay/cards.ts (continued) export interface MitInput { orderId: string; // YOUR renewal/order id amountMinor: number; currency: string; paymentMethodId: string; // vp_pmt_… saved earlier buyerId: string; // must match the card's owner, or 404 // Should match what you declared at save time. ⚠ Nothing here enforces that: // a mismatch is logged server-side and the call still succeeds. It is the CARD // NETWORK that refuses, and it surfaces to you as a bare issuer decline with no // code pointing at the cause. So this is on you to get right, not on our 4xx. arrangement: BillingArrangement; originalIntentId: string; // vpi_… the charge where the buyer was present } export async function chargeSavedCard(vp: VonPayClient, input: MitInput) { return vp.request("POST", "/v1/payment_intents", { body: { amount: input.amountMinor, currency: input.currency, capture_method: "automatic", payment_method: { id: input.paymentMethodId }, buyer_id: input.buyerId, mit: { initiator: "merchant", // nobody is at the keyboard reason: input.arrangement, // recurring | installment | unscheduled original_transaction_id: input.originalIntentId, }, // No return_url — an off-session charge has no buyer to send anywhere. // setup_for_future_use is NOT a field here; sending it returns 400 on purpose. }, idempotencyKey: idem(input.orderId, "renewal"), }); } ``` Gate the whole feature on the merchant's account supporting it: ```ts const caps = await vp.request("GET", "/v1/capabilities"); if (!caps.supported_operations.mit) { // Don't mint tokens this account can never charge. Hide the feature instead. throw new FeatureUnavailable("saved cards"); } ``` Handling the renewal outcome — `payment_method_consent_missing` is the one that needs its own branch, because no retry will ever fix it: ```ts try { const intent = await chargeSavedCard(vp, input); if (intent.status === "succeeded") return markRenewed(subscription, intent.id); if (intent.status === "failed") return markRenewalDeclined(subscription, intent.decline_code); } catch (e) { if (e instanceof VonPayError && e.code === "payment_method_consent_missing") { // Unrecoverable by retry. The card was saved without off-session permission // and there is no endpoint that adds it. The customer must re-enter the card. return startCardUpdateFlow(subscription, { reason: "We need you to confirm this card for automatic renewals.", }); } throw e; } ``` :::warning Card-on-file at irregular intervals may not work on every connection On some processor connections a fixed-schedule subscription (`recurring` → `recurring`) is approved while every other arrangement — *including a correctly matched `unscheduled` → `unscheduled` pair* — is declined. If your merchants keep a card and charge it whenever the customer next orders, raise it with us before you build. Never tell a merchant to declare `recurring` to work around this. It is a statement to the card networks about an arrangement they do not have. ::: ### 3.5 Recovering a token you never received {#s35-recovering} If a card is saved during a flow that completes asynchronously — after a 3-D Secure challenge, say — the token may not be in the response you got. Read it back: ```ts // src/vonpay/cards.ts (continued) export interface SavedCard { id: string; // vp_pmt_… status: "active"; // Each can be null (display identity only; a wallet or an older save may lack one). card: { brand: string | null; last4: string | null; exp_month: number | null; exp_year: number | null }; setup_for_future_use: string | null; allow_redisplay: string | null; stored_credential_use: BillingArrangement | null; created_at: string; } export async function listSavedCards(vp: VonPayClient, buyerId: string) { const page = await vp.request<{ data: SavedCard[]; next_cursor: string | null; has_more: boolean }>( "GET", `/v1/payment_methods?buyer_id=${encodeURIComponent(buyerId)}&limit=25`, ); return page.data; } /** Only these can be charged off-session. Filter before you show a renewal option. */ cards.filter((c) => c.setup_for_future_use === "off_session"); ``` Full guide: [Recurring and saved cards](../guides/recurring-and-saved-cards.md). --- ## Step 4 — Webhooks {#step-4} Webhooks are how you learn the outcome of anything that finishes after your request returns. Build them; do not rely on the redirect or the synchronous response alone. ### 4.1 Events a gateway adapter maps {#s41-events} | Event | Fires when | Maps to | |---|---|---| | `charge.succeeded` | A payment succeeded (hosted or direct) | sale / capture success | | `charge.failed` | A payment attempt failed | sale / capture decline | | `charge.refunded` | A refund settled (once per refund) | refund success | | `refund.failed` | A refund did not complete — no money went back | refund failure | | `payment_intent.succeeded` | Intent reached `succeeded` | auth + capture settle | | `payment_intent.failed` | Intent reached `failed` | auth decline | | `payment_intent.cancelled` | Intent voided before capture | void | Envelope: `{ id, type, created, livemode, merchant_id, data }`. Full catalogue and per-event payloads: [Webhook events](../integration/webhook-events.md). ### 4.2 Verifying the signature {#s42-verifying} Over the **raw** body, before any parsing. Re-serialised JSON will not match. ```ts // src/vonpay/webhooks.ts const MAX_AGE_S = 300; // reject anything older than 5 minutes const MAX_SKEW_S = 30; // …or more than 30 seconds in the future /** * Header: `x-vonpay-signature: t=,v1=` * Message: `${t}.${rawBody}` * Key: the endpoint's whsec_… secret as raw UTF-8 — do NOT strip the prefix, * do NOT base64-decode it. * * Accepts if ANY v1 entry matches. Today the header carries exactly one, and * rotation replaces the signing secret with no dual-signing grace period: once the * switch-over completes, the previous secret stops verifying. * * ⭐ ROTATING THE SECRET: during the switch-over, verify against the OLD secret * and the NEW one, and accept the delivery if EITHER matches. Drop the old one * once the window has passed. * * ⚠ This function checks ONE secret — the loop below iterates the `v1=` entries * in the header, not your secrets, and today the header carries exactly one. * So "accept both" means CALLING THIS TWICE during rotation, once per secret: * * const ok = verifySignature(raw, header, newSecret) * || verifySignature(raw, header, oldSecret); * * ⚠ Why both, and not a swap at a single moment: rotation MINTS the new secret, * so there is nothing to swap until the rotate call returns, and the new secret * is pushed to the signer in deferred work after that response. There is * therefore a brief window in which EITHER secret may sign a delivery. A 4xx * returned in that window is NOT retried — reject a genuine delivery there and * it is gone for good. * * Full procedure: docs.vonpay.com/integration/webhook-secrets#3-rotate */ rawBody: string, header: string | null, signingSecret: string, ): boolean { if (!header) return false; let t: number | null = null; const candidates: string[] = []; for (const part of header.split(",")) { const idx = part.indexOf("="); if (idx === -1) continue; const k = part.slice(0, idx).trim(); const v = part.slice(idx + 1).trim(); if (k === "t") t = Number(v); else if (k === "v1" && v) candidates.push(v); } if (t === null || !Number.isFinite(t) || candidates.length === 0) return false; const now = Math.floor(Date.now() / 1000); if (now - t > MAX_AGE_S) return false; if (t - now > MAX_SKEW_S) return false; const expected = crypto .createHmac("sha256", signingSecret) .update(`${t}.${rawBody}`, "utf8") .digest(); return candidates.some((hex) => { let given: Buffer; try { given = Buffer.from(hex, "hex"); } catch { return false; } // timingSafeEqual throws on a length mismatch — check first. return given.length === expected.length && crypto.timingSafeEqual(given, expected); }); } ``` The same verifier in PHP, since many platforms are: ```php 300) return false; // too old if ($t - $now > 30) return false; // too far in the future // Raw UTF-8 secret, prefix included. Message is ".". $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); foreach ($candidates as $given) { if (hash_equals($expected, $given)) return true; // constant-time } return false; } ``` The header carries one `v1=` entry, no grace window: after a rotation every delivery is signed with the new secret only and a 4xx you return is not retried — accept both secrets while you rotate, then drop the old one ([how](../integration/webhook-secrets.md#3-rotate)). ### 4.3 The receiver {#s43-receiver} ```ts // src/vonpay/webhooks.ts (continued) webhookRouter.post( "/hooks/vonpay/:tenantId", // RAW body. This route must see the body BEFORE any JSON parser does, a // parsed-then-re-serialised body will never match the signature. // // ⚠ Ordering inside THIS file is not enough. If your app mounts a global // `app.use(express.json())` at startup, the common pattern, it has already // consumed the body by the time this runs, `req.body` is an object, and every // real webhook then fails verification with a 401 that has no obvious cause. // Mount this router before that parser, or exclude this path from it. express.raw({ type: "application/json", limit: "1mb" }), async (req, res) => { const raw = (req.body as Buffer).toString("utf8"); const creds = await loadCredentials(req.params.tenantId); if (!creds?.webhookSigningSecret) return res.status(404).end(); if (!verifySignature(raw, req.header("x-vonpay-signature") ?? null, creds.webhookSigningSecret)) { return res.status(401).end(); } const event = JSON.parse(raw) as VonPayEvent; // Second lock: the merchant named INSIDE the verified payload must be the // merchant this tenant is connected to. The HMAC above is the real control // and it is correct on its own; this is the belt for the day a support tool // resets the wrong tenant's signing secret, or two tenants are connected // to one Von account by mistake. With only the HMAC, a valid event for // merchant A would then be applied to tenant B's orders. // // The pin is learned from OUR API under the tenant's own secret key when // they create their first session (pinMerchantIdFromSession, connect.ts), // never from a delivery's payload. Until that first session exists no // legitimate charge or refund event can exist either (only the connect-time // test event), so an unpinned tenant is judged by the HMAC alone, exactly // as before this check existed. It catches a credential SWAPPED after // connect, not one provisioned for the wrong merchant on day one; nothing // downstream of a wrong secret can tell, which is why the connect-time // test event should show the merchant it names next to the form. if (creds.merchantId && event.merchant_id !== creds.merchantId) { log.warn( { tenantId: req.params.tenantId, got: event.merchant_id }, "vonpay webhook verified but for a different merchant; refusing", ); return res.status(401).end(); } // At-least-once delivery, so dedup BEFORE the work. `claimEvent` must be a // durable, atomic insert, a unique index on (tenant_id, event_id) is what // actually enforces it, because two deliveries can arrive concurrently. // Returning false means "already claimed": we have seen this event. if (!(await claimEvent(req.params.tenantId, event.id))) { return res.status(200).end(); } // ⛔ PROCESS FIRST, ACK LAST. Do NOT send 200 before handleEvent resolves. // // A 200 tells us the event was delivered, and we will never send it again. // If you ack first and the handler then throws, a DB blip, a downstream // timeout, a bug in one branch, that charge.succeeded or charge.refunded is // gone permanently, with no error anywhere and no retry to recover it. A // crash between the ack and the handler is worse: the event body only ever // existed in memory, so it dies with the process. // // Acking last is safe here precisely BECAUSE of claimEvent above: if we are // slow and the delivery is retried, the retry hits the claim and no-ops. You // trade a duplicate delivery (harmless) for never losing one (not harmless). try { await handleEvent(req.params.tenantId, event); res.status(200).end(); } catch (err) { // Release the claim AND return non-2xx, so the retry actually re-delivers. // Releasing without a non-2xx frees the dedup slot for a redelivery that // is never coming. await releaseEvent(req.params.tenantId, event.id); log.error({ err, eventId: event.id }, "vonpay webhook handler failed"); res.status(500).end(); } }, ); // If your handler is genuinely slow (seconds), don't ack-first to hide it - // persist the WHOLE event body durably, ack, and drain it from a worker with a // reaper for rows left in flight. Persisting a claim flag alone is not that: // the claim records that you saw an id, not what the event said. ``` ### 4.4 Routing the event {#s44-routing} ```ts // src/vonpay/webhooks.ts (continued) export interface VonPayEvent { id: string; // vp_evt_…, dedup on this type: string; created: number; // unix seconds livemode: boolean; // ONE endpoint receives both test and live test_event?: true; // ONLY on a synthesized "Send test event" delivery merchant_id: string; data: Record; // narrow per event type, never `any` } /** * `data` is JSON off the wire. Read each field as the type the event catalog says * it is, or null — never cast it. */ const str = (v: unknown): string | null => (typeof v === "string" ? v : null); const num = (v: unknown): number | null => (typeof v === "number" ? v : null); /** * Every id an event carries that names a PAYMENT. Which ones are filled in * depends on how the payment was taken AND on which event it is: * * event session_id payment_intent_id vp_tx_id transaction_id * charge.succeeded, charge.refunded ~ ~ ~ ~ * refund.failed ~ ~ ~ ~ * charge.failed ~ ~ null ~ * payment_intent.* ~ ~ - ~ * dispute.created / .won / .lost - - - ✓ (+ dispute_id) * * ~ present on some payment paths, null on others - not in the payload * * ⛔ NO SINGLE FIELD IS A SAFE KEY FOR "WHICH ORDER IS THIS ABOUT". * - session_id is null on discrete (server-to-server) charges, AND on a refund * made through POST /v1/refunds, even when the charge itself was hosted. * - payment_intent_id is null on hosted-direct and guest embedded charges that * never minted an intent. * - vp_tx_id is not on payment_intent.* at all, and is always null on * charge.failed (a decline writes no settlement row). * - a dispute carries ONLY transaction_id. * An earlier version of this sample keyed the order on "the first non-null of * session_id, payment_intent_id, vp_tx_id". A hosted charge then keyed on * session_id, its API refund (no session_id) keyed on payment_intent_id, the * refund matched no order and was never recorded; and every dispute threw. * * THE RULE THAT WORKS: record every id, match on any. * 1. When you create the payment, record the id WE returned (the session id * from POST /v1/sessions, or the intent id from POST /v1/payment_intents) * against your order: rememberPaymentIds(tenantId, order.id, [id]). * 2. On every event, find the order by ANY id the event carries, then record * the ones you have not seen (orderFor, below). By the time a refund or a * dispute arrives, the charge events have taught you the ids it uses. Match * the VALUES, not the field names: an API refund on some paths carries the * charge's vp_tx_* in `transaction_id`, and that still finds the order. * * `transaction_id` is here as a MATCH key only. It is not one id space (the * processor's reference on most paths, our vp_tx_* on some), so never reconcile * money on it or send it back to us; use vp_tx_id for that. But for one payment * it is the same value on its charge events and on its disputes, and it is the * only payment id a dispute has. * * Back it with one table: UNIQUE (tenant_id, payment_ref) → order_id, written * insert-if-absent. Never re-point an id at a different order: if an event's * ids lead to two different orders, that is a data fault to alert on, not a * tie to break. */ const d = event.data; return [d.session_id, d.payment_intent_id, d.vp_tx_id, d.transaction_id].filter( (v): v is string => typeof v === "string" && v !== "", ); } /** * The order an event is about, learning any new ids on the way. Null when no * recorded id matches; each arm below decides what that means for its event. * * ⛔ THIS NAMES THE ORDER, NOT THE ATTEMPT. One checkout can carry several * payment attempts (a declined card, then a second card that pays) and every * event for every attempt carries the SAME session_id. So a match tells you * which order an event is about; it never tells you the event is the latest * word on it. Deliveries are also retried and can arrive out of order. The * handlers below are therefore MONOTONIC: a decline or a cancellation never * moves an order that is already paid (see the charge.failed arm). */ async function orderFor(tenantId: string, event: VonPayEvent): Promise { const ids = paymentIds(event); if (ids.length === 0) return null; const order = await findOrderByAnyPaymentId(tenantId, ids); if (order) await rememberPaymentIds(tenantId, order.id, ids); return order; } /** * For events that MOVED money: no order is a fault, not a no-op. A throw returns * non-2xx (see the receiver) and we redeliver with the fault visible. The usual * cause is that the charge event which teaches you this event's ids has not been * processed yet, and the retry finds it. Silently acknowledging would leave money * moved on our side and nothing recorded on yours. */ function required(order: Order | null, event: VonPayEvent): Order { if (!order) throw new Error(`no order recorded for ${event.type} ${event.id}`); return order; } export async function handleEvent(tenantId: string, event: VonPayEvent) { // ⛔ A TEST EVENT IS NOT MONEY, AND IT CHECKS FIRST. "Send test event" (your // settings-page button, or the dashboard) delivers a synthetic event marked // `test_event: true`. When the test names a checkout session it carries that // session's REAL `session_id` and `vp_tx_id`, so the order lookup below would // find a real, unpaid order and mark it paid. Acknowledge it (the receiver // returns 2xx) and do nothing else. `livemode: false` is NOT this signal: real // test-key payments carry it too. The field is absent on real events, so // compare with `=== true`. if (event.test_event === true) return; // Dedup on `event.id` (the receiver already did); find the ORDER by the payment // ids. The two are different keys: an orders table keyed on the event id gets // two rows for one payment (charge.* and payment_intent.* both fire for it). // The lookup runs INSIDE the arms that need an order, never before the switch: // several event types carry no payment id at all. switch (event.type) { case "charge.succeeded": case "payment_intent.succeeded": { const order = required(await orderFor(tenantId, event), event); await markPaid(tenantId, order.id, num(event.data.amount), event.livemode); break; } case "charge.failed": case "payment_intent.failed": { // ⛔ A DECLINE NEVER UN-PAYS AN ORDER. The abandoned first card's // charge.failed can be delivered AFTER the second card's charge.succeeded // (retry backoff reorders deliveries), and both find this order. Record the // decline against the attempt; touch the order only while it is still // unpaid. Drive state off the event type (payloads carry no status field), // never off arrival order. No order found: nothing moved, so there is nothing to // put right. Log it and acknowledge. const order = await orderFor(tenantId, event); if (!order) { log.warn({ type: event.type, eventId: event.id }, "vonpay decline for no recorded order"); break; } await markFailedUnlessPaid(tenantId, order.id, { attemptRef: str(event.data.transaction_id), code: str(event.data.failure_code), networkCode: str(event.data.network_decline_code), }); break; } case "charge.refunded": { // Read amount_refunded_total, it is cumulative and unambiguous. // Do NOT sum `amount` yourself: on some connections it is ALREADY a running // total, so summing marks an order fully refunded after the first partial. const order = required(await orderFor(tenantId, event), event); await syncRefundTotal(tenantId, order.id, num(event.data.amount_refunded_total)); break; } case "refund.failed": { // No money went back. Keep it open in your ledger. const order = required(await orderFor(tenantId, event), event); await flagRefundForReview(tenantId, order.id, str(event.data.refund_id)); break; } case "payment_intent.cancelled": { // Same rule as the decline arm: an abandoned attempt's cancellation must not // void an order another attempt has paid. Nothing moved, so no order found // is a no-op, not a fault. const order = await orderFor(tenantId, event); if (order) await markVoidedUnlessPaid(tenantId, order.id); break; } case "dispute.created": case "dispute.won": case "dispute.lost": { // ⛔ A DISPUTE IS RECORDED ON ITS OWN ID FIRST, AND NEVER THROWS FOR A MISSING // ORDER. It carries only dispute_id + transaction_id, it is delivered to every // subscription whether or not you asked for it, and the merchant has a // deadline to respond. transaction_id finds the order through the ids its // charge events taught you. If it finds none, flag it for a human and still // acknowledge: a retry cannot make an order appear that you never recorded, // and a chargeback stuck in a redelivery loop is one nobody answers. const order = await orderFor(tenantId, event); await recordDispute(tenantId, { disputeId: str(event.data.dispute_id), status: event.type.slice("dispute.".length), // "created" | "won" | "lost" transactionId: str(event.data.transaction_id), amount: num(event.data.amount), currency: str(event.data.currency), orderId: order?.id ?? null, }); if (!order) await flagDisputeForReview(tenantId, str(event.data.dispute_id)); break; } default: // Everything else lands here WITHOUT an order lookup: session.*, // payment_intent.authorized, mirror.order.created, payout.*, application.*, // merchant.ready_for_payments. Several carry no payment id at all, and a // lookup that threw would turn each into a delivery retried forever. log.info({ type: event.type }, "unhandled vonpay event"); } // Health signal for step 7. Record VERIFIED deliveries, not arrivals. await recordVerifiedDelivery(tenantId, event.id, event.created); } ``` Order is not guaranteed. Drive state off `type` + `data.status`, never arrival order. ### 4.5 Register the endpoint for the merchant {#s45-register} A merchant can register a webhook endpoint by hand, copy the signing secret, and paste it into your settings page. Every step there is a step they can get wrong, and a wrong one is silent: the connection looks healthy and your platform simply never hears about refunds. ```ts // src/vonpay/webhooks.ts (continued) "charge.succeeded", "charge.failed", "charge.refunded", "refund.failed", "payment_intent.succeeded", "payment_intent.failed", "payment_intent.cancelled", ] as const; export interface WebhookSubscription { id: string; object: "webhook_subscription"; url: string; enabledEvents: string[]; status: "active" | "paused" | "disabled"; lastSuccessAt: string | null; createdAt: string; } `${process.env.PUBLIC_BASE_URL}/hooks/vonpay/${tenantId}`; /** * Idempotent: safe to call on every reconnect. * * Note the request body is camelCase — the webhooks surface follows the session * surface, not the snake_case payment surface. * * `signingSecret` comes back ONCE, on create and on rotate, and never again. If a * subscription already exists we cannot read its secret back, so we rotate to get * a fresh one rather than leaving the tenant with a secret nobody holds. */ export async function ensureWebhook( vp: VonPayClient, tenantId: string, existing: WebhookSubscription[] = [], ): Promise<{ id: string; signingSecret: string }> { const url = webhookUrl(tenantId); const match = existing.find((s) => s.url === url); if (match) { // ⛔ NO Idempotency-Key, deliberately: this endpoint does not honour one. A // key would only make the client RETRY a 502 (see client.ts), and a retried // rotation rotates TWICE, leaving you storing the losing attempt's secret so // every delivery fails to verify. Without a key a failure surfaces instead, // and the next save simply runs ensureWebhook again. const rotated = await vp.request<{ id: string; signingSecret: string }>( "POST", `/v1/webhook_subscriptions/${match.id}/rotate_signing_secret`, { body: {} }, ); return { id: match.id, signingSecret: rotated.signingSecret }; } try { const created = await vp.request( "POST", "/v1/webhook_subscriptions", { body: { url, enabledEvents: [...CONNECTOR_EVENTS], description: `${PLATFORM} connector`, }, // No Idempotency-Key: this endpoint does not honour one. What stops a // second subscription for this URL is the 409 below, not a key. }, ); return { id: created.id, signingSecret: created.signingSecret }; } catch (e) { // Someone created it between our list and our create. Re-read and rotate. if (e instanceof VonPayError && e.code === "webhook_subscription_conflict") { const page = await vp.request<{ data: WebhookSubscription[] }>( "GET", "/v1/webhook_subscriptions?limit=100", ); return ensureWebhook(vp, tenantId, page.data); } throw e; } } /** * Powers the "Send test event" button in your settings page. Synchronous. * * Success is `delivered: true`, NOT a change to any order: the event arrives with * `test_event: true` and the router ignores it by design (see `handleEvent`). */ export async function sendTestEvent(vp: VonPayClient, subscriptionId: string) { return vp.request<{ delivered: boolean; response_status: number | null; delivery_attempt_id: string | null; error: string | null; }>( "POST", `/v1/webhook_subscriptions/${subscriptionId}/send_test_event`, { body: { eventType: "charge.succeeded" } }, ); } ``` **One subscription per merchant — not one per mode.** Subscriptions are mode-agnostic: one endpoint receives both test and live events, and each event carries `livemode`. Branch on that field, not on two endpoints. Register the subscription for the merchant rather than asking them to paste a secret — a connector that is connected but has silently never registered one processes payments and hears nothing, so refunds raised on one side never reverse the card on the other. Keep a manual paste-the-secret path so a merchant is never stuck if registration fails, and build a "have we heard from this merchant lately?" check keyed on **verified** deliveries, not arrivals. --- ## Step 5 — The gateway settings page {#step-5} This is the screen your merchant fills in. It is where most gateway integrations lose merchants, and almost all of the loss is avoidable. The governing idea: **ask for the fewest things that cannot be derived, then prove they work before saying "Connected".** ### 5.1 Fields to collect {#s51-fields} | # | Field | Required | Format | Input | Notes | |---|---|---|---|---|---| | 1 | **Secret key** | Always | `vp_sk_live_…` / `vp_sk_test_…` | Password (masked) | The only credential needed to charge. Store encrypted; never render it back | | 2 | **Publishable key** | Only if you render card fields in your page | `vp_pk_live_…` / `vp_pk_test_…` | Text | Safe in the browser. Must be the **same merchant and same mode** as #1 | | 3 | **Webhook signing secret** | Only if you do *not* auto-register ([§4.5](#s45-register)) | `whsec_…` | Password (masked) | Shown once, at creation, in our dashboard | That is the whole list. Three fields, and two of them are conditional. Where the merchant gets them: **[app.vonpay.com/dashboard/developers/api-keys](https://app.vonpay.com/dashboard/developers/api-keys)**. Deep-link it from the field label — do not make them search. ```ts // src/vonpay/connect.ts export interface GatewaySettingsForm { mode: "test" | "live"; secretKey: string; publishableKey?: string; // Only present when you offer the manual webhook path as a fallback. webhookSigningSecret?: string; } /** Both modes use the same host. This is a constant, never a merchant input. */ ``` ### 5.2 Fields to leave off — and why {#s52-omit} Each of these appears on other gateways' settings pages. On ours, every one is either harmful or dead weight. | Do not add | Why | |---|---| | **Merchant ID / account ID** | Derived from the key. No endpoint accepts a merchant id in the body. A field for it can only ever be wrong or ignored | | **Environment / mode dropdown** *(as a thing we're told)* | We never read a mode from your request. The key determines it. See [§5.3](#s53-test-and-live) for the one legitimate use of a mode control | | **API base URL / endpoint** | `https://checkout.vonpay.com` for both test and live. If we boarded you on a processor sandbox we gave you a specific host — hardcode it per environment in your build, do not ask the merchant | | **Session signing secret (`ss_…`)** | Merchants see one in their dashboard and will try to paste it. It cannot verify anything you receive — the return-URL signature uses a platform-wide key, so a merchant's `ss_…` can never match it. Confirm payment status by calling us back with the API key you already hold | | **Statement descriptor** | Configured per merchant on our side, not per call. Display it read-only from `GET /v1/descriptor_config` and link the merchant to us to change it. A writable field here would silently do nothing | | **Currency** | Comes from the charge. Show the merchant's settlement currencies read-only from `GET /v1/capabilities` | | **"Enable 3-D Secure" toggle** | Not yours to switch. It is decided by the issuer and the merchant's account configuration. What you *do* need is a `return_url` — see [§5.6](#s56-behaviour) | ### 5.3 Test and live {#s53-test-and-live} Mode is carried entirely by the key. Your merchant never tells us which mode they want, and we never read a mode field from the request body. **The pattern that works:** store **two independent credential sets**, and give the merchant one **Mode: Test / Live** control whose only job is choosing *which stored set your platform sends*. ```ts // src/vonpay/connect.ts (continued) /** Two independent credential rows per tenant. Switching mode never overwrites. */ export async function loadCredentials(tenantId: string): Promise { const row = await db.gatewayCredentials.findFirst({ where: { tenantId, provider: "vonpay", active: true }, }); if (!row) return null; return { secretKey: decrypt(row.secretKeyEnc), publishableKey: row.publishableKey ?? undefined, webhookSigningSecret: row.webhookSigningSecretEnc ? decrypt(row.webhookSigningSecretEnc) : undefined, webhookSubscriptionId: row.webhookSubscriptionId ?? undefined, merchantId: row.vonpayMerchantId ?? undefined, }; } /** * Validate the prefix against the SELECTED mode, in the browser AND again on * the server, before any network call. A vp_sk_test_ pasted into the live slot * is a mistake you can catch in 2ms instead of letting a merchant lose a day * to it. * * ⛔ THIS IS THE ONLY CHECK THAT STOPS A LIVE KEY BEING SAVED UNDER "TEST". * testConnection's network steps prove the key is valid and is a SECRET key; * neither can tell you it belongs to the mode the merchant selected, because * we answer both modes' keys on the same host. Skip this and a merchant who * pastes vp_sk_live_ with the Test toggle on sees "Connected · Test" and takes * real money from what they believe are test orders. testConnection (step 1), * the settings component and the certification script all call it. */ export type PrefixVerdict = "ok" | "pasted_publishable_key" | "wrong_mode_key" | "not_a_key"; const trimmed = key.trim(); if (trimmed.startsWith("vp_pk_")) return "pasted_publishable_key"; if (!trimmed.startsWith("vp_sk_")) return "not_a_key"; const expected = mode === "live" ? "vp_sk_live_" : "vp_sk_test_"; return trimmed.startsWith(expected) ? "ok" : "wrong_mode_key"; } /** * Pin the tenant's Von merchant id from OUR API, under THEIR secret key, the * first time they create a session (call it from createCardCaptureSession). * `GET /v1/sessions/{id}` returns `merchantId`, and it only answers for a * session those credentials own, so the value is proven, not trusted. Never * pin from a webhook payload: a delivery is something the network handed * you, a session read is something you asked for with a secret only you hold. * * Idempotent: the UPDATE only fills a NULL, so call it on every session create * and it costs one indexed no-op after the first. * * ⚠ A WRONG PIN IS A SILENT BLACKOUT: every later delivery for the tenant * whose merchant id disagrees is refused with 401, and nothing clears the pin * by itself. If credentials were provisioned for the wrong merchant on day one * the pin is wrong from day one. The reset is to NULL `vonpayMerchantId` on the * tenant's active row; make that a support action your team can run, and alert * on repeated merchant-mismatch refusals so a bad pin is noticed in minutes. */ export async function pinMerchantIdFromSession( tenantId: string, vp: VonPayClient, sessionId: string, ) { const s = await vp.request<{ merchantId: string }>("GET", `/v1/sessions/${sessionId}`); await db.gatewayCredentials.updateMany({ where: { tenantId, provider: "vonpay", active: true, vonpayMerchantId: null }, data: { vonpayMerchantId: s.merchantId }, }); } ``` Show the mode as a persistent badge on **every** payment screen, not just in settings. A merchant who forgets they are in test mode will report missing money. ### 5.4 The "Test connection" button {#s54-test-connection} Do not save credentials you have not proven. Run this sequence on save and on demand — two cheap reads, no writes, no money. ```ts // src/vonpay/connect.ts (continued) export interface Capabilities { supported_operations: { auth_capture_separation: boolean; partial_capture: boolean; partial_refund: boolean; unreferenced_refund: boolean; void_after_capture: "supported" | "rerouted_to_refund" | "not_supported"; mit: boolean; network_tokens: boolean; three_d_secure_2: boolean; ach: boolean; payouts_api: boolean; dispute_reporting: "tracked" | "not_tracked"; }; settlement_currencies: string[]; rate_limits: { payment_intents_per_minute?: number }; } export type ConnectionProbe = | { ok: true; capabilities: Capabilities; subscriptions: WebhookSubscription[] } | { ok: false; code: string; requestId?: string | null }; export async function testConnection( form: GatewaySettingsForm, ): Promise { // ── Step 1 ── format, before any network call. const verdict = checkSecretKeyPrefix(form.secretKey, form.mode); if (verdict !== "ok") return { ok: false, code: verdict }; const vp = new VonPayClient({ secretKey: form.secretKey.trim() }, VONPAY_HOST); // ── Step 2 ── the key authenticates AND the merchant has a working configuration. // 422 merchant_not_configured means their account isn't finished // on our side — that is a different message from a bad key. let capabilities: Capabilities; try { capabilities = await vp.request("GET", "/v1/capabilities"); } catch (e) { if (e instanceof VonPayError) return { ok: false, code: e.code, requestId: e.requestId }; throw e; } // ── Step 3 ── ...and it is genuinely a SECRET key. // // NOT OPTIONAL. GET /v1/capabilities accepts EITHER key type, so a merchant who // pastes their publishable key into the secret field passes step 2 cleanly — and // then every charge fails in production. This endpoint is secret-key-only and // returns 403 auth_key_type_forbidden on a publishable key, so it is what // actually proves you hold the right one. It also returns the existing // subscriptions we need in order to reconcile the webhook. let subscriptions: WebhookSubscription[]; try { const page = await vp.request<{ data: WebhookSubscription[] }>( "GET", "/v1/webhook_subscriptions?limit=100", ); subscriptions = page.data; } catch (e) { if (e instanceof VonPayError) return { ok: false, code: e.code, requestId: e.requestId }; throw e; } return { ok: true, capabilities, subscriptions }; } ``` :::danger Step 3 is the non-obvious part `GET /v1/capabilities` accepts **either** key type. A connection test built on it alone passes when a merchant pastes their publishable key into the secret field — they see a green tick and discover the problem on their first live charge. The second, secret-key-only call is not optional. ::: Two rules to design against: a credential can pass a connection test and still fail at money time, so check capabilities and not just authentication; and a stored probe result nobody reads is not a check — read it before you show a green badge. ### 5.5 Save, and let the account drive the UI {#s55-save} Persist only after the probe passes, then register the webhook in the same transaction of work, so a merchant can never end up connected-but-deaf. ```ts // src/vonpay/connect.ts (continued) export type SaveResult = | { ok: true; capabilities: Capabilities; webhookVerified: boolean } | { ok: false; message: string; requestId?: string | null }; export async function saveGatewaySettings( tenantId: string, form: GatewaySettingsForm, ): Promise { const probe = await testConnection(form); if (!probe.ok) { return { ok: false, message: MERCHANT_COPY[probe.code] ?? MERCHANT_COPY.default, requestId: probe.requestId, }; } const vp = new VonPayClient({ secretKey: form.secretKey.trim() }, VONPAY_HOST); // Register (or rotate) the webhook BEFORE we mark the connection healthy. const hook = form.webhookSigningSecret ? { id: null, signingSecret: form.webhookSigningSecret } // manual fallback : await ensureWebhook(vp, tenantId, probe.subscriptions); await db.gatewayCredentials.upsert({ where: { tenantId_provider_mode: { tenantId, provider: "vonpay", mode: form.mode } }, update: { secretKeyEnc: encrypt(form.secretKey.trim()), publishableKey: form.publishableKey?.trim() ?? null, webhookSubscriptionId: hook.id, webhookSigningSecretEnc: encrypt(hook.signingSecret), capabilities: probe.capabilities, // cached for the UI; re-read on open active: true, }, create: { /* …same fields… */ }, }); // Make the other mode inactive without deleting it. await db.gatewayCredentials.updateMany({ where: { tenantId, provider: "vonpay", mode: { not: form.mode } }, data: { active: false }, }); // Prove the round trip end to end, so "Connected" means something. let webhookVerified = false; if (hook.id) { const probeResult = await sendTestEvent(vp, hook.id); webhookVerified = probeResult.delivered && probeResult.response_status === 200; } return { ok: true, capabilities: probe.capabilities, webhookVerified }; } export async function disconnect(tenantId: string) { const creds = await loadCredentials(tenantId); if (creds?.webhookSubscriptionId) { const vp = new VonPayClient(creds, VONPAY_HOST); try { await vp.request("DELETE", `/v1/webhook_subscriptions/${creds.webhookSubscriptionId}`); } catch (e) { // Already gone is a success for our purposes. if (!(e instanceof VonPayError && e.code === "webhook_subscription_not_found")) throw e; } } await db.gatewayCredentials.deleteMany({ where: { tenantId, provider: "vonpay" } }); } ``` `GET /v1/capabilities` returns exactly what this merchant's connection can do. Use it to decide what to render — never a config file, never a hardcoded list. ```json { "supported_operations": { "auth_capture_separation": true, "partial_capture": true, "partial_refund": true, "unreferenced_refund": false, "void_after_capture": "not_supported", "mit": true, "network_tokens": true, "three_d_secure_2": true, "ach": false, "payouts_api": true, "dispute_reporting": "not_tracked" }, "settlement_currencies": ["USD", "EUR", "GBP", "CAD", "AUD"], "rate_limits": { "payment_intents_per_minute": 300 } } ``` | Capability | What your settings page should do | |---|---| | `auth_capture_separation: false` | Hide or disable the "Authorize only" capture option entirely | | `mit: false` | Hide "Save cards for future billing". Offering it would create tokens that can never be charged | | `partial_refund: false` | Refund UI becomes full-amount only | | `void_after_capture: "not_supported"` | After capture, offer Refund — never Void | | `ach: false` | Do not offer bank debit | | **`dispute_reporting: "not_tracked"`** | **Show a warning banner.** On this connection a chargeback never reaches us, so your record of the payment keeps reading "succeeded" no matter what happens at the card network. The merchant must watch their processor's own dashboard and respond there. Merchants have lost disputes by default because nobody told them | | `settlement_currencies` | Populate the currency display; do not hardcode a list | Also read `GET /v1/descriptor_config` and render a **statement-descriptor preview** — "Your customers will see `ACME* ORDER-1234` on their statement." It is read-only, it costs you one call, and it heads off a whole class of chargebacks caused by an unrecognised descriptor. ```ts // src/vonpay/connect.ts (continued) export async function loadDescriptorPreview(vp: VonPayClient) { const { descriptor_config } = await vp.request<{ descriptor_config: { name: string; dynamic_suffix?: { source: string } } | null; }>("GET", "/v1/descriptor_config"); if (!descriptor_config) return null; // connector default applies const tail = descriptor_config.dynamic_suffix?.source === "order_id" ? "ORDER-1234" : ""; // The whole thing — brand, separator and tail — is capped at 22 characters. return `${descriptor_config.name}${tail ? `* ${tail}` : ""}`.slice(0, 22); } ``` ### 5.6 Behaviour settings {#s56-behaviour} Keep the list short. Every toggle is a support ticket. | Setting | Default | Maps to | |---|---|---| | **Capture** — *Authorize and capture* / *Authorize only* | Authorize and capture | `capture_method: "automatic"` / `"manual"`. Only show if `auth_capture_separation` is true | | **Save cards for future billing** | Off | Turns on `setup_for_future_use: "off_session"` + your consent checkbox. Only show if `mit` is true | | **Billing arrangement** *(appears when saving is on)* | — | `storedCredentialUse`: *Subscription* → `recurring`, *Fixed instalments* → `installment`, *Charge when they order* → `unscheduled`. Explain in the merchant's words; the value is a declaration to the card networks and it cannot be changed for cards already saved | | **Collect billing address** | On | Populates `billing_address`, which lets the processor run address verification. Improves approval rates | | **3-D Secure return URL** | *(you set this, not the merchant)* | `return_url` on the charge. Without it a challenge-required payment is rejected outright | | **Webhook endpoint** | *(you set this)* | Show it read-only with a **Send test event** button and the timestamp of the last verified delivery | ```ts // src/vonpay/connect.ts (continued) export interface GatewayBehaviour { captureMode: "automatic" | "manual"; saveCards: boolean; arrangement: BillingArrangement; collectBillingAddress: boolean; } captureMode: "automatic", saveCards: false, arrangement: "unscheduled", collectBillingAddress: true, }; /** * Never persist a setting the account cannot honour — a stored `manual` on an * account without auth/capture separation fails at money time, not at save time. */ b: GatewayBehaviour, caps: Capabilities, ): GatewayBehaviour { return { ...b, captureMode: caps.supported_operations.auth_capture_separation ? b.captureMode : "automatic", saveCards: caps.supported_operations.mit ? b.saveCards : false, }; } ``` ### 5.7 Error messages the merchant can act on {#s57-copy} Map our `code` to copy that names the fix. Never show them a raw JSON body. ```ts // src/vonpay/copy.ts // Local prefix checks — caught before any network call. pasted_publishable_key: "That looks like your publishable key. Paste the secret key — it starts vp_sk_.", // Fires in BOTH directions, so the copy names both. A live key under the Test // toggle is the dangerous one: it would take real money from "test" orders. wrong_mode_key: "That key is for the other mode. Test mode needs a key starting vp_sk_test_, live mode one starting vp_sk_live_.", not_a_key: "That doesn't look like a Von Payments key. Secret keys start vp_sk_.", // Auth auth_invalid_key: "We didn't recognise that key. Copy the whole key from your Von Payments dashboard — it should start vp_sk_.", auth_key_type_forbidden: "That looks like your publishable key. Paste the secret key, which starts vp_sk_.", auth_key_expired: "This key has been rotated or has expired. Create a new one in your Von Payments dashboard.", auth_merchant_inactive: "This Von Payments account is currently disabled. Contact Von Payments support.", // Account state merchant_not_configured: "Payment routing isn't set up on this Von Payments account yet. Contact Von Payments.", // Runtime // A charge against a saved card that is not this customer's, not on this // account, or saved in the other mode — all answered as not found. payment_method_not_found: "We couldn't find that saved card for this customer. Ask them to enter their card again.", payment_method_mode_mismatch: "This saved card belongs to your other environment. Switch modes, or save the card again in this one.", payment_method_consent_missing: "This card wasn't saved with permission for automatic charges. Ask the customer to re-enter it.", provider_unavailable: "The payment network is temporarily unavailable. We'll retry automatically.", endpoint_not_implemented: "This Von Payments account isn't activated for this operation yet. Contact Von Payments.", default: "We couldn't reach Von Payments with that key. Check the key and try again.", }; ``` `rate_limit_exceeded` is deliberately absent — never show it. Back off per the `Retry-After` header and retry (the client in [§2.2](#s22-http-client) already does). Log `X-Request-Id` against the merchant on every failure and surface it in your own support view. It is the fastest way for us to answer a question about their account. ### 5.8 The screen {#s58-screen} ```ts // src/settings/GatewaySettings.tsx const saved = tenant.vonpay; const [mode, setMode] = useState<"test" | "live">(saved?.mode ?? "test"); const [secretKey, setSecretKey] = useState(""); const [publishableKey, setPublishableKey] = useState(saved?.publishableKey ?? ""); const [status, setStatus] = useState<"idle" | "testing" | "saving">("idle"); const [error, setError] = useState(null); const [caps, setCaps] = useState(saved?.capabilities ?? null); const ops = caps?.supported_operations; // Capability-driven: never render a control the account cannot honour. const canAuthorizeOnly = ops?.auth_capture_separation === true; const canSaveCards = ops?.mit === true; const disputesUntracked = ops?.dispute_reporting === "not_tracked"; // The secret key is never re-rendered. An empty field on an already-connected // account means "keep the stored key"; typing replaces it. const secretPlaceholder = saved ? "•".repeat(24) : "vp_sk_live_…"; async function submit(action: "test" | "save") { setError(null); if (secretKey) { const verdict = checkSecretKeyPrefix(secretKey, mode); if (verdict !== "ok") return setError(MERCHANT_COPY[verdict]); } setStatus(action === "test" ? "testing" : "saving"); const res = await fetch(`/api/tenants/${tenant.id}/gateways/vonpay`, { method: action === "test" ? "PUT" : "POST", // PUT = probe only, POST = persist headers: { "Content-Type": "application/json" }, body: JSON.stringify({ mode, secretKey: secretKey || undefined, publishableKey }), }).then((r) => r.json()); setStatus("idle"); if (!res.ok) return setError(res.message); setCaps(res.capabilities); setSecretKey(""); // never keep it in component state } return (

Von Payments

{saved && ( {saved.connected ? "Connected" : "Not connected"} · {mode === "live" ? "Live" : "Test"} )}
Mode

Test mode uses your test keys. No real money moves.

setSecretKey(e.target.value)} />

Starts vp_sk_. Server-side only.{" "} Get your keys ↗

setPublishableKey(e.target.value)} />

Starts vp_pk_. Safe in the browser.

{error &&

{error}

}
{caps && (

✓ Key verified — secret key, {mode} mode

✓ Webhook registered {saved?.lastVerifiedDeliveryAt && ` · last verified delivery ${timeAgo(saved.lastVerifiedDeliveryAt)}`}

Currencies {caps.settlement_currencies.join(" ")}

{saved?.descriptorPreview &&

Statement {saved.descriptorPreview}

} {disputesUntracked && (

⚠ Chargebacks on this connection are not reported back to Von Payments. Monitor disputes in your processor dashboard.

)}
)} {caps && (
Payment behaviour {/* Hidden entirely when the account can't separate auth from capture — showing a control that always fails is worse than not offering it. */} {canAuthorizeOnly && ( <> )} {canSaveCards && ( <>
Billing arrangement

Can’t be changed for cards already saved.

)}
)}
); } ``` Rendered, that is: ```text ┌─ Von Payments ──────────────────────────── ● Connected · Live ─┐ │ │ │ Mode ( ) Test (•) Live │ │ Test mode uses test keys. No real money moves. │ │ │ │ Secret key [ •••••••••••••••••••••••• ] ↗ Get keys │ │ Publishable key [ vp_pk_live_a1b2c3d4e5 ] │ │ │ │ [ Test connection ] [ Save ] │ ├────────────────────────────────────────────────────────────────┤ │ ✓ Key verified ✓ Webhook registered · verified 4m ago│ │ Currencies USD EUR GBP CAD AUD │ │ Statement ACME* ORDER-1234 │ │ │ │ ⚠ Chargebacks on this connection are not reported back to │ │ Von Payments. Monitor disputes in your processor dashboard. │ ├────────────────────────────────────────────────────────────────┤ │ Payment behaviour │ │ Capture (•) Authorize and capture ( ) Authorize only │ │ Save cards [x] Let customers save cards for future orders│ │ Arrangement ( ) Subscription (•) Charge when they order │ │ Billing address [x] Collect at checkout (improves approvals) │ └────────────────────────────────────────────────────────────────┘ ``` The status strip under the credentials is the part people skip. It is what turns "I saved it, I think it worked" into "I can see it worked." ### 5.9 Anti-pattern checklist {#s59-checklist} - [ ] The secret key is never re-displayed after save — masked, replaceable, never readable - [ ] Test and live credentials are stored separately; switching mode does not overwrite - [ ] No merchant-ID field, no base-URL field, no `ss_…` field - [ ] Saving without a successful connection test is not possible - [ ] The connection test calls a **secret-key-only** endpoint, not just `capabilities` - [ ] Exactly one webhook subscription per merchant — not one per mode - [ ] Retries reuse the original `Idempotency-Key`; nothing appends an attempt counter - [ ] Refund handling branches on `status`, and a `202` is not treated as a failure - [ ] Refund totals read `amount_refunded_total`; nothing sums `amount` - [ ] `setup_for_future_use` is sent when the card is **saved**, never on the charge - [ ] Toggles the merchant's account cannot support are hidden, not shown-and-failing - [ ] `dispute_reporting: "not_tracked"` surfaces a visible warning - [ ] Disconnecting deletes the webhook subscription and wipes stored credentials --- ## Step 6 — Certification {#step-6} Before we list you, show us these run with the test keys from Step 0. A screen recording or a written log with request ids is fine. On that sandbox the order total decides a decline, so run the declined-sale case at a declining total from [Test mode](../reference/test-cards.md#processor-test-account), such as `200012`; an ordinary total approves. ```ts // scripts/certify.ts — run with a vp_sk_test_ key. No real money is involved. const vp = new VonPayClient({ secretKey: process.env.VP_TEST_KEY! }); const gw = new VonPayGateway(vp); const uniq = () => `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; /** Shared charge fields. Swap in a vp_pmt_ token vaulted in your own sandbox. */ const base = () => ({ currency: "USD", paymentMethodId: process.env.VP_TEST_PAYMENT_METHOD!, // vp_pmt_test_… buyerId: "cert-buyer-1", returnUrl: "https://example.com/3ds/return", }); const checks: Array<[string, () => Promise]> = [ // ── Credentials ─────────────────────────────────────────────── ["01 valid secret key connects", async () => { const p = await testConnection({ mode: "test", secretKey: process.env.VP_TEST_KEY! }); assert.equal(p.ok, true); }], ["02 publishable key in the secret field is rejected", async () => { assert.equal( checkSecretKeyPrefix(process.env.VP_TEST_PUBLISHABLE_KEY!, "test"), "pasted_publishable_key", ); // …and it is rejected on the wire too, not just locally. const p = await testConnection({ mode: "test", secretKey: process.env.VP_TEST_PUBLISHABLE_KEY!, }); assert.equal(p.ok, false); }], ["03 a live-mode key is rejected in test mode", async () => { assert.equal(checkSecretKeyPrefix("vp_sk_live_xxxxxxxx", "test"), "wrong_mode_key"); }], // ── Payments ────────────────────────────────────────────────── ["05 sale approved", async () => { const i = await gw.sale({ ...base(), orderId: uniq(), amountMinor: 1499 }); assert.equal(i.status, "succeeded"); }], ["06 sale declined surfaces a decline, not an exception", async () => { // 200012 = a total the processor test environment declines ("declined by the // card's bank"); see docs.vonpay.com/reference/test-cards. const i = await gw.sale({ ...base(), orderId: uniq(), amountMinor: 200012 }); assert.equal(i.status, "failed"); assert.ok(i.decline_code, "a declined charge must carry a decline_code"); }], ["07 authorize then capture in full", async () => { const order = uniq(); const auth = await gw.authorize({ ...base(), orderId: order, amountMinor: 5000 }); assert.equal(auth.status, "authorized"); const cap = await gw.capture(order, auth.id); assert.equal(cap.status, "succeeded"); }], ["08 authorize then capture partially", async () => { const order = uniq(); const auth = await gw.authorize({ ...base(), orderId: order, amountMinor: 5000 }); const cap = await gw.capture(order, auth.id, 2500); assert.equal(cap.status, "succeeded"); }], ["09 authorize then void", async () => { const order = uniq(); const auth = await gw.authorize({ ...base(), orderId: order, amountMinor: 5000 }); const v = await gw.void(order, auth.id); assert.equal(v.status, "voided"); }], ["10 full refund, then a partial second refund does not double-count", async () => { const order = uniq(); const sale = await gw.sale({ ...base(), orderId: order, amountMinor: 5000 }); const first = await gw.refund(order, sale.id, { amountMinor: 2000 }); if (first.kind !== "refunded" && first.kind !== "pending") assert.fail(`first refund: ${first.kind}`); const second = await gw.refund(order, sale.id, { amountMinor: 1000, refundRef: "refund-2" }); if (second.kind !== "refunded" && second.kind !== "pending") assert.fail(`second refund: ${second.kind}`); assert.notEqual(first.refundId, second.refundId, "a distinct key must mint a distinct refund"); }], ["11 a retried charge with the same key produces exactly one charge", async () => { const order = uniq(); const input = { ...base(), orderId: order, amountMinor: 1499 }; // Sequential, because this is what a timeout retry actually looks like. // (Two TRULY concurrent sends can return 409 charge_in_progress to the loser. // That is NOT retryable — the original may already have charged — so don't // assert on the concurrent shape here. Assert on the outcome that matters, // which is that only one intent exists.) const first = await gw.sale(input); const replay = await gw.sale(input); assert.equal(first.id, replay.id, "same Idempotency-Key must return the original intent"); }], ["11b a refund replayed on the same key is reported as a replay", async () => { const order = uniq(); const sale = await gw.sale({ ...base(), orderId: order, amountMinor: 3000 }); await gw.refund(order, sale.id, { amountMinor: 1000 }); const again = await gw.refund(order, sale.id, { amountMinor: 1000 }); assert.equal(again.kind, "replay", "an identical refund must NOT move money twice"); }], // ── Saved cards (only if you offer recurring) ───────────────── ["13 a saved card charges off-session with the mit block", async () => { /* requires a vp_pmt_ token vaulted with setup_for_future_use: "off_session" */ }], ["14 a card saved WITHOUT consent surfaces an actionable message", async () => { /* expect VonPayError code === "payment_method_consent_missing" */ }], ]; for (const [name, fn] of checks) { try { await fn(); console.log(` PASS ${name}`); } catch (e) { console.error(` FAIL ${name}\n ${(e as Error).message}`); process.exitCode = 1; } } ``` The webhook checks run against your own receiver rather than ours: ```ts // 16 — signature verification rejects a tampered body const raw = JSON.stringify({ id: "vp_evt_test_1", type: "charge.succeeded", data: {} }); const t = Math.floor(Date.now() / 1000); const good = crypto.createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex"); assert.equal(verifySignature(raw, `t=${t},v1=${good}`, SECRET), true); assert.equal(verifySignature(raw + " ", `t=${t},v1=${good}`, SECRET), false); // body changed assert.equal(verifySignature(raw, `t=${t - 400},v1=${good}`, SECRET), false); // too old assert.equal(verifySignature(raw, `t=${t},v1=deadbeef`, SECRET), false); // bad hmac // 17 — a duplicate event id is processed exactly once await postToOwnReceiver(raw, t, good); await postToOwnReceiver(raw, t, good); assert.equal(await countSideEffects("vp_evt_test_1"), 1); ``` **The full list we want evidence for** | # | Check | |---|---| | 1–4 | Valid key connects · publishable key rejected with a fix message · wrong-mode key rejected before save · disconnect removes credentials **and** the webhook subscription | | 5–11 | Sale approved · sale declined · auth→capture full · auth→capture partial · auth→void · refund full and partial without double-counting · retry with the same key yields one charge | | 12–14 | Card saved with consent returns `setup_for_future_use` · saved card charges with the `mit` block · a card saved without consent surfaces `payment_method_consent_missing` as an actionable message | | 15–18 | Subscription auto-registered on connect · signature verification rejects a tampered body · duplicate `id` processed once · *Send test event* reports the real delivery outcome | | 19–20 | A `429` backs off and retries rather than failing the payment · a `501` surfaces as a provisioning message, not a crash | --- ## Step 7 — Go live {#step-7} 1. **The merchant gets live keys from us, not from you.** Live keys require an approved application — KYC and contract. Requesting them early returns `403 merchant_not_onboarded`. Your settings page should link to onboarding when it sees that code. 2. **Roll out in stages.** First merchant, then a small cohort, then general availability. Do not enable the gateway option for your whole merchant base in one step. 3. **Monitor.** Two checks earn their keep: ```ts // Liveness — unauthenticated, cheap, safe to poll. // Any non-200 means "do not route traffic". Both failure shapes are 503 and only // one carries `reason`, so branch on the status code, not the body. const res = await fetch("https://checkout.vonpay.com/api/health"); if (!res.ok) await pauseVonPayRouting(); ``` ```sql -- Webhook silence, per merchant. THIS is the check that would have caught the -- incident in §4.5. Note it keys on VERIFIED deliveries: a receiver that returns -- 200 to everything cannot tell you whether what arrived was real. SELECT t.id, t.name, max(d.verified_at) AS last_verified FROM tenants t JOIN vonpay_charges c ON c.tenant_id = t.id AND c.created_at > now() - interval '24 hours' LEFT JOIN vonpay_webhook_deliveries d ON d.tenant_id = t.id AND d.verified GROUP BY t.id, t.name HAVING max(d.verified_at) IS NULL OR max(d.verified_at) < now() - interval '6 hours'; ``` Also alert on a run of `501` or `422 merchant_not_configured` for one merchant — that is provisioning drift on our side, and we would rather hear it from you than from them. 4. **Support.** Send us the `X-Request-Id`, the merchant, and the timestamp. That is enough for us to answer almost anything. 5. **Getting into your gateway dropdown is a commercial conversation, not a technical one.** There is no developer-portal review or app-store gate on our side. Talk to your Von Payments contact. Checklist: [Go live](../guides/go-live-checklist.md). --- ## Appendix A — Endpoint quick reference {#appendix-a} | Purpose | Call | Auth | |---|---|---| | Capability matrix | `GET /v1/capabilities` | Any key | | Prove a secret key | `GET /v1/webhook_subscriptions` | Secret | | Statement descriptor | `GET /v1/descriptor_config` | Secret | | Validate without creating | `POST /v1/sessions?dry_run=true` | Secret or publishable | | Create hosted session | `POST /v1/sessions` | Secret or publishable | | Session status | `GET /v1/sessions/{id}` | Secret | | Charge / authorize | `POST /v1/payment_intents` | Secret | | Retrieve a charge | `GET /v1/payment_intents/{id}` | Secret | | Capture | `POST /v1/payment_intents/{id}/capture` | Secret | | Void | `POST /v1/payment_intents/{id}/void` | Secret | | Refund | `POST /v1/refunds` | Secret | | Vault a card (browser) | `POST /v1/public/tokens` | Publishable | | Vault a card (server) | `POST /v1/tokens` | Secret | | List a customer's cards | `GET /v1/payment_methods?buyer_id=` | Secret | | Revoke a card | `DELETE /v1/tokens/{id}` | Secret | | Register webhook | `POST /v1/webhook_subscriptions` | Secret | | Test a webhook | `POST /v1/webhook_subscriptions/{id}/send_test_event` | Secret | | Health | `GET /api/health` | None | | Machine discovery | `GET /.well-known/vonpay.json` | None | **Rate limits** (sliding 60-second windows; ask us to lift them per merchant): payments and refunds 300/min per secret key (100/min per IP without one); session creates 30/min per key, plus 10/min per IP with a publishable key or 300/min per IP with a secret key; webhook reads 100/min per key, writes 30/min per key. Full detail: [Rate limits](../reference/rate-limits.md). --- ## Appendix B — Questions to raise with us before you build {#appendix-b} 1. **Which integration shape are our merchants' accounts provisioned for?** Embedded fields availability is per-merchant. Hosted checkout works everywhere. 2. **Is the discrete payment-intents lifecycle activated for the connections our merchants are on?** If not, they get `501` and we need to sequence activation with your rollout. 3. **Does card-on-file at irregular intervals work on these connections?** See [§3.4](#s34-charging-saved). If your merchants need it, we need to confirm it before you promise it. 4. **Does `dispute_reporting` read `tracked` for these merchants?** If not, agree who tells the merchant and how. 5. **Should the connector auto-register webhooks?** We recommend yes; confirm it works end to end against your sandbox account first. 6. **What is the exact consent-element API for embedded fields?** See the note in [§3.3](#s33-collecting) — confirm it before building recurring on top of it. 7. **Which host?** Production for your sandbox; a separate host if we board you onto a processor sandbox. --- ## Related {#related} - [Integrate VORA as a payment gateway](./index.md) — the API spec - [Choose your integration](../integration-paths.md) - [Platform integrator sandbox](../guides/platform-sandbox.md) - [Reference adapter (Node, MIT)](https://github.com/Von-Payments/vonpay-samples/tree/main/platform-integrator-nextjs) — multi-tenant: per-merchant credential lookup, tenant-scoped sessions, single webhook endpoint routing by merchant - [Recurring and saved cards](../guides/recurring-and-saved-cards.md) - [Embedded fields](../embedded-fields/quickstart.md) · [Elements mode](../embedded-fields/elements.md) - [Webhook events](../integration/webhook-events.md) · [Webhook verification](../integration/webhook-verification.md) - [Error codes](../reference/error-codes.md) · [API keys](../reference/api-keys.md) · [Rate limits](../reference/rate-limits.md) - [REST API](../sdks/rest-api.md) · [OpenAPI spec](https://checkout.vonpay.com/openapi.yaml) Our Node and Python SDKs cover sessions, payment intents (create, capture, void), refunds, tokens and buyers, and list/retrieve webhook subscriptions; registering a subscription, rotating its secret, sending a test event and reading a payment intent back are REST calls — which is why §4.5 above registers with `vp.request`. The samples above hand-roll the client from the OpenAPI spec; the reference adapter uses `@vonpay/checkout-node` directly. --- ## Overview Source: https://docs.vonpay.com/platforms # Integrate VORA as a Payment Gateway A one-page reference for platform engineering teams building a Von Payments connector inside their product — the API surface mapped against the gateway-adapter shape your platform already uses for other third-party gateways. ## What VORA is VORA exposes two front-end integration paths (Hosted Checkout and Embedded Fields — the latter rendering in `embed` or `elements` mode) that both settle through **Payment Intents**, the server-side engine — see [Choose your integration](../integration-paths.md) for the side-by-side comparison. For most platform connectors, **Payment Intents is the right integration** because its discrete `auth → capture → void → refund` lifecycle maps 1:1 with the gateway-adapter contracts most platforms already have. The rest of this page assumes Payment Intents as your primary integration; the Sessions and Embedded Fields surfaces are covered where they intersect (3DS, webhooks). ### Adapter contract mapping If your platform's adapter contract has discrete auth/capture/void/refund methods, **Payment Intents maps directly**: - **Auth-only** → `POST /v1/payment_intents` with `capture_method: "manual"` → intent reaches `authorized` - **Capture** → `POST /v1/payment_intents/{id}/capture` (full or partial via `amount_to_capture`) → intent reaches `succeeded` - **Auth + Capture** (one-step charge) → `POST /v1/payment_intents` with `capture_method: "automatic"` → intent reaches `succeeded` - **Void** → `POST /v1/payment_intents/{id}/void` (only before capture) → intent reaches `voided` - **Refund** → `POST /v1/refunds` (full or partial via `amount`) → **four 2xx shapes, only one of which moved money**: `201`+`succeeded` (money moved), `202`+`requested` (accepted, still in progress), `200`+`canceled` (**voided before it left — buyer NOT repaid**), `200`+`idempotent:true` (a replay). **Branch on `status`, not the code — a `202` is not a failure and retrying it refunds the buyer twice.** See [Refunds](../reference/refunds.md#response--refund). If you'd rather use Hosted Checkout, the mapping collapses to a single call: - **Auth + Capture** → one `POST /v1/sessions` call. Outcome arrives via webhook + signed redirect. - **Void** → not applicable. Sessions that don't complete transition to `expired` or `failed`. - **Refund** → initiated from the merchant's dashboard (or upstream); delivered to your webhook as a `charge.refunded` event. ## API surface — what your connector calls ### Base URL - **Production:** `https://checkout.vonpay.com` - **Sandbox:** same base URL; the key prefix (`vp_sk_test_*` vs `vp_sk_live_*`) selects the environment. ### Headers **Required:** - `Authorization: Bearer ` — the merchant's secret API key - `Content-Type: application/json` **Strongly recommended (server accepts requests without them, but you should always send):** - `Von-Pay-Version: 2026-04-14` — pin the API version. Omitting this header tracks the latest stable, which can change behind you. - `Idempotency-Key: ` — send one on every `POST`; required on some refund and payment routes, see [Idempotency](#idempotency). ### Endpoints | Verb | Path | Purpose | |---|---|---| | **Sessions** (Hosted Checkout) ||| | `POST` | `/v1/sessions` | Create a checkout session. Returns `id`, `checkoutUrl`, `expiresAt` (30-min TTL) | | `GET` | `/v1/sessions/{id}` | Retrieve session status — pending / processing / succeeded / failed / expired | | **Payment Intents** (discrete lifecycle) ||| | `POST` | `/v1/payment_intents` | Create an intent with `capture_method: "automatic"` (auth+capture) or `"manual"` (auth-only) | | `GET` | `/v1/payment_intents/{id}` | Retrieve an intent's persisted state. Use it as a polling fallback when a webhook may have been missed — the outcome is not webhook-only | | `POST` | `/v1/payment_intents/{id}/capture` | Capture an authorized intent (full or partial via `amount_to_capture`) | | `POST` | `/v1/payment_intents/{id}/void` | Void an authorized intent (before capture) | | `POST` | `/v1/refunds` | Create a refund (full or partial via `amount`); references the intent via `payment_intent` | | `POST` | `/v1/tokens` | Mint a `vp_pmt_*` payment-method token from an iframe-vault handle | | **Capability discovery** ||| | `GET` | `/v1/capabilities` | Per-merchant capability matrix — `supported_operations`, `settlement_currencies`, `rate_limits` (snake_case). Read once at startup; gate optional operations on this. | | **Public (publishable-key) endpoints** — used by Embedded Fields browser SDK ||| | `GET` | `/v1/public/sessions/{id}` | Session lookup with a publishable key (browser-safe) | | `POST` | `/v1/public/binder-load` | Per-processor element capability map | | `POST` | `/v1/public/tokens` | Browser-side `vp_pmt_*` minting | | **Operational** ||| | `GET` | `/api/health` | Liveness probe. No auth required. A plain call answers **200** `status: "ok"`, or **503** `status: "error"` when the service takes itself out of rotation — that branch alone carries `reason` and `detail`. Add `?deep=true` to also probe dependencies: **200** `"healthy"`, or **503** `"degraded"` with a `checks` map and no `reason`. **Both failure shapes are a 503 and only one has `reason`** — branch on the status code, and treat any non-200 as "do not route traffic". | | `GET` | `/.well-known/vonpay.json` | Discovery metadata. No auth required. | The full request/response shapes are in the [REST API reference](../sdks/rest-api.md) (downloadable OpenAPI spec). For a typed SDK surface, see the [Node](../sdks/node-sdk.md) and [Python](../sdks/python-sdk.md) SDKs — your adapter doesn't need to use them, but they're a working reference for request shapes. ### Webhook events your endpoint receives Configured by the merchant in their dashboard — they register one or more endpoint URLs at `/dashboard/developers/webhooks` and subscribe each to the event types your platform needs. The events below are the ones a gateway adapter normally maps; the complete list and field-by-field payloads are on [Webhook Events](../integration/webhook-events.md). | Event | When fired | Maps to | |---|---|---| | **Charges** ||| | `charge.succeeded` | Buyer completed a hosted-checkout payment OR a direct `/v1/payment_intents` charge succeeded | adapter `sale` / `capture` success | | `charge.failed` | Charge attempt failed | adapter `sale` / `capture` decline | | `charge.refunded` | A refund settled against a prior charge (full or partial; fires once per refund) | adapter `refund` success | | `refund.failed` | A refund did **not** complete — **no money went back to the buyer.** The failure twin of `charge.refunded`; mirrors its payload so one mapping handles both. It is delivered to every active subscription, whether or not you selected it, but not every processor connection emits it. Treat an unresolved refund as still open in **your own ledger** rather than waiting for this event — see [Refunds](../reference/refunds.md#status). | adapter `refund` failure | | **Payment Intents** ||| | `payment_intent.succeeded` | Intent reached `succeeded` (auto-capture, manual capture, or post-3DS settle) | adapter `auth`+`capture` settle | | `payment_intent.failed` | Intent reached `failed` | adapter `auth` decline | | `payment_intent.cancelled` | Intent was voided before capture (the object's `status` field reports `voided`; the event name is `cancelled` — same terminal state) | adapter `void` | Every event is wrapped in the envelope `{id, type, created, livemode, merchant_id, data}`. A payment's events name it with up to three ids in `data`: `session_id`, `payment_intent_id` and `vp_tx_id`. Which are null depends on how the payment was taken and on the event (a refund made through the API carries no `session_id`), so store every id the payment's first event carries against the order and match later events on any of them. Dispute events carry none of the three; match them on `transaction_id`, which the charge events also carry. One checkout's attempts share a `session_id`, and deliveries can arrive out of order, so a decline or cancellation must never move an order that is already paid. ⚠️ **On the refund events, read `amount_refunded_total` for how much of the charge has been returned in total.** Do not accumulate `amount` yourself: what it means varies by processor connection — cumulative on some, per-refund on others — so summing it marks an order fully refunded early on the connections where it is already a running total. See [Refunds](../reference/refunds.md#status). There is no `session.expired` / "buyer abandoned" event. For abandoned-cart detection, poll `GET /v1/sessions/{id}` after the 30-minute TTL. ## Webhook signature format HMAC-SHA256 over `t.`, keyed with the per-endpoint `whsec_*` signing secret minted at endpoint registration. ``` x-vonpay-signature: t=,v1= ``` - **Algorithm:** HMAC-SHA256 - **Key:** the endpoint's `whsec_*` secret, raw UTF-8 bytes (do not base64-decode, do not strip the prefix) - **Message:** `t + "." + raw_body` — the `t` value from the header concatenated with the raw HTTP request body before any parsing - **Replay window:** asymmetric — reject if `now - t > 300` (past) or `t - now > 30` (future) - **Single secret:** the header carries exactly one `v1=` entry and there is no grace window — after a rotation every delivery is signed with only the new secret, and a `400`, `401` or `403` you return is not retried. Accept both secrets during your switch-over, then drop the old one ([sequence](../integration/webhook-secrets.md#3-rotate)) Reference verifier code in five languages (Node, Python, Go, Ruby, PHP) is on the [Webhook Verification](../integration/webhook-verification.md) page. Compare with a constant-time helper (`hmac.compare_digest`, `crypto.timingSafeEqual`, `subtle.ConstantTimeCompare`). ## 3DS handoff How 3DS surfaces depends on which integration path the merchant uses. ### Hosted Checkout (Sessions) 3DS is transparent. The hosted page renders the issuer's challenge inline: - On success, the session continues to `succeeded` and your webhook fires normally. - On failure, the session moves to `failed`; your webhook fires. The decline detail arrives as `failure_reason` / `failure_code` on the `charge.failed` / `session.failed` webhook — a `GET /v1/sessions/{id}` poll returns `status: failed` but carries no failure-code field. Your adapter's "3DS challenge" code path typically has nothing to do — the encapsulated flow swallows it. If your adapter contract requires a separate "3DS in progress" state, poll `GET /v1/sessions/{id}` while the session is `pending`. ### Payment Intents The connector handles the challenge. When the issuer requires 3DS, the intent returns `status: "requires_action"` with a `next_action` block: ```json { "type": "redirect_to_url", "redirect_to_url": { "url": "https://challenge.example/3ds/abc123" } } ``` Redirect the buyer to `next_action.redirect_to_url.url` as a top-level navigation (not inside an iframe — banks block this). After the challenge, learn the outcome via the `payment_intent.succeeded` / `payment_intent.failed` webhook, and use [`GET /v1/payment_intents/{id}`](../integration/payment-intents.md) to read the persisted state directly when you need a polling fallback or want to confirm an outcome you may have missed. Don't trust the browser's return signal as authoritative. ### Embedded Fields + Payment Intents If the merchant pairs Embedded Fields with Payment Intents, the redirect above is the path: the connector sends `return_url` on the create call and redirects the buyer to `next_action.redirect_to_url.url`. See [Embedded Fields — 3D Secure](../embedded-fields/3ds.md). ## Idempotency Every `POST` request (`/v1/sessions`, `/v1/payment_intents`, `/v1/payment_intents/{id}/capture`, `/v1/payment_intents/{id}/void`, `/v1/refunds`, `/v1/tokens`) **should** carry an `Idempotency-Key` header with a unique-per-operation value (typically your platform's internal order ID + the operation name — see the key shape and the retry rule below). From the 28 October 2026 cut-off `POST /v1/payment_intents` can refuse any keyless charge, and today, on a provider connection that keeps no queryable record of its own, `POST /v1/refunds` and `POST /v1/payment_intents` already refuse a keyless request with `400` [`idempotency_key_required`](../reference/error-codes.md#idempotency_key_required); send the header on every connection and the distinction never matters to you. - Replaying the same `Idempotency-Key` returns the original response — including the original `checkoutUrl` — for as long as the session record is retained — effectively the lifetime of the row — not a fixed 24-hour cache. No duplicate session is created. - Reusing the same `Idempotency-Key` with a *different* request body returns `422` with `code=idempotency_replay_incompatible`. Recommended pattern for adapters: ``` Idempotency-Key: {platform_short_name}_{merchant_internal_order_id}_{operation} ``` Example: `sticky_order-789012_capture`. The last segment names **which operation on that order** this is — `capture`, `refund`, `refund-2` for a deliberate second refund. It must not carry an attempt or retry counter: retrying is the case the key exists to collapse, so a counter there defeats it. :::danger Reuse the key when you retry — never increment it A transient failure **is** a retry of the same logical operation. Sending a fresh key on a money endpoint skips the duplicate check entirely: on `POST /v1/refunds` that is a **second real refund**, and on `POST /v1/payment_intents` a **second real charge** (plus a second connected-store order). Change the key only for an operation that is genuinely different — a second, intentional refund of the same payment, or a new order. See [Refunds](../reference/refunds.md#create-a-refund) and [Connected Platforms](../connected-platforms/index.md). ::: ## Error code catalog All errors return JSON with `error`, `code`, `fix`, and `docs` fields, plus an `X-Request-Id` header for correlation: ```json { "error": "API key is malformed or does not exist", "code": "auth_invalid_key", "fix": "Check that your API key is correctly formatted and active", "docs": "https://docs.vonpay.com/reference/security#key-types" } ``` The full ErrorCode catalog is at [Error Codes](../reference/error-codes.md). The codes most relevant to a connector: | HTTP | Code | Common adapter handling | |---|---|---| | 401 | `auth_invalid_key` | Surface to the merchant — their pasted key is wrong or rotated past grace | | 401 | `auth_key_expired` | Surface to the merchant — they need to update the configured key | | 401 | `auth_merchant_inactive` | Surface to the merchant — Von Payments has disabled the account | | 403 | `merchant_not_onboarded` | The merchant hasn't completed KYC — surface a "complete onboarding" link | | 422 | `merchant_not_configured` | Surface to the merchant — payment routing isn't fully set up on their side | | 400 | `validation_invalid_amount` | Adapter bug — fix the amount mapping (must be positive integer minor units) | | 400 | `validation_missing_field` | Adapter bug — required field missing in the request body | | 409 | `session_wrong_state` | Idempotency — the session is in a state that disallows this operation | | 410 | `session_expired` | Session's 30-min TTL elapsed; create a new one | | 429 | `rate_limit_exceeded` / `rate_limit_exceeded_per_key` | Back off per the `Retry-After` header (1 to 60 s). On session creates: 30 req / 60 s per API key; with a secret key (`vp_sk_*`, or a legacy `vp_key_*`) a loose 300 req / 60 s per IP, otherwise 10 req / 60 s per IP. Create sessions server-side with the secret key. | | 502 | `provider_unavailable` | Upstream payment provider is unreachable. The outcome is unknown — retry a charge only with the **same `Idempotency-Key`**; keyless, reconcile instead. | | 402 | `provider_charge_failed` | Card declined — surface the decline to the merchant's UI | A connector that handles the `auth_*`, `merchant_*`, `validation_*`, `provider_*` and `session_*` families is complete — `session_expired` (410) means create a new session, not retry; the remaining codes are operational (rate limits, idempotency conflicts, internal errors). ## Sandbox Every Von Payments account has test-mode keys that never move real money. What a test request reaches and what decides its outcome is on [Test mode](../reference/test-cards.md); the [Platform Integrator Sandbox guide](../guides/platform-sandbox.md) covers provisioning. ## Reference adapter implementations A multi-tenant Node.js reference adapter is published at [`github.com/Von-Payments/vonpay-samples/tree/main/platform-integrator-nextjs`](https://github.com/Von-Payments/vonpay-samples/tree/main/platform-integrator-nextjs) (MIT-licensed). It demonstrates the full session lifecycle for a platform serving many merchants: - Per-tenant credential lookup (each onboarded merchant has its own `vp_sk_*` and per-endpoint `whsec_*` stored on the platform side; `lib/tenants.ts` is shaped like a real DB query). **Do not collect a per-tenant `ss_*`** — nothing in the integration uses it, and it cannot verify a return: returns carry a platform-wide signature. See [Handle the return](../integration/handle-return.md). - Tenant-scoped session creation with `Idempotency-Key` and pinned `Von-Pay-Version` - Return handling through `confirmReturn` — the redirect is confirmed by an authenticated server-side read of the session, per tenant, rather than by trusting the query string - **Multi-tenant webhook routing** — single `/api/webhooks` endpoint receives events for all tenants, peeks at `merchantId` to route, then verifies HMAC with the tenant's `whsec_*` webhook signing secret (the endpoint's whsec_*, not the vp_sk_* API key) - Composite-key event idempotency dedup — outbound events DO carry a top-level `id` (`vp_evt_*`); dedupe on that, not on the sample's `sessionId:event:timestamp` composite; store in Redis in production. - A small CRM-style UI with 3 simulated tenants → customers → "Charge $X" flow The general-purpose Node.js single-merchant samples ([`checkout-nextjs`](https://github.com/Von-Payments/vonpay-samples/tree/main/checkout-nextjs), [`checkout-express`](https://github.com/Von-Payments/vonpay-samples/tree/main/checkout-express), [`checkout-paybylink-nextjs`](https://github.com/Von-Payments/vonpay-samples/tree/main/checkout-paybylink-nextjs)) cover the SDK surface against a single merchant if your platform ships its own multi-tenancy layer. :::info PHP integrators The Node adapter at `platform-integrator-nextjs` documents the patterns to port into a PHP gateway-adapter contract. Use the [REST API reference](../sdks/rest-api.md) directly for raw HTTP, or use [`vonpay-checkout`](../sdks/python-sdk.md) (Python SDK) if your platform is Python-based. ::: ## Account model Each merchant of yours is a top-level Von Payments merchant with their own keys, dashboard, and webhook routing — same shape as every gateway integration your platform already supports. The merchant brings their per-merchant credentials; your platform stores them per-merchant; your adapter calls VORA server-to-server with the right merchant's key for each transaction. ## Partnership The technical spec on this page is what your eng team needs to build the connector. Getting it listed in your platform's gateway dropdown is a separate conversation with your Von Payments contact. ## Related - [Platform integration runbook](./gateway-integration-runbook.md) — this page is the API surface; the runbook is the build order, the settings screen your merchants fill in, and the evidence we need before listing you - [Platform Integrator Sandbox](../guides/platform-sandbox.md) — get keys, no KYC, in under a minute - [Quickstart](../integration/quickstart.md) — the 5-minute walkthrough; the `vp_sk_test_*` you create there is the same one your connector will exercise - [Webhook Verification](../integration/webhook-verification.md) — full HMAC verification scheme + reference code in 5 languages - [Error Codes](../reference/error-codes.md) — full ErrorCode catalog - [API Keys](../reference/api-keys.md) — key types, rotation, revocation - [Sandbox & Test Mode](../guides/sandbox.md) — keys, environments, webhook setup - [REST API](../sdks/rest-api.md) — full request/response shapes + OpenAPI spec --- ## /reference/api-keys Source: https://docs.vonpay.com/reference/api-keys # API Key Types ## Where keys come from Key issuance depends on mode: - **Test keys (`vp_sk_test_*`, `vp_pk_test_*`) come from your business's [sandbox](../guides/sandbox.md).** Activate it from [Developers](https://app.vonpay.com/dashboard/developers) and create them there. Asking for test keys on the live business is refused with **`403 test_keys_are_sandbox_only`**. - **Live keys (`vp_sk_live_*`, `vp_pk_live_*`) are created on the live business, never on a sandbox** (**`403 live_keys_not_on_sandbox`**). They are issued once your merchant application is approved *and* a payment processor is connected to your account. Both are required, and they are checked in that order: - Not approved yet: **`403 merchant_not_onboarded`** ([Error Codes](error-codes.md#merchant_not_onboarded)). - Approved, but nothing connected: **`403 live_keys_need_processor`** ([Error Codes](error-codes.md#live_keys_need_processor)). - We could not confirm your payment setup: **`503 processor_check_unavailable`** ([Error Codes](error-codes.md#processor_check_unavailable)). This one is temporary and is not a problem with your request; retry shortly. These refusals gate **creating a new key**. Keys you already hold keep working, and [rotating](#rotation-grace) one is never gated, so a leaked key can always be replaced. ## Key types at a glance | Key | Prefix | Where to use it | Rotation | |---|---|---|---| | **Test secret key** | `vp_sk_test_` | Server-side code, test mode only | Grace window (default 24h) | | **Live secret key** | `vp_sk_live_` | Server-side code, production only | Grace window (default 24h) | | **Test publishable key** | `vp_pk_test_` | Browser-exposed client code, test mode only | Grace window (default 24h) | | **Live publishable key** | `vp_pk_live_` | Browser-exposed client code, production only | Grace window (default 24h) | | **Legacy secret key** | `vp_key_` | ⚠ Older accounts only. Still accepted, and treated as a **full secret key** — server-side only, never in a browser | Rotate to a `vp_sk_` key | :::note Legacy session secret Older accounts may hold an `ss_test_` / `ss_live_` session secret. It is no longer issued and verifies nothing — in particular it is **not** how you confirm a payment when the buyer is redirected back. Read the outcome from `sessions.get` and fulfil on the [`charge.succeeded`](../integration/webhook-events.md#chargesucceeded) webhook instead; see [Handle the return](../integration/handle-return.md). ::: Webhook signing secrets (`whsec_*`) are **separate** — see [Webhook Signing Secrets](../integration/webhook-secrets.md). ## Secret vs publishable — when to use which - **Secret keys (`vp_sk_*`)** can create sessions, retrieve session status, and do everything through the API. Never expose them to a browser or mobile app. - **Publishable keys (`vp_pk_*`)** are safe to embed in client code. They can initialize the drop-in `vora-hosted.js` widget and **create** sessions — `POST /v1/sessions` is the only *merchant-facing* endpoint a publishable key can **write** through, and even there five fields are secret-key only (`gateway`, `storedCredentialUse`, `captureMethod`, `setupForFutureUse` and the buyer fields): a publishable-key request carrying one is refused with `auth_key_type_forbidden`. One read-only route also accepts either key type: `GET /v1/capabilities`. Every other `/v1/` merchant route rejects a publishable key with `auth_key_type_forbidden` (HTTP 403), including retrieving the full session details a secret key sees. **Session creation is not the only thing a publishable key does.** Ten browser-facing `/v1/public/*` handlers *require* a publishable key and reject a secret one — so the reach of a publishable key is wider than the merchant surface suggests: | Route | Method | What it does | |---|---|---| | `/v1/public/sessions/{id}` | `GET` | Redacted public session view (id, expiry, amount, currency, routing — no PII, no merchant-internal metadata, no provider session id) | | `/v1/public/sessions/{id}/embed-token` | `GET` | Short-lived token for the embedded-fields frame | | `/v1/public/sessions/{id}/mirror-order` | `GET` | Mirrored store-order status for this session | | `/v1/public/sessions/{id}/charge` | `POST` | **Charges the buyer** — Embedded Fields [charge at submit](../embedded-fields/charge-at-submit.md). `422 merchant_not_configured` where your gateway does not support it; `501 endpoint_not_implemented` while it is switched off platform-wide or not yet live-enabled for your gateway | | `/v1/public/sessions/{id}/complete` | `POST` | Completes the session after payment — the SDK calls it for you | | `/v1/public/binder-load` | `POST` | Per-processor element capability map | | `/v1/public/tokens` | `POST` | Mints a `vp_pmt_*` payment-method token from the browser — and on a wallet request (`instrument: "wallet"`) **charges the buyer directly** | | `/v1/public/telemetry/browser` | `POST` | Browser telemetry ingest | | `/v1/public/wallets/apple-session` | `POST` | Apple Pay merchant validation for standalone wallets, enabled per account | | `/v1/public/wallets/google-session` | `POST` | Google Pay session for standalone wallets, enabled per account | The session routes are **session-bound** — each is scoped to one checkout session, and a cross-merchant, mode-mismatched, or unknown session collapses to an opaque `404`. That scoping, not the key type, is what limits them. (`/v1/public/telemetry/browser` takes no session and is rate-limited per IP instead.) Key type is derived authoritatively from the prefix: `vp_pk_` ⇒ publishable, `vp_sk_` ⇒ secret. The checkout API enforces key-type restrictions server-side. For example, calling `sessions.get()` (`GET /v1/sessions/:id`, the full secret-key view) with a publishable key returns **`auth_key_type_forbidden`** (HTTP 403). The SDK surfaces that 403; it does not reject the key at construction time — constructing a client with a publishable key is allowed. ## Rotation grace When you rotate a secret or publishable key, the old key stays valid for a **grace window** so you can deploy the new key without downtime. The grace period is caller-configurable — **`1h`**, **`24h`**, or **`7d`** — and defaults to **24h** when you don't specify one. The old key keeps working until `grace_ends_at`, after which it rejects with `auth_key_expired`. ### Rotation timeline (default 24h grace) | t = | State | |---|---| | t0 | Click *Rotate* in `/dashboard/developers/api-keys`. New key is created; old key enters grace. | | t0 | UI shows the raw value of the new key **once**. Copy it to your secret manager immediately. | | t0 → t0+24h | Both keys accepted. Deploy the new key across all your services during this window. | | t0+24h | Old key rejects with `auth_key_expired` (HTTP 401). Grace ends. | If you rotate with a `1h` or `7d` grace, the t0+24h row shifts to t0+1h or t0+7d accordingly. You cannot re-rotate a key that is already in its grace window — that returns `404` ("not eligible for rotation"); rotate the current primary key instead. A predecessor already in grace stays active until **its own** `grace_ends_at` passes; rotating again does not deactivate it early. There is no per-window rotation rate limit (24h is the default grace period, not a cooldown), though each mode has a cap on the number of simultaneously-active keys (fresh + in-grace) — exceeding it returns `409`. ### Compromise — skip the grace If a key is exposed (leaked to a public repo, screenshot, shoulder-surf, etc.), **do not** initiate a normal rotation. Grace would keep the compromised key working for the duration of the grace window. Instead, from `/dashboard/developers/api-keys`: 1. Click *Revoke* on the compromised key (not *Rotate*). It is refused with no grace window. 2. Create a fresh key. 3. Move your deployed services to the fresh key. A revoked key (one with no rotation metadata) rejects with `auth_invalid_key`. A key that simply ran out its rotation grace rejects with `auth_key_expired`. ### Rotation-badge states (dashboard) Each key in `/dashboard/developers/api-keys` carries a badge: **Active** (the current primary), **Grace: ends in <N>h** (previous primary, accepted until `grace_ends_at`), **Revoked** (`auth_invalid_key` from now on) or **Expired** (grace passed — `auth_key_expired`). When a service starts failing authentication after a rotation, the badge on the key it holds says which. ## Expiry behavior API keys do not have a baked-in TTL — they stay **Active** until rotated or revoked. The only paths to expiry are: - **Normal rotation** → previous key enters its grace window → rejects with `auth_key_expired` once `grace_ends_at` passes. - **Revoke** → immediate rejection with `auth_invalid_key`. - **Merchant account suspension** → all keys for that merchant immediately reject with `auth_merchant_inactive` (HTTP 401). This is an account-level state (the merchant account is suspended or no longer approved for the mode), separate from per-key rotation/revocation. - **Revocation by Von Payments** — e.g. in response to a breach report. Shows the same *Revoked* badge. A freshly-rotated key needs no further activation step. ## Related - [Webhook Signing Secrets](../integration/webhook-secrets.md) — per-subscription secrets, different lifecycle - [Security](./security.md) - [Error Codes — `auth_*`](./error-codes.md) --- ## /reference/api Source: https://docs.vonpay.com/reference/api # API Reference The complete Von Payments Checkout API is documented in OpenAPI 3.1 format. ## OpenAPI Spec [`openapi.yaml`](https://checkout.vonpay.com/openapi.yaml) — import into Postman, Insomnia, Redocly, or any OpenAPI tool. ## Two front-end integration paths **Hosted Checkout** (`POST /v1/sessions` → redirect the buyer to `checkoutUrl`) and **Embedded Fields** (the browser SDK on your own page, in `embed` or `elements` mode) both vault a `vp_pmt_*` and settle through **Payment Intents**, the server-side engine for authorization, capture, refund and void. On an Embedded Fields charge-and-save session `submit()` has already charged — do **not** also call `POST /v1/payment_intents` for it. Side-by-side comparison: [Choose your integration](../integration-paths.md). ## Endpoints Summary ### Get session status {#get-session-status} `GET /v1/sessions/{id}` returns the full status of a previously-created session. Requires a secret key (`vp_sk_*`); publishable keys are rejected with `auth_key_type_forbidden`. See [Session Object](session-object.md) for the response shape. ### Close a session so you can charge another way {#expire-session} `POST /v1/sessions/{sessionId}/expire` closes an open, unpaid session so no payment can start on it afterwards. Call it before you take payment for the same order another way, such as when a buyer whose card was declined on the payment form switches to a card you saved earlier. Reading the session first is not enough. `GET /v1/sessions/{sessionId}` tells you what has been recorded so far, but a payment the buyer's browser sent before giving up can still arrive after your read. Expiring the session closes that window: the check and the change happen in one atomic step, and payment attempts take the same step, so an expire and a payment racing for one session can never both succeed. **Closing a declined session.** A `failed` session created with `integrationMode: "elements"` and `chargeAtSubmit: true` can also be closed (`expiredFrom: "failed"`): when no payment is recorded, `paymentStatus` is `unpaid`, no payment attempt on it is still in progress (such as a card waiting on the buyer's bank check), no hold from it is waiting to be released, and its hosted checkout page was never opened. A decline known to belong to the session's latest payment attempt is closed immediately. Otherwise the session must be unchanged for 10 minutes: the wait covers a payment that can still finish shortly after a decline it cannot be tied to, such as a wallet payment on the same session. If the decline allowed another card on the same session (`retry.allowed: true`), the session is back to `pending` and closes immediately. - **`200`** means the session is closed and no payment attempt that goes through our servers was in progress or can start. Expiring an already-`expired` session whose `expiredFrom` is `pending` or `failed` also returns `200`, so the call is safe to retry. - **`409 session_not_expirable`** means nothing changed. On a declined session it carries a `reason`: `recent_activity` comes with `retryAfterSeconds` and a `Retry-After` header, so call again after that many seconds; `payment_may_be_in_flight` may never clear. Treat an unrecognised `reason` as `payment_may_be_in_flight`. Without a `recent_activity` reason, a payment may exist on the session that has not been reported, which is also what you get when `expiredFrom` is `processing` or `null`. Do not take payment for the order another way. For those two states, **do not rely on polling the session or on a webhook** either: there is no session-expiry event, and otherwise a payment that lands on an already-expired session is not reported today. On an `elements` + `chargeAtSubmit` session, `expiredFrom: "processing"` can later change to `failed` once the provider confirms the payment was never authorized after a timed-out bank check, and that attempt's `payment_intent.failed` / `charge.failed` webhooks announce it. Contact support with the session id. **One exception, on `embed` sessions.** The buyer's browser pays the processor directly there, using a credential issued when the form loaded. That credential stays valid for up to an hour and cannot be recalled, so expiring the session stops new forms loading and refuses everything that goes through our servers, but it cannot stop a payment the buyer had **already submitted**. Remove or disable the payment form on your page and confirm the buyer is not mid-submission before you expire it. If a payment completes anyway it is **not reported to you today**, and it carries no payment intent, so it cannot be refunded through this API; contact support with the session id. Reporting for that case is being built. Sessions in `elements` mode, hosted checkout and wallet payments all go through our servers, where the guarantee holds. ### Change an open session's total {#update-session-total} `PATCH /v1/sessions/{sessionId}` changes the total of an open, unpaid checkout session. You call it when the buyer picks a delivery address or a shipping option inside the Apple Pay or Google Pay sheet and your store re-prices the order. Requires a secret key. ```bash curl -X PATCH https://checkout.vonpay.com/v1/sessions/vp_cs_live_k7x9m2n4p3 \ -H "Authorization: Bearer $VON_PAY_SECRET_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 5498 }' ``` | Field | Required | Notes | |---|---|---| | `amount` | Yes | The new total in minor units (`5498` = $54.98), from 1 to 99,999,999. | | `currency` | No | ISO 4217 code. Omit it to keep the session's currency. | | `lineItems` | No | Same shape as on [`POST /v1/sessions`](../integration/create-session.md). When sent, it replaces the displayed line items. | A `200` returns the same object as `GET /v1/sessions/{sessionId}`, carrying the new `amount`. The server SDKs call it `sessions.update()` (2.11.0 and later). The call sends no webhook, and an `Idempotency-Key` is optional because setting a total is idempotent on its own. **Only `elements` payment sessions can change their total.** Anything else returns `409 session_not_modifiable` with a `reason`, and nothing is changed: | `reason` | What it means | What to do | |---|---|---| | `integration_mode` | Hosted checkout and the embedded payment form fix the total when the form loads. | Create a new session with the new total. | | `setup_session` | A no-charge setup session has no total. | Nothing to change. | | `store_order` | The session is linked to a connected-store order (`mirror` or `order`), so the total is the store's. | Change the order in the store instead. | | `in_page_bank_check` | Your account runs the buyer's bank check ([3-D Secure](../embedded-fields/3ds.md)) on the page before paying. The bank checks the amount, and the charged total must match the checked one. | Create a new session with the new total. | | `not_open` | A payment is in progress, the session is paid, held, declined or expired, or it has passed its time limit (it can still read `pending` and `unpaid` for a short while after). | Read `session.status` and `session.paymentStatus` in the response. Do not take payment for the same order another way while a payment is in progress. | **A change can never land under a payment already in progress.** The check and the change are one atomic step, and payment attempts go through the same step. The payment form also tells the server which total the buyer approved: the [`express-checkout` element](../guides/standalone-wallets.md#buyer-details-and-shipping-in-the-wallet-sheet) sends it for you, and a bring-your-own-button integration sends it as `expected_amount`. If the session no longer holds that total, the payment is refused with [`409 expected_amount_mismatch`](error-codes.md#expected_amount_mismatch) and **the buyer is not charged**. A total that changes while a payment request is being processed is refused the same way, even when no `expected_amount` was sent. ### Session statuses {#session-statuses} A checkout session has one of five statuses: `pending`, `processing`, `succeeded`, `failed`, or `expired`. The normal flow is `pending → processing → succeeded` (or `→ failed` / `→ expired`); a session may also move directly `pending → succeeded`/`failed`/`expired`. Not all transitions are one-way: a `processing` session can revert to `pending`, and a `failed` session can later converge to `succeeded` on a successful retry (success takes precedence). Only `succeeded` and `expired` are terminal. See [Session Object — Status Lifecycle](session-object.md#status-lifecycle). ### Payment intent statuses {#payment-intent-statuses} Payment intents progress through a discrete lifecycle of six statuses: `requires_action → authorized → captured → succeeded` (or `voided` / `failed`). On the manual-capture path, `authorized → captured → succeeded` runs as three discrete steps; on the automatic-capture path, an `authorized` intent can move straight to `succeeded` when settlement is synchronous. See the [Payment Intents guide](../integration/payment-intents.md#lifecycle) for the full state machine, transition rules, and error envelope. ## Endpoints {#endpoints} This lists the merchant endpoints in the published contract. API error responses carry a `docs` link that points into this table. Where an endpoint is operator-enabled rather than always available, the row says so — being in the contract is not the same as being switched on for your account. | Method | Path | Auth | Description | |--------|------|------|-------------| | `POST` | `/v1/sessions` | Publishable or Secret | Create a checkout session (Hosted Checkout) — the only *merchant-facing* endpoint a publishable key can **write** through. Eleven `/v1/public/*` routes **require** one; the three below are the commonly-used ones, see [API Key Types](api-keys.md#secret-vs-publishable--when-to-use-which) for all of them | | `POST` | `/v1/sessions?dry_run=true` | Publishable or Secret | Validate params without creating a session | | `GET` | `/v1/sessions/{sessionId}` | Secret | Get session status | | `PATCH` | `/v1/sessions/{sessionId}` | Secret | Change the total of an open, unpaid `elements` session, typically after the buyer picks a shipping address or option in the Apple Pay / Google Pay sheet. Refused with `409 session_not_modifiable` (nothing changed) on hosted and embedded-form sessions, setup sessions, sessions linked to a store order, and sessions with an in-page bank check; create a new session for those. Sends no webhook. See [Change an open session's total](#update-session-total) | | `POST` | `/v1/sessions/{sessionId}/expire` | Secret | Close an open, unpaid session so no payment can start on it afterwards. Call it **before** taking payment for the same order another way, such as when a buyer whose card was declined switches to a card you saved earlier. Reading the session does not claim it, and a payment the browser sent before the buyer gave up can still arrive after your read. Idempotent on an already-`expired` session whose `expiredFrom` is `pending` or `failed`; any other state returns `409 session_not_expirable` and changes nothing | | `POST` | `/v1/payment_intents` | Secret | Create a payment intent (auth or auth+capture) — see [guide](../integration/payment-intents.md) | | `GET` | `/v1/payment_intents/{id}` | Secret | Retrieve a payment intent's persisted state. **This is where you read back the full `metadata`** — the provider truncates long values on the charge itself, but the intent keeps what you sent. Because it comes back verbatim, keep card numbers, bank details and government IDs out of `metadata` | | `POST` | `/v1/payment_intents/{id}/capture` | Secret | Capture an authorized intent (full or partial via `amount_to_capture`) | | `POST` | `/v1/payment_intents/{id}/void` | Secret | Void an authorized intent (before capture) | | `GET` | `/v1/payment_intents/{id}/mirror-order` | Secret | Mirrored store-order reference for a discrete-flow intent — see [Connected platforms](/connected-platforms) | | `GET` | `/v1/refunds` | Secret | List one payment's refunds, newest first, with its refund totals — see [Refunds](/reference/refunds#list-refunds) | | `POST` | `/v1/refunds` | Secret | Refund a `succeeded` payment intent (full or partial via `amount`) | | `POST` | `/v1/tokens` | Secret | Mint a `vp_pmt_*` payment-method token from a provider vault reference (`provider_reference`) | | `GET` | `/v1/payment_methods` | Secret | List a buyer's saved cards by your own customer reference — see [Tokens](tokens.md#list-a-buyers-saved-cards) | | `PATCH` | `/v1/tokens/{id}` | Secret | Replace the billing address stored with a saved card, or clear it with `null` (see [Tokens](tokens.md#update-billing-address)) | | `DELETE` | `/v1/tokens/{id}` | Secret | Revoke a stored payment method so it can never be charged again — see [Tokens](tokens.md#revoke) | | `POST` | `/v1/buyers` | Secret | Create or update a buyer record (upsert on `external_id` / `email`) — see [Buyers](buyers.md) | | `GET` | `/v1/buyers` | Secret | Exact buyer lookup by `?external_id=` or `?email=` (at least one required; no unfiltered listing) | | `GET` | `/v1/buyers/{id}` | Secret | Retrieve a buyer by `vp_by_*` id | | `PATCH` | `/v1/buyers/{id}` | Secret | Sparse buyer profile update (`external_id` not patchable) | | `GET` | `/v1/capabilities` | Publishable or Secret | Per-merchant capability matrix (`supported_operations`, `settlement_currencies`, `rate_limits`) | | `GET` | `/v1/descriptor_config` | Secret | Your statement-descriptor configuration — see [Statement descriptors](statement-descriptors.md) | | `POST` | `/v1/mirror/quote` | Secret | Price a basket against the connected store before charging — see [Connected platforms](/connected-platforms) | | `GET` | `/v1/webhook_subscriptions` | Secret | List your webhook subscriptions — see [Webhook verification](../integration/webhook-verification.md) | | `POST` | `/v1/webhook_subscriptions` | Secret | Create a webhook subscription | | `GET` | `/v1/webhook_subscriptions/{id}` | Secret | Retrieve one subscription | | `PATCH` | `/v1/webhook_subscriptions/{id}` | Secret | Update a subscription's events, description or status (its URL cannot change) | | `DELETE` | `/v1/webhook_subscriptions/{id}` | Secret | Delete a subscription | | `POST` | `/v1/webhook_subscriptions/{id}/rotate_signing_secret` | Secret | Rotate a subscription's `whsec_*` signing secret — see [Webhook secrets](../integration/webhook-secrets.md) | | `POST` | `/v1/webhook_subscriptions/{id}/send_test_event` | Secret | Send a test event to a subscription | | `GET` | `/v1/webhook_events/{id}` | Secret | Retrieve a single webhook event | | `GET` | `/v1/webhook_listen` | Secret | Stream webhook delivery transcripts (Server-Sent Events) — what the CLI's `listen` command uses. ⚠️ **Not generally available** — see [Streaming endpoints](#streaming-endpoints) below. Listed because it is in the published contract and the CLI references it | | `GET` | `/v1/wallet-domains` | Secret | List your Apple Pay / Google Pay domain verifications — see [Apple Pay setup](../embedded-fields/apple-pay-setup.md) | | `POST` | `/v1/wallet-domains` | Secret | Register a wallet domain | | `GET` | `/v1/wallet-domains/{id}` | Secret | Retrieve one wallet-domain verification | | `DELETE` | `/v1/wallet-domains/{id}` | Secret | Remove a wallet-domain verification (soft delete) | | `POST` | `/v1/wallet-domains/{id}/verify` | Secret | Re-run verification for one wallet domain | | `GET` | `/v1/wallet-domains/{id}/file` | Secret | Download the Apple Pay domain-verification file to host on your site | | `POST` | `/v1/sdk-telemetry` | Secret | SDK telemetry (on by default with a test key, off with a live key). **Your code does not call this** — the server SDKs post to it for you when telemetry is enabled | | `GET` | `/v1/public/sessions/{sessionId}` | Publishable | Browser-safe session lookup — used by Embedded Fields SDK | | `POST` | `/v1/public/binder-load` | Publishable | Per-processor element capability map (Embedded Fields) | | `POST` | `/v1/public/tokens` | Publishable | Browser-side `vp_pmt_*` minting (Embedded Fields) | | `GET` | `/v1/public/payment_intents/{id}/events` | Signed token | Live payment-status stream for the buyer's browser (Server-Sent Events). ⚠️ **Not generally available** — see [Streaming endpoints](#streaming-endpoints) below | | `GET` | `/api/health` | None | Health check (add `?deep=true` for deep variant) | | `POST` | `/v1/public/sessions/{sessionId}/charge` | Publishable | Charge the card the buyer just entered, from the browser, on an Embedded Fields **elements-mode** session — the charge-at-submit path. Your server never sees the card. See [Custom layout](../embedded-fields/custom-checkout.md); on this path do **not** also call `POST /v1/payment_intents`, or you charge twice. | | `POST` | `/v1/public/sessions/{sessionId}/complete` | Publishable | Resolve the outcome of an embedded payment after submit. The SDK calls this for you — you read the result it returns. A result here is a **UX signal, not proof of payment**: confirm from the [webhook](../integration/webhooks.md) before fulfilling. | | `GET` | `/v1/public/sessions/{sessionId}/embed-token` | Publishable | Poll whether an embedded session is ready to mount. The SDK handles this during `load()`; you would only call it directly when diagnosing a card form that never appears — see [Embedded Fields errors](../embedded-fields/errors.md). | | `GET` | `/v1/public/sessions/{sessionId}/mirror-order` | Publishable | Read the connected-store order reference for a completed session — for showing an order number on your thank-you page. Safe from the browser; it returns the reference only, never buyer identity. | | `POST` | `/v1/public/wallets/apple-session` | Publishable | Apple Pay merchant validation, for a **bring-your-own-button** wallet integration. Not needed with the Express Checkout element, which handles validation itself — see [Standalone wallets](../guides/standalone-wallets.md). | | `POST` | `/v1/public/wallets/google-session` | Publishable | Google Pay session setup for a **bring-your-own-button** wallet integration. Not needed with the Express Checkout element — see [Standalone wallets](../guides/standalone-wallets.md). | ## Streaming endpoints are not generally available {#streaming-endpoints} Two endpoints in the contract stream over Server-Sent Events, and **both answer `503` to every caller** — use [webhook subscriptions](../integration/webhook-verification.md) for delivery instead. They fail in different shapes: `GET /v1/webhook_listen` returns a JSON envelope with [`service_unavailable`](error-codes.md#service_unavailable) and `Retry-After: 5` (waiting changes nothing); `GET /v1/public/payment_intents/{id}/events` returns a `503` with **no body**, so code that parses every error as JSON throws instead of reading a code. ## Authentication Merchant-facing endpoints use Bearer token auth: ``` Authorization: Bearer vp_sk_live_xxx ``` Test keys use the `vp_sk_test_` prefix. Live keys use `vp_sk_live_`. Most endpoints require a **secret** key (`vp_sk_*`). The exceptions are session creation (`POST /v1/sessions`, including its `dry_run` variant) and `GET /v1/capabilities`, which accept either key type, and the `/v1/public/*` endpoints, which require a **publishable** key (`vp_pk_*`). So a publishable key can create sessions, read capabilities, and call the public browser endpoints; it is rejected on every secret-only route with `auth_key_type_forbidden` (HTTP 403). ## Request Headers | Header | Required | Description | |--------|----------|-------------| | `Authorization` | Yes | Bearer API key. The accepted key type depends on the endpoint — secret (`vp_sk_*`) for most routes, publishable (`vp_pk_*`) for the `/v1/public/*` endpoints, and either type for session creation and `GET /v1/capabilities`. | | `Content-Type` | Conditional | `application/json` on the browser/public POST endpoints (`/v1/public/sessions/{id}/charge`, `/v1/public/tokens`), which reject a missing or non-JSON type with HTTP 415 `unsupported_media_type`. The authenticated REST POST endpoints (`/v1/sessions`, `/v1/payment_intents`, `/v1/refunds`, `/v1/tokens`) parse the JSON body without a Content-Type check, but sending `application/json` is recommended for all POST requests. | | `Von-Pay-Version` | No | API version date string (e.g. `2026-04-14`). If omitted, the request uses the current API version. Pin this header to avoid breaking changes when the API evolves. | | `Idempotency-Key` | Conditional | One key per logical operation, reused on every retry — a replay with the same key returns the original response. **Required on every `POST /v1/payment_intents` from the 28 October 2026 cut-off**, after which a keyless charge can be refused at any time; until then a keyless charge succeeds but its response carries `Deprecation`, `Sunset` and `Link` headers. Already required on `POST /v1/refunds` and `POST /v1/payment_intents` where the processor connection keeps no queryable record of its own (a keyless request is refused with `400` [`idempotency_key_required`](error-codes.md#idempotency_key_required)); optional elsewhere, but a keyless retry issues a **second real refund or charge**. Never mint a fresh key to get past a `409` [`charge_in_progress`](error-codes.md#charge_in_progress). Max 255 printable-ASCII characters. See [Idempotency](#idempotency). | ## Response Headers Every response includes: | Header | Description | |--------|-------------| | `X-Request-Id` | Unique request ID for debugging | The three rate-limit headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` are returned on every rate-limited response — both successful responses and `429` responses. `Retry-After` is the only one that also appears **outside** rate limiting: a `503` carries it too, both during a maintenance write-freeze and on the disabled-`/v1/webhook_listen` state described above. Do not read `Retry-After` as "you were rate limited" — branch on the status code. See [Rate Limits](rate-limits.md). ## Idempotency {#idempotency} `Idempotency-Key` is enforced per-resource: a key is unique to `(merchant_id, idempotency_key)` on the resource it created (session, payment intent, refund). A replayed key with a **matching request body** returns the original response. A replayed key with a **different request body** returns HTTP 422 `idempotency_replay_incompatible` — the SDK surfaces this loudly so a buggy retry that changed amount, cart, or redirect URLs cannot silently inherit a prior resource. **Retention.** Idempotency keys are bound to the row they created, not to a separate cache with a fixed TTL. A replayed key returns the same resource for as long as the row is retained on our side — effectively the lifetime of the resource record. Use one key per **logical operation** and reuse it across every retry of that operation — that reuse is what makes the retry safe. Use a *different* key only when the operation is genuinely different (a second, intentional refund of the same payment; a new order), not merely because it is a later attempt. **One exception:** a refund that came back `200` with `status: "canceled"` is terminal and returned no money, and reissuing it under the same key replays that response forever — see [Refunds](refunds.md#refund-canceled). Full rule: [Idempotency](../integration/idempotency.md). **Format.** Max 255 characters, printable ASCII only (codepoints 0x20–0x7E). Whitespace-only keys are treated as no key. The server SDKs retry an ambiguous failure (`5xx`, timeout) on a money-moving call (create, capture, void, refund) **only when you sent a key** — see [Auto-retry](../sdks/node-sdk.md#money-moving-calls-without-an-idempotency-key-are-not-retried). ## Errors `session_already_completed` is returned as HTTP 409 Conflict, distinct from `session_expired`'s HTTP 410 Gone. It comes from any endpoint that would settle an already-`succeeded` session again — the public completion and read endpoints, and `POST /v1/payment_intents` when the `session_id` you pass already charged at submit — never from `POST /v1/sessions` itself. Branch on `code`: never create a new session for a 409 (the buyer already paid — that would re-charge), whereas a 410 means the session TTL elapsed and you may create a fresh one. ## Rate Limits {#rate-limits} Full bucket list and handling rules: [Rate Limits](rate-limits.md). --- ## /reference/buyers Source: https://docs.vonpay.com/reference/buyers # Buyers The Buyers API gives you first-class buyer records: create or update a profile, look it up by your own reference or by email, and read it back by id. Sessions, payment intents, and tokens all link to the same records, so a buyer you reference at checkout is the same buyer you manage here. A buyer is your customer — the person paying. **Required key:** secret key (`vp_sk_*`). Publishable keys are rejected with `403`; browser-facing endpoints never return buyer identity. Buyers are **mode-scoped**: a buyer created with a test key gets a `vp_by_test_*` id and is invisible to live keys (and vice-versa). ## The buyer object Every `/v1/buyers` operation returns the full stored profile. Every key is always present; fields with no stored value are `null`. ```json { "id": "vp_by_live_k7x9m2n4p3q5r6s8", "external_id": "user_8f3a2b", "email": "jane@example.com", "name": "Jane Doe", "phone": "+1 212 555 0100", "company": "Acme Inc", "address": { "address_line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "country": "US" }, "metadata": { "tier": "gold" }, "created_at": "2026-07-16T10:00:00Z", "last_seen_at": "2026-07-16T10:05:00Z" } ``` | Field | Type | Description | |---|---|---| | `id` | string | Buyer id, mode-prefixed (`vp_by_test_*` / `vp_by_live_*`). | | `external_id` | string (≤ 200) \| null | Your own **stable** customer reference (1–200 chars), e.g. `user_8f3a2b` — the same value you pass as `buyerId` on session create / `buyer_id` on the discrete surfaces. The primary lookup key; **wins over `email`** when both are present. Not patchable after create. `null` for email-only buyers. | | `email` | string (≤ 254) \| null | Buyer's email (valid email, max 254 chars). The fallback lookup key when `external_id` is absent. **Unique per merchant** across buyer records. | | `name` | string (≤ 200) \| null | Buyer's full name (1–200 chars). Stored, but **never split into given and family parts** and never sent to your payment provider. The name that reaches the processor is the separate `first_name` / `last_name` pair sent on the charge, which is not stored here — see [Putting a name on the payment](#buyer-name). | | `phone` | string (≤ 32) \| null | Phone number (3–32 chars) — digits with an optional leading `+` and common separators (spaces, dots, hyphens, parentheses). Deliberately not strict E.164; forward whatever your checkout collected. | | `company` | string (≤ 200) \| null | Company name (1–200 chars). | | `address` | object \| null | Buyer-level address on file — same shape as `billing_address`: `address_line1` (1–100) + `postal_code` (1–16) + `country` (2-letter uppercase ISO 3166-1 alpha-2) required; `address_line2` / `city` / `state` optional (each ≤ 100). Distinct from the charge-scoped `billing_address` on payment-intent create — this one persists on the buyer profile. | | `metadata` | object | Free-form merchant metadata. PII keys are scrubbed before storage. **Merges with append semantics** — existing keys are preserved, new keys are added; a write never bulk-replaces the stored metadata, and keys are never removed. Serialized size capped at 8 KB on the `/v1/buyers` write surfaces. `{}` when never set. | | `created_at` | string | When the buyer record was first created (ISO 8601). | | `last_seen_at` | string | Touched on every write that references this buyer — direct `/v1/buyers` writes and buyer-linked session / payment-intent / token creates alike. | ## Create or update a buyer `POST /v1/buyers` creates or updates a buyer record (upsert). The write is keyed on `external_id` when present, else on `email` — **at least one of the two identity fields is required** (a profile with no lookup key could never be retrieved or charged against). When both are sent, `external_id` wins as the lookup key and the email is stored on the profile. **A buyer first seen with an email only picks up your reference.** When you send an `external_id` no buyer has yet with an email that already belongs to a buyer without one (for example, a shopper who checked out as a guest with that email), the `external_id` is attached to that buyer and the call returns `200`. No second buyer is created, and the buyer's saved cards take the same reference, so they list under `GET /v1/payment_methods?buyer_id=`. If that buyer already has a **different** `external_id`, the call returns `409` [`buyer_email_conflict`](error-codes.md#buyer_email_conflict) and nothing changes. The same rule applies to the buyer on `POST /v1/sessions`, `POST /v1/payment_intents` and `POST /v1/tokens`, except that a conflict there never fails the request: it succeeds and the buyer is not linked. ```bash curl https://checkout.vonpay.com/v1/buyers \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "external_id": "user_8f3a2b", "email": "jane@example.com", "name": "Jane Doe", "phone": "+1 212 555 0100", "company": "Acme Inc", "address": { "address_line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "country": "US" }, "metadata": { "tier": "gold" } }' ``` Returns **`201`** when the call created the buyer and **`200`** when it updated an existing one; either way the body is the [full stored profile](#the-buyer-object), so a successful write is never silent — what you read back is what was persisted (e.g. `metadata` after the append-merge). ```json { "id": "vp_by_live_k7x9m2n4p3q5r6s8", "external_id": "user_8f3a2b", "email": "jane@example.com", "name": "Jane Doe", "phone": "+1 212 555 0100", "company": "Acme Inc", "address": { "address_line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "country": "US" }, "metadata": { "tier": "gold" }, "created_at": "2026-07-16T10:00:00Z", "last_seen_at": "2026-07-16T10:00:00Z" } ``` ## Look up a buyer `GET /v1/buyers` is an exact lookup by `?external_id=` or `?email=`. **At least one of the two query parameters is required** — there is deliberately no unfiltered buyer listing in v1; a request with neither returns `400 validation_error`. When both are supplied, `external_id` wins (the same identity-resolution order as the upsert write). ```bash curl "https://checkout.vonpay.com/v1/buyers?external_id=user_8f3a2b" \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" ``` Returns `{ "data": [...] }` with **zero or one** profiles — both lookup keys are unique per merchant, so a match is never ambiguous. An empty `data` array means no buyer matched; this surface does not 404 on a miss (the opaque 404 applies to the id-keyed read below). ```json { "data": [ { "id": "vp_by_live_k7x9m2n4p3q5r6s8", "external_id": "user_8f3a2b", "email": "jane@example.com", "name": "Jane Doe", "phone": "+1 212 555 0100", "company": "Acme Inc", "address": { "address_line1": "1 Market St", "city": "San Francisco", "state": "CA", "postal_code": "94105", "country": "US" }, "metadata": { "tier": "gold" }, "created_at": "2026-07-16T10:00:00Z", "last_seen_at": "2026-07-16T10:05:00Z" } ] } ``` ## Retrieve a buyer `GET /v1/buyers/{id}` returns one buyer profile by `vp_by_*` id. ```bash curl https://checkout.vonpay.com/v1/buyers/vp_by_live_k7x9m2n4p3q5r6s8 \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" ``` The response is [the buyer object](#the-buyer-object). It returns a **uniform `404` [`buyer_not_found`](error-codes.md#buyer_not_found)** when the id does not exist, belongs to a different merchant, **or** its test/live mode does not match the API key's mode — the same body in every case, so buyer ids cannot be probed across merchants or modes. To find a buyer by your own reference, use the [lookup](#look-up-a-buyer) instead. ## Update a buyer `PATCH /v1/buyers/{id}` is a sparse profile update — send only the fields to change (`email`, `name`, `phone`, `company`, `address`, `metadata`). Omitted fields are preserved. `email` is the one field that can be **cleared** as well as replaced: sending `email: null` erases it. See [Erasing a buyer's email](#erase-email) below — including why an accidental `null` is a real hazard worth guarding against in your client. ```bash curl -X PATCH https://checkout.vonpay.com/v1/buyers/vp_by_live_k7x9m2n4p3q5r6s8 \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Jane A. Doe", "phone": "+1 212 555 0199" }' ``` The response is the full post-update profile. Rules to know: - **`external_id` is NOT patchable** — it is the identity key saved payment methods and the checkout flows hang off, and re-keying it would silently detach them. Sending it is rejected with `400`. Create a new buyer instead. - Setting an `email` that already belongs to another of your buyers returns **`409` [`buyer_email_conflict`](error-codes.md#buyer_email_conflict)** — emails are unique per merchant across buyer records, and rejecting beats silently merging two buyer records into one. - `metadata` merges — existing keys are preserved, new keys are added, keys are never removed, and an existing key you send again is overwritten; an empty object does not clear it ([Metadata](metadata.md)). - The same uniform `404 buyer_not_found` posture as the retrieve applies. - `first_name` / `last_name` are **not accepted here** — they are not stored on the buyer record at all. See [Putting a name on the payment](#buyer-name). ## Erasing a buyer's email {#erase-email} Send `email: null` on the update to erase the stored address: ```bash curl -X PATCH https://checkout.vonpay.com/v1/buyers/vp_by_live_k7x9m2n4p3q5r6s8 \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": null }' ``` This clears the address on the buyer record and the lookup hash that finds a buyer by email, so the buyer stops being findable by address. It also asks your payment provider to drop their copy — but **a `200` here does not confirm that happened.** That request is dispatched after we answer you, is never awaited, and reports no failure back: a provider error, a timeout or an open circuit breaker all pass silently. ⛔ **And for a buyer you only ever identified by email, the retraction cannot be performed at all** — the key we would need to reach the provider's record was derived from the address that was just erased. Do not tell a shopper the provider's copy is gone on the strength of a success response. **It is not a complete erasure of the address, and you should not describe it to a shopper as one.** Copies survive elsewhere. These are the ones we have confirmed: | Where | What clears it | |---|---| | The settlement ledger — an encrypted copy kept with the payment for financial-audit retention | Nothing. It remains for the life of that retention. | | Checkout sessions from the recent past | An age-based anonymiser at 90 days — so immediately after this call the address is still on every session from the last quarter. | | An address already forwarded to your connected store at order time | Your store. It is an external system and there is no retraction path from here. | The table is not an inventory of every copy, and it does not include your own systems. For a formal erasure request, treat this call as the profile half and contact us for the retained copies. What survives and what doesn't: - **The buyer record survives**, and so does every payment linked to it — the id keeps working, and reporting and reconciliation are unaffected. - **The address is gone from the buyer record and cannot be restored there.** There is no undo — and, per the table above, this is the buyer record only, not every copy. - **An email-only buyer becomes unfindable by lookup.** The record is still reachable by its `vp_by_*` id, but `GET /v1/buyers?email=` will no longer locate it. If you rely on email lookup, keep your own reference in `external_id` before you erase — that keeps the record findable afterwards. :::warning An accidental `null` erases just as permanently as a deliberate one A JSON serialiser that emits absent optional fields as `null` turns a routine phone-number update into an email erasure that returns `200`. Send only the keys you intend to change. ::: ## Putting a name on the payment {#buyer-name} If you want the shopper's name to appear on the payment at your processor, send **`first_name` and `last_name`** on the charge — `POST /v1/payment_intents`, either as flat fields or inside the nested `buyer` object: ```bash curl https://checkout.vonpay.com/v1/payment_intents \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 1499, "currency": "USD", "payment_method": { "id": "vp_pmt_live_..." }, "buyer": { "external_id": "user_8f3a2b", "first_name": "Jane", "last_name": "Doe" } }' ``` **They belong to the charge, not to the profile.** `first_name` and `last_name` are not stored on the buyer record — `name` is the stored field — and they are accepted on `POST /v1/buyers` and `POST /v1/tokens` only so the buyer shape is the same everywhere; only the charge forwards them. There is nothing to backfill: `PATCH /v1/buyers/{id}` rejects them with `400 validation_error`, and charges already made cannot be amended — send them on every charge from now on. **A combined `name` is never split** into given and family parts. Send the structured pair or nothing; when you send both, `first_name` / `last_name` take priority. Each is capped at **100 characters** (shorter than `name`'s 200) and a longer value is rejected, not shortened. :::note Whether the name reaches your provider is an account-level setting The billing block that carries these fields is forwarded per account, not per request, so a successful response does not by itself confirm it was sent. The fields are accepted either way. Ask us if you need it switched on and aren't sure it is. ::: `billing_address` is the other half of the same block, and it is what makes [address verification](../guides/address-verification.md) run at all — without it, that check never happens on your charges. ## Nested `buyer` on the create calls You don't have to call `POST /v1/buyers` separately — the three create calls accept an optional nested `buyer` object that **upserts the same buyer record** and links the session / charge / token to it. It's the richer alternative to the flat buyer fields, which remain fully supported. | Surface | Casing | Nested object keys | Flat fields (still supported) | |---------|--------|--------------------|-------------------------------| | `POST /v1/sessions` | camelCase | `externalId`, `email`, `name`, `phone`, `company`, `address` (`addressLine1`, `addressLine2`, `city`, `state`, `postalCode`, `country`), `metadata` | `buyerId`, `buyerName`, `buyerEmail` | | `POST /v1/payment_intents` | snake_case | `external_id`, `email`, `name`, **`first_name`**, **`last_name`**, `phone`, `company`, `address` (`address_line1`, …), `metadata` | `buyer_id`, `buyer_email` | | `POST /v1/tokens` | snake_case | same as payment intents | `buyer_id` | On `POST /v1/sessions` every one of these is **secret key only**: a publishable-key request carrying `buyerId`, `buyerEmail`, `buyerName` or the nested `buyer` object is refused with `403 auth_key_type_forbidden`. A buyer identity is what a saved card is stored under, so it may only be asserted from your server; see [Buyer identification](../integration/create-session.md#buyer-identification). ⚠️ **`first_name` / `last_name` are the exception to "upserts the same buyer record".** They are accepted on all three surfaces for a consistent shape, but they are not part of the stored profile anywhere — and only `POST /v1/payment_intents` does anything with them. See [Putting a name on the payment](#buyer-name). The rules, on all three surfaces: - The nested object **must carry an identity** (`external_id` / `externalId` or `email`) — either directly or via the surface's flat field. A nested buyer with no identity from either source returns `400 validation_error`. - Sending a flat field AND its nested counterpart with **equal** values is fine. **Different** identity values return `400 validation_error` rather than silently preferring one (on payment intents, `buyer_id` also doubles as the saved-payment-method ownership assertion — the server never guesses which value you meant). - The accepted `buyer` object is **echoed back on the create response** (present only when you sent it; `buyer.metadata` is echoed post-scrub — the stored value), so a successful write is verifiable. - On `POST /v1/payment_intents`, when the stored `payment_method` already carries a buyer from vault time, **that saved buyer still wins for attribution**. Example — session create with a nested buyer (camelCase): ```bash curl https://checkout.vonpay.com/v1/sessions \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "amount": 1499, "currency": "USD", "buyer": { "externalId": "user_8f3a2b", "email": "jane@example.com", "name": "Jane Doe", "address": { "addressLine1": "1 Market St", "city": "San Francisco", "state": "CA", "postalCode": "94105", "country": "US" } } }' ``` ## Reading buyer identity back from a charge When a charge is linked to a buyer (a `buyer_id` / `buyer_email` sent at create, a nested `buyer`, or a saved payment method that belongs to a buyer), `GET /v1/payment_intents/{id}` includes `buyer_id` (your customer reference) and `buyer_email` — so you can go from a charge back to the buyer later (support, reconciliation). Charges with no buyer linkage omit both keys. The linkage applies to automatic **and manual** captures — a `capture_method: "manual"` (authorize-then-capture) charge sent with buyer identity at create carries it through the capture, so it reads back the same as an automatic capture. ## Errors | Status | Code | Cause | |--------|------|-------| | `400` | [`validation_error`](error-codes.md#validation_error) | A field failed validation; neither `external_id` nor `email` on create; neither lookup parameter on `GET /v1/buyers`; `external_id`, `first_name`, `last_name` (or any other unknown field) in a `PATCH` body; or a flat buyer field and its nested `buyer` counterpart sent with different identity values on a create call. | | `403` | [`auth_key_type_forbidden`](error-codes.md#auth_key_type_forbidden) | A publishable key was used — the Buyers API is secret-key only. | | `404` | [`buyer_not_found`](error-codes.md#buyer_not_found) | The `vp_by_*` id is unknown to this merchant (opaque — missing, another merchant's, or a test/live mode mismatch). | | `409` | [`buyer_email_conflict`](error-codes.md#buyer_email_conflict) | A `PATCH` tried to set an email that already belongs to another of your buyers. | | `429` | [`rate_limit_exceeded`](error-codes.md#rate_limit_exceeded) | `/v1/buyers*` shares the payment-routes limit (300 requests / 60s per secret key) — see [Rate Limits](rate-limits.md). | ## Related - [Create a session](../integration/create-session.md) — the flat `buyerId` / `buyerEmail` fields and buyer identification guidance - [Payment Intents](../integration/payment-intents.md) — charging with buyer attribution, and [what reaches your payment provider](../integration/what-reaches-your-provider.md) - [Address verification](../guides/address-verification.md) — what `billing_address` buys you on a charge - [Tokens](tokens.md) — associating a vaulted payment method with a buyer - [Error codes](error-codes.md) — the full error catalog --- ## /reference/capabilities Source: https://docs.vonpay.com/reference/capabilities # Capabilities `GET /v1/capabilities` returns the effective capability matrix for the authenticated merchant: which payment-intent operations are supported, which settlement currencies are available, and the payment-intent rate limit. The matrix is processor-agnostic — it never identifies which underlying provider supplies a capability, so you branch on the capability, not on a processor name. Read it **once at integrator startup** and cache it for the process lifetime. Branch on it before invoking optional operations (separate capture, partial refund, void-after-capture) so you fail early in your own code instead of round-tripping to a `501` from the API. **Authentication:** any valid API key — both publishable (`vp_pk_*`) and secret (`vp_sk_*`) keys are accepted, since the matrix is read-only metadata. ## Request ```bash curl https://checkout.vonpay.com/v1/capabilities \ -H "Authorization: Bearer vp_sk_test_YOUR_KEY" ``` ```javascript const caps = await client.capabilities.get(); ``` ## Response ```json { "supported_operations": { "auth_capture_separation": true, "partial_capture": true, "partial_refund": true, "unreferenced_refund": false, "void_after_capture": "not_supported", "mit": true, "network_tokens": true, "three_d_secure_2": false, "ach": false, "payouts_api": false }, "settlement_currencies": ["USD", "EUR", "GBP", "CAD", "AUD"], "rate_limits": { "payment_intents_per_minute": 300 } } ``` In the Node SDK the keys are camelCase (`caps.supportedOperations.partialCapture`); on the wire they are snake_case as shown above. ### `supported_operations` | Flag | Type | Meaning | |------|------|---------| | `auth_capture_separation` | boolean | Authorize and capture can be separate steps (`capture_method: "manual"` on the payment intent, then `POST /v1/payment_intents/:id/capture`). | | `partial_capture` | boolean | Capture less than the authorized amount. | | `partial_refund` | boolean | Refund less than the captured amount via `POST /v1/refunds` with an `amount`. | | `unreferenced_refund` | boolean | Issue a refund that does not reference an original charge. | | `void_after_capture` | string enum | Whether `POST /v1/payment_intents/:id/void` works once the intent is captured. **Every processor returns `not_supported` today** — reverse a captured payment with [`refunds.create`](refunds.md) instead. The enum also carries `supported` (void works after capture) and `rerouted_to_refund` (the void call is handled through the refund pathway for you); no processor returns either at present. See [branching](#branching-on-the-matrix) below. | | `mit` | boolean | Merchant-initiated transactions (e.g. recurring, unscheduled). | | `network_tokens` | boolean | Network tokenization is available for the merchant's processor. | | `three_d_secure_2` | boolean | 3-D Secure 2 is available. | | `ach` | boolean | ACH bank-transfer payments. | | `payouts_api` | boolean | Payouts API. | | `dispute_reporting` | string enum | Whether a chargeback against one of your payments reaches Von Payments. `tracked`: we are notified and emit a `dispute.created` webhook. `not_tracked`: we are not notified. The payment's `status` never becomes `disputed` on either value — see [below](#dispute-reporting). | ### `settlement_currencies` Array of ISO 4217 currency codes the merchant can settle in. ### `rate_limits` | Field | Type | Meaning | |-------|------|---------| | `payment_intents_per_minute` | integer | Per-minute ceiling per secret key, shared by the payment routes (`/v1/payment_intents`, `/v1/refunds`, `/v1/tokens`, `/v1/buyers`, `/v1/payment_methods`). See [Rate Limits](rate-limits.md). | ## `succeeded` is not evidence a payment was not disputed {#dispute-reporting} A payment's `status` is a closed set (`requires_action`, `authorized`, `captured`, `succeeded`, `voided`, `failed`) and none of them means "disputed" — a disputed payment keeps reading `succeeded` on either value of `dispute_reporting`, so polling a payment can never reveal a dispute. - **`tracked`** — we are notified and send [`dispute.created`](../integration/webhook-events.md) (then `dispute.won` / `dispute.lost`). `dispute.*` is delivered to **every active webhook subscription**, whichever events it registered for — you need at least one active endpoint, and the webhook is the only signal. - **`not_tracked`** — we are not notified and nothing in this API will tell you the dispute exists. Monitor and answer disputes in your processor's own dashboard; the card network's response deadline is shown there, and a dispute you do not answer in time is lost. You answer your own disputes on either value; this field only changes whether we can tell you one was raised. ## Branching on the matrix Check the matrix before an optional operation instead of catching a `501`: ```javascript const caps = await client.capabilities.get(); if (caps.supportedOperations.voidAfterCapture === "supported") { // The processor can void the intent directly after capture. await client.paymentIntents.void(pi.id, { idempotencyKey: `${pi.id}_void` }); } else { // Every processor today. Reverse a captured payment with a refund. const refund = await client.refunds.create({ paymentIntent: pi.id }); // ⛔ Not throwing is not the same as the buyer being repaid — `canceled` is a // `200` that moved no money. See [Refunds](refunds.md#status). if (refund.status === "canceled") throw new Error("refund canceled before settlement"); } ``` Branch so the refund is the fallback, not a special case — a branch that handles only `supported` and `rerouted_to_refund` reverses nothing ([Refunds → Void-after-capture](refunds.md#void-after-capture)). An operation the active binder does not support returns [`501 endpoint_not_implemented`](error-codes.md#endpoint_not_implemented). An operation that is supported but invalid for the intent's current state returns [`409 invalid_transition`](error-codes.md#invalid_transition). ## Related - [Payment Intents](../integration/payment-intents.md) — the operations gated by this matrix - [Refunds](refunds.md) — partial-refund and void-after-capture behavior - [Error Codes](error-codes.md) — `endpoint_not_implemented`, `invalid_transition` --- ## /reference/discovery Source: https://docs.vonpay.com/reference/discovery # API Discovery Von Payments exposes machine-readable discovery endpoints. No authentication is required for any of these. The JSON below is an **abbreviated, illustrative** copy of the live document — fetch the endpoint for the authoritative, complete payload. ## `GET /.well-known/vonpay.json` Returns discovery metadata about the API — version, available endpoints, documentation links, and SDK packages. ```bash curl https://checkout.vonpay.com/.well-known/vonpay.json ``` ```json { "api_version": "2026-04-14", "endpoints": { "sessions": "/v1/sessions", "payment_intents": "/v1/payment_intents", "refunds": "/v1/refunds", "tokens": "/v1/tokens", "capabilities": "/v1/capabilities", "public_sessions": "/v1/public/sessions/{id}", "public_tokens": "/v1/public/tokens", "public_binder_load": "/v1/public/binder-load", "health": "/api/health" }, "scripts": { "hosted": { "url": "https://js.vonpay.com/v1/vora-hosted.js", "legacy_url": "https://checkout.vonpay.com/vonpay.js", "buyer_experience": "redirect", "key_type": "publishable", "note": "Browser snippet that creates a checkout session and redirects the buyer to hosted checkout. Use this when a redirect-based hosted checkout is acceptable. `legacy_url` is the prior URL for this script; it keeps serving through a deprecation window so existing integrations stay valid — new integrations should use `url`." }, "embedded": { "url": "https://js.vonpay.com/v1/vora.js", "buyer_experience": "embedded_iframe", "key_type": "publishable", "note": "Embedded Fields browser SDK (vora.js). Mounts iframe-vault card / address / wallet fields on the merchant's page; the buyer stays on the merchant's domain. On supported gateways submit() charges the amount AND saves a reusable vp_pmt_* token in one step (charge-and-save) — do NOT also call POST /v1/payment_intents for that same payment (it double-charges); confirm via webhook. DO NOT add any other processor SDK alongside; vora.js selects the underlying processor adapter automatically." } }, "docs": "https://docs.vonpay.com", "llms_txt": "https://checkout.vonpay.com/llms.txt", "openapi": "https://checkout.vonpay.com/openapi.yaml", "mcp_package": "@vonpay/checkout-mcp", "sdk": { "node": "@vonpay/checkout-node", "python": "vonpay-checkout", "cli": "@vonpay/checkout-cli" }, "sdk_preview": { "vora_js": { "package": "@vonpay/vora-js", "status": "not_yet_published", "note": "Browser SDK for embedded payment fields. NOT on npm today. Use the CDN script at scripts.embedded.url instead — DO NOT build a manual integration against the public_* endpoints with a third-party processor SDK; that produces incompatible token formats." }, "vora_react": { "package": "@vonpay/vora-react", "status": "not_yet_published", "note": "React wrapper for the browser SDK. NOT on npm today. Use the CDN script at scripts.embedded.url and a thin local React wrapper around the Vora constructor (imported from the package, not a window global)." } }, "embedded": { "availability": "all_modes_on_supported_gateways", "fallback": { "endpoint": "/v1/sessions", "field": "checkoutUrl", "note": "Hosted checkout supports every merchant regardless of gateway. Use it when embedded fields are unavailable for a given session." } }, "status": "https://status.vonpay.com" } ``` AI agents and developer tools can use this endpoint to auto-discover the API without hardcoded URLs. The `scripts` block gives a machine-readable map of the two browser entry points (hosted redirect vs embedded iframe) so a tool doesn't have to parse prose docs to know which URL belongs to which buyer experience. When a script has been renamed, the optional `legacy_url` field carries its prior URL, which keeps serving through a deprecation window so SRI-pinned integrations stay valid while new integrations adopt `url`. > **Sandbox vs. live for embedded fields:** on a sandbox account the embedded path runs on its processor's test environment, and a test key with no test environment behind it is refused with `422 sandbox_account_required`; with a live key on a merchant account it works on connections with a supported card-field binder. For the exact sandbox/live matrix and the `422` fallback cases, see [Choose your integration](../integration-paths.md). Hosted checkout (the `checkoutUrl` fallback above) always works regardless of gateway. ## `GET /llms.txt` Returns an LLM-readable reference of the API. ⚠️ Despite the `llms.txt` name this is the **full** corpus (about 1 MB), not a short index — it is byte-identical to [`docs.vonpay.com/llms-full.txt`](https://docs.vonpay.com/llms-full.txt). If you want the compact index instead, use [`docs.vonpay.com/llms.txt`](https://docs.vonpay.com/llms.txt) (about 41 KB). Covers the API surface, authentication, and key concepts. ```bash curl https://checkout.vonpay.com/llms.txt ``` ## `GET /openapi.yaml` Returns the full OpenAPI 3.1.0 specification for the API. Import into Postman, Insomnia, Redocly, or any OpenAPI-compatible tool. ```bash curl https://checkout.vonpay.com/openapi.yaml ``` --- ## /reference/error-codes Source: https://docs.vonpay.com/reference/error-codes # Error Codes Errors from the `/v1` API return JSON with `error`, `code`, `fix`, `docs`, and `selfHeal` fields, plus an `X-Request-Id` response header. ```json { "error": "Human-readable error message", "code": "error_code", "fix": "Suggested action to resolve the error", "docs": "https://docs.vonpay.com/reference/error-codes#error_code", "selfHeal": { "retryable": false, "nextAction": "no_action", "llmHint": "Machine-readable guidance for SDKs and agents." } } ``` The `selfHeal` object is always present in the standard envelope and gives SDKs and agents a machine-readable hint about whether the request is retriable and what to do next. A small number of internal responses (for example a streaming connection that closes with a bare `503`) fall outside this envelope and carry neither the JSON body nor the `X-Request-Id` header. ## HTTP Status Codes | Code | Meaning | Common Causes | |------|---------|---------------| | 400 | Bad Request | Invalid request body, missing required fields, validation failure | | 401 | Unauthorized | Missing or invalid `Authorization: Bearer` token | | 404 | Not Found | Session ID doesn't exist | | 409 | Conflict | Session is in the wrong state (e.g., already completed) | | 410 | Gone | Session has expired (configurable TTL — 30-minute default) | | 429 | Too Many Requests | Rate limit exceeded | | 500 | Internal Server Error | Unexpected server error | ## Error Codes Reference | Code | HTTP | Description | |------|------|-------------| | [`auth_missing_bearer`](#auth_missing_bearer) | 401 | No `Authorization: Bearer` header provided | | [`auth_invalid_key`](#auth_invalid_key) | 401 | API key is malformed or does not exist | | [`auth_key_expired`](#auth_key_expired) | 401 | Key has rotated past its grace window | | [`auth_missing_bearer_publishable`](#publishable-variants) | 401 | No `Authorization: Bearer` header on a browser-callable `/v1/public/*` route | | [`auth_invalid_key_publishable`](#auth_invalid_key_publishable) | 401 | Publishable key not recognized on this host — **most often an environment mismatch, not a bad key**. See [below](#auth_invalid_key_publishable) before rotating anything | | [`auth_key_expired_publishable`](#publishable-variants) | 401 | Publishable key is past its rotation grace window. Publishable keys reach the browser by **redeploy**, not by swapping an environment variable | | [`auth_key_type_forbidden`](#auth_key_type_forbidden) | 403 | Publishable key used on a secret-only endpoint, or sandbox/live mode mismatch | | [`auth_merchant_inactive`](#auth_merchant_inactive) | 401 | Merchant account is disabled or suspended | | [`merchant_not_onboarded`](#merchant_not_onboarded) | 403 | Live-key creation is blocked until merchant-onboarding review completes | | [`live_keys_need_processor`](#live_keys_need_processor) | 403 | The account is approved, but no payment processor is connected to it yet | | [`test_keys_are_sandbox_only`](#test_keys_are_sandbox_only) | 403 | Test keys were requested on a live business; they come from its sandbox | | [`live_keys_not_on_sandbox`](#live_keys_not_on_sandbox) | 403 | A live key was requested on a sandbox | | [`processor_check_unavailable`](#processor_check_unavailable) | 503 | We could not confirm the account's payment setup. Temporary, and not a problem with your request; retry shortly | | [`auth_service_unavailable`](#auth_service_unavailable) | 503 | Authentication service is temporarily unavailable | | [`session_not_found`](#session_not_found) | 404 | Session ID does not exist | | [`checkout_address_not_found`](#checkout_address_not_found) | 404 | The request reached a `{name}.vonpay.com` address that is not serving checkout. Use the `checkoutUrl` from `POST /v1/sessions` exactly as returned | | [`session_expired`](#session_expired) | 410 | Session expired (TTL elapsed), or a terminal session with no successful charge — create a new one | | [`session_already_completed`](#session_already_completed) | 409 | A completion call hit an already-`succeeded` session — the buyer was charged once (or, on a `captureMethod: "manual"` session, authorised once); do **not** retry or create a new session (would double-charge). Read the result via webhook or session retrieve | | [`session_wrong_state`](#session_wrong_state) | 409 | Session is in the wrong state for this operation | | [`session_not_expirable`](#session_not_expirable) | 409 | The session could not be expired and **nothing changed**. A payment may exist on it that has not been reported, so do not take payment another way; read the `session` in the response. See [below](#session_not_expirable) | | [`session_not_modifiable`](#session_not_modifiable) | 409 | `PATCH /v1/sessions/{id}` could not change the total and **nothing changed**. `reason` says why. See [below](#session_not_modifiable) | | [`charge_in_progress`](#charge_in_progress) | 409 | A charge is already in flight — **for this session** on the hosted surface, or **for this `Idempotency-Key`** on `POST /v1/payment_intents`. **Do not retry, do not create a new session, and do not reissue with a fresh `Idempotency-Key`** — on some provider configurations a second attempt is a second charge that cannot be merged afterwards. Read the original's status instead. See [below](#charge_in_progress) | | [`charge_already_in_flight`](#charge_already_in_flight) | 409 | Another request with this `Idempotency-Key` is still running. **Retry with the same key** after `retryAfterSeconds` — never with a fresh one. Nothing was charged by this request | | [`charge_reservation_unavailable`](#charge_reservation_unavailable) | 503 | The pre-charge record for your `Idempotency-Key` could not be written; **nothing reached the processor**. Retry the same request with the same key | | [`expected_amount_mismatch`](#expected_amount_mismatch) | 409 | The payment was for a total the session no longer holds. **The buyer was not charged.** Show the current total and have the buyer approve it again. See [below](#expected_amount_mismatch) | | [`session_integrity_error`](#session_integrity_error) | 500 | Internal session state mismatch — contact support | | [`validation_error`](#validation_error) | 400 | Request body failed schema validation | | [`currency_not_supported`](#currency_not_supported) | 422 | The currency is not accepted for new payments yet. Nothing was charged, saved or created. Use a different currency | | [`validation_missing_field`](#validation_missing_field) | 400 | A required field is missing from the request body | | [`validation_invalid_amount`](#validation_invalid_amount) | 400 | Amount is not a positive integer or exceeds maximum | | [`validation_unknown_field`](#validation_unknown_field) | 400 | The body carried a field this endpoint does not accept. Refused, never ignored. Two causes: wrong casing, or a real field that belongs on a **different** call | | [`merchant_not_configured`](#merchant_not_configured) | 422 | Merchant is missing required configuration (e.g., payment provider credentials) | | [`binder_unavailable`](#binder_unavailable) | 409 | Merchant has no payment provider configured for this operation — complete onboarding. A test key does not avoid it | | [`sandbox_account_required`](#sandbox_account_required) | 422 | A test key was used where there is no test environment: on a live account, or on a sandbox with no payment provider. Test payments run only on a sandbox account | | [`capability_not_supported`](#capability_not_supported) | 422 | The merchant's payment provider does not support the requested operation — check `GET /v1/capabilities` | | [`rate_limit_exceeded`](#rate_limit_exceeded) | 429 | A per-IP (or route) rate limit was reached. Wait `Retry-After` (1 to 60 seconds), then retry with backoff | | [`rate_limit_exceeded_per_key`](#rate_limit_exceeded_per_key) | 429 | Per-API-key rate limit (30 session-creates/min). Wait `Retry-After`, or contact support if you need a higher ceiling | | [`provider_unavailable`](#provider_unavailable) | 502 | Upstream payment provider is not responding | | [`payment_outcome_unknown`](#payment_outcome_unknown) | 502 | A capture or void reached the payment provider, but its result could not be recorded. **Do not retry**: a retried capture can capture twice. Read the payment intent back. See [below](#payment_outcome_unknown) | | [`provider_attestation_failed`](#provider_attestation_failed) | 403 | Payment provider rejected the session-bound attestation | | [`provider_charge_failed`](#provider_charge_failed) | 402 | Card declined or charge rejected by the upstream provider | | [`provider_request_rejected`](#provider_request_rejected) | 422 | Payment provider rejected the request as invalid before any money moved (not a decline, not an outage) — fix the offending field and retry | | [`internal_error`](#internal_error) | 500 | Unexpected server error | | [`webhook_missing_signature`](#webhook_missing_signature) | 401 | Inbound provider webhook is missing its signature header | | [`webhook_invalid_signature`](#webhook_invalid_signature) | 401 | Webhook signature does not match the expected value | | [`webhook_not_configured`](#webhook_not_configured) | 503 | Webhook verification secret is not configured on the server | | [`webhook_test_delivery_failed`](#webhook_test_delivery_failed) | 502 | A synchronous webhook test-send probe could not be delivered. Retriable on the raw API; the 3.6.0+ SDKs do not repeat it | | [`origin_forbidden`](#origin_forbidden) | 403 | Internal endpoint called from outside the checkout page | | [`transaction_verification_failed`](#transaction_verification_failed) | 403 | Transaction could not be verified with the payment provider | | [`unsupported_media_type`](#unsupported_media_type) | 415 | Content-Type header is missing or not `application/json` | | [`endpoint_not_implemented`](#endpoint_not_implemented) | 501 | A payment-intent operation is not yet implemented for this merchant's provider (capability gate) | | [`endpoint_retired`](#endpoint_retired) | 410 | A permanently retired endpoint. No public API call returns it today | | [`idempotency_replay_incompatible`](#idempotency_replay_incompatible) | 422 | Idempotency key was reused with a different request body | | [`unexpected_redirect`](#unexpected_redirect) | n/a | Raised by the server SDKs, never sent by the API: the SDK got an HTTP redirect and refused to follow it. Do not retry; check the base URL, proxy and network | | [`idempotency_key_required`](#idempotency_key_required) | 400 | An `Idempotency-Key` header is required and none was sent. Today it applies on **refunds** whose payment was taken on a connection keeping no queryable record of a reversal, and on **payment routes** that keep no record of their own. **From the 28 October 2026 cut-off it can apply to any charge** on `POST /v1/payment_intents`. Not retryable as sent. See [below](#idempotency_key_required) | | [`invalid_transition`](#invalid_transition) | 409 | Payment intent is in a state that forbids the requested operation (e.g. capture on `succeeded`) | | [`capture_amount_exceeds_authorized`](#capture_amount_exceeds_authorized) | 422 | `amount_to_capture` is larger than the amount authorized on the intent. The response carries `authorized_amount` | | [`refund_intent_not_refundable`](#refund_intent_not_refundable) | 422 | The parent payment intent is not in a refundable state (refunds require `status: "succeeded"`) | | [`refund_amount_exceeds_remaining`](#refund_amount_exceeds_remaining) | 422 | Requested refund amount is greater than the remaining refundable balance | | [`refund_before_settlement`](#refund_before_settlement) | 422 | The payment has not settled yet, so it cannot be refunded — nothing moved. Wait for settlement, then retry the same request | | [`refund_currency_mismatch`](#refund_currency_mismatch) | 422 | Refund `currency` does not match the parent intent's currency | | [`refund_target_is_duplicate`](#refund_target_is_duplicate) | 422 | The `transaction` names a duplicate record of a payment recorded under another transaction. The response names the one to use in `refund_instead` | | [`refund_target_not_refundable`](#refund_target_not_refundable) | 422 | The `transaction` cannot be refunded through the API. Contact support with its id | | [`payment_method_required`](#payment_method_required) | 422 | This merchant's payment provider requires a vaulted payment method on the request | | [`payment_method_not_found`](#payment_method_not_found) | 404 | The `payment_method.id` does not exist or does not belong to this merchant, or it is a single-use card with no buyer saved more than 24 hours ago | | [`saved_card_buyer_required`](#saved_card_buyer_required) | 422 | A card was to be saved for later use with no buyer identified. Nothing was charged or saved | | [`required_fields_missing`](#required_fields_missing) | 422 | A card payment lacked buyer details your account requires. The buyer was not charged | | [`payment_method_consent_missing`](#payment_method_consent_missing) | 422 | The token was vaulted without off-session consent. Consent is **write-once** — collect the card again rather than trying to update the token | | [`buyer_required_for_saved_card`](#buyer_required_for_saved_card) | 422 | The session's provider saves the card as part of the charge and needs a buyer to save it against. **Nothing was charged.** The buyer is fixed when the session is created and cannot be added afterwards — create a new session with `buyerId`. See [below](#buyer_required_for_saved_card) | | [`buyer_not_found`](#buyer_not_found) | 404 | The buyer id is unknown to this merchant (opaque — same body for a missing id, another merchant's buyer, or a test/live mode mismatch) | | [`buyer_email_conflict`](#buyer_email_conflict) | 409 | A buyer create or update used an email that already belongs to another of your buyers with a different reference. Nothing changed; not retryable | | `mirror_amount_below_line_items` | 400 | The connected-store mirror's line items (plus `shipping`, on Next Commerce) exceed the `amount` being charged. Refused **before** the charge — the store order would be worth more than the money taken. One-sided: charging *more* than the lines itemise is allowed. See [Connected Platforms](../connected-platforms/shopify.md#amount-shortfall) | | [`refund_declined`](#refund_declined) | 422 | The provider declined the refund — **no money moved through this refund.** If `error` says the payment was disputed, the buyer already has the money back through the chargeback: do not retry or refund another way. Otherwise a corrected retry is possible. See [below](#refund_declined) | | [`service_unavailable`](#service_unavailable) | 503 | The endpoint is temporarily unavailable — retry with backoff | | [`feature_unavailable`](#feature_unavailable) | 503 | The capability is deliberately switched off, not briefly down. **Retrying will not clear it** — degrade to your webhook path. See [below](#feature_unavailable) | | [`rate_limit_service_unavailable`](#rate_limit_service_unavailable) | 503 | The rate limiter itself is unavailable — you have not exceeded a limit | | [`charge_at_submit_not_enabled`](#charge_at_submit_not_enabled) | 422 | This session is not eligible for charge-at-submit — vault the card and charge server-side | | [`validation_url_scheme_forbidden`](#validation_url_scheme_forbidden) | 400 | `successUrl` / `cancelUrl` must be HTTPS on a public host in live mode (`localhost` is test-mode only) | | [`webhook_subscription_not_found`](#webhook_subscription_not_found) | 404 | The webhook-subscription id is unknown to this merchant | | [`webhook_subscription_conflict`](#webhook_subscription_conflict) | 409 | A subscription already exists for this URL — update or delete it instead | | [`webhook_subscription_url_immutable`](#webhook_subscription_url_immutable) | 400 | An update sent `url`. A subscription's URL cannot change; nothing was changed. Create a new subscription and delete the old one | | [`webhook_event_not_found`](#webhook_event_not_found) | 404 | The webhook-event id is unknown to this merchant | | [`wallet_domain_not_found`](#wallet_domain_not_found) | 404 | The wallet-domain id is not registered to this merchant | | [`wallet_domain_invalid`](#wallet_domain_invalid) | 422 | Body failed validation — either `domain` is not a bare hostname, or `wallet_type` is not `apple_pay` / `google_pay` | | [`wallet_domain_wrong_type`](#wallet_domain_wrong_type) | 404 | Only Apple Pay registrations host a verification file | | [`wallet_domain_no_binder`](#wallet_domain_no_binder) | 409 | No wallet-capable gateway on the account yet | | [`wallet_domain_rate_limited`](#wallet_domain_rate_limited) | 429 | One verification re-run per domain every 30 seconds — poll for status instead | | [`wallet_domain_provider_unavailable`](#wallet_domain_provider_unavailable) | 503 | The upstream domain-registration provider is unreachable — retry with backoff | | [`mirror_settle_only_disabled`](#mirror_settle_only_disabled) | 400 | `mirror.settle_only` was refused for this request — use `parent_order_ref` with the add-on's `line_items` instead | ### Connected-platform and order-model refusals {#mirror-order-codes} These refuse the request **before any charge**, so the buyer is never charged when one fires — with one exception. A **raw** `mirror` block on `POST /v1/payment_intents` with `destination: shopify` is checked only [partially before the charge](../connected-platforms/shopify.md#direct-charge-mirror-checks): an unconnected store or a missing permission is discovered *after* the payment, so `mirror_shop_not_authorized` and `mirror_shop_missing_scopes` are never returned on that path — you get a completed payment and no store order. The `order` / `mirrorTo` shape is checked before the charge on both routes. Two codes depend on the surface. A field the shared schema refuses at parse — a Shopify `voucher` or `shipping` — comes back as `validation_error` on `POST /v1/sessions` (and a Shopify `shipping` on `order` / `mirrorTo` likewise; that shape has no voucher field at all); only the raw block on `POST /v1/payment_intents` names it with a `mirror_shopify_*` code. Diagnosis for each — the likely cause and the exact check to run — is on [Troubleshooting](../troubleshooting.md). | Code | HTTP | Meaning | |------|------|-------------| | `mirror_malformed` | 400 | The mirror block (`mirror` or `metadata.mirror` on `POST /v1/payment_intents`) arrived as a string or number rather than a nested JSON object — usually a client-side serialization bug | | `mirror_too_large` | 400 | The serialized `mirror` block is over the 4096-byte cap. Trim `line_items`, drop optional customer fields, or move long strings into `metadata` | | `mirror_line_missing_product_ref` | 400 | A mirror line item has no `external_product_ref` — every line needs the connected store's variant id | | `mirror_shop_not_authorized` | 400 | That store host is not connected to this merchant. Connect it in the dashboard, then retry with the exact same host in `mirror.shop` | | `mirror_shop_missing_scopes` | 422 | The store connection exists but lacks the permissions to write orders and customers. Re-grant the connection, then retry | | `mirror_upsell_unsupported` | 400 | Upsell append is not available for Shopify mirrors — drop `parent_order_ref` / `upsell_key` and send the extra item as its own order | | `mirror_shopify_voucher_unsupported` | 400 | Raw block on `POST /v1/payment_intents` only. Shopify does not price store vouchers. Use `mirror.coupon` for a display-only label, or charge the full amount | | `mirror_shopify_shipping_unsupported` | 400 | Raw block on `POST /v1/payment_intents` only. `mirror.shipping` is not carried onto a Shopify order, so the order total would be short by that amount. Fold shipping into a `line_items[]` entry instead | | `mirror_voucher_disabled` | 400 | Store-priced vouchers are not available on this call: on `POST /v1/sessions` always (hosted checkout never re-prices a discounted total), and on `POST /v1/payment_intents` while the platform has them switched off. Use `mirror.coupon` as a label that does not change the total | | `mirror_voucher_amount_mismatch` | 400 | The amount sent is not what the store prices this basket and code at. Quote it first, or omit the amount on a hosted session and let us price it | | `mirror_voucher_rejected` | 400 | The connected store refused the discount code — check it exists and is active there, or drop the voucher and charge the undiscounted total | | `mirror_voucher_unverifiable` | 503 | The store could not price the discount right now. **Retryable** — unlike every other voucher refusal on this list | | `order_total_mismatch` | 400 | The order model does not add up to the charge: line totals plus `order.shipping.amount` must equal `amount`. Coupons are display-only and do not reduce it | | `order_mirror_conflict` | 400 | Both the `order` / `mirrorTo` model and a raw `mirror` block were sent. They describe the same store order two ways — keep one | | `order_model_disabled` | 400 | The `order` / `mirrorTo` model is not enabled for this account. Send a `mirror` block instead, or ask support to enable it | | `upsell_duplicate` | 409 | This `upsell_key` already has a charge on this store. **Nothing was charged again** — treat the original payment named in `original_payment_intent_id` as the outcome. That field is omitted when the earlier payment is in the other mode (live or test) from your key | Codes whose `docs` link names another page are documented there. Rate-limit buckets are on the [Rate Limits](rate-limits.md) page. ## Validation Errors (400) Validation errors include a descriptive message from the schema validator. For most of them the `error` field carries the raw validator output — a JSON array of issue objects, each with the failing field's `path` and a message: > **Exception — [`validation_unknown_field`](#validation_unknown_field).** That code's `error` is a written sentence naming the offending fields, and the body carries an `unknownFields` array alongside it. If you wrote a parser that assumes `error` is always a serialized issue array, it will not handle this one — read `unknownFields` instead. ```json { "error": "[\n {\n \"expected\": \"number\",\n \"code\": \"invalid_type\",\n \"path\": [\n \"amount\"\n ],\n \"message\": \"Invalid input: expected number, received string\"\n }\n]", "code": "validation_invalid_amount", "fix": "Amount must be a positive integer in minor units (cents). 1499 = $14.99", "docs": "https://docs.vonpay.com/integration/create-session#amount-format", "selfHeal": { "retryable": false, "nextAction": "fix_request", "llmHint": "Amount must be a positive integer in the currency's minor unit (cents for USD, pence for GBP). 1499 = $14.99. Decimals or strings are rejected.", "actions": [ { "type": "check_format", "field": "amount", "expected_pattern": "^[1-9][0-9]*$" } ] } } ``` Common validation issues: | Field | Rule | |-------|------| | `amount` | Must be a positive integer (1–99,999,999) | | `currency` | Must be exactly 3 characters | | `country` | Must be exactly 2 characters | | `successUrl` | Must be HTTPS (localhost exempt in sandbox/test mode) | | `lineItems` | Max 100 items | | `metadata` values | Max 500 characters each | For `POST /v1/sessions`, `currency` and `amount` are always required. ## Debugging Every `/v1` response includes `X-Request-Id` (success or error). When contacting support, include this ID for fast issue resolution. ``` X-Request-Id: CezuRjYK_sos ``` The value is a short URL-safe identifier (mixed-case letters, digits, `-`, and `_`) on most routes; webhook-subscription and webhook-event routes return a full UUID instead. --- ## Per-code reference ### auth_missing_bearer **HTTP:** 401. The request did not include an `Authorization: Bearer ` header. Add the header with your `vp_sk_*` or `vp_pk_*` key. (A legacy `vp_key_*` key, treated as a secret key, is also accepted.) ### auth_invalid_key **HTTP:** 401. The API key is malformed, unknown, or has been revoked. Check the prefix (`vp_sk_test_`, `vp_sk_live_`, `vp_pk_test_`, `vp_pk_live_`) and confirm the key exists in `/dashboard/developers/api-keys`. If you just rotated, double-check the grace window hasn't expired. ### auth_key_expired **HTTP:** 401. The key has rotated past its grace window (default 24 hours, configurable per rotation). Distinct from `auth_invalid_key` so SDKs can detect rotation and fetch a fresh key instead of failing the request. Get a fresh key from `/dashboard/developers/api-keys`. ### The `_publishable` variants {#publishable-variants} The browser-callable `/v1/public/*` routes return `_publishable` counterparts of the three key errors above — `auth_missing_bearer_publishable`, `auth_invalid_key_publishable`, `auth_key_expired_publishable`. Same HTTP statuses, same meanings; the self-heal envelope steers you toward a **publishable** key. For `auth_key_expired_publishable`, remember a publishable key reaches the browser inside your built bundle, so replacing it takes a **redeploy**, not an environment-variable change. ### auth_invalid_key_publishable **HTTP:** 401. Check the **environment before the key**. A publishable key is scoped to the host that issued it, and the browser SDK's `apiBaseUrl` option **defaults to production** — it is not inferred from the key — so a session created against one host and a browser left on the default fails every call with this code while the key is valid where it came from. That is why it carries `nextAction: "fix_request"`, not `rotate_key`. Diagnosis: [Troubleshooting → `auth_invalid_key_publishable`](../troubleshooting.md). ### auth_key_type_forbidden **HTTP:** 403. Primary cause: a publishable key (`vp_pk_*`) used against a secret-only endpoint like `GET /v1/sessions/:id`. `POST /v1/sessions` accepts a publishable key, but five of its fields do not: `gateway`, `storedCredentialUse`, `captureMethod`, `setupForFutureUse` and the buyer fields (`buyerId`, `buyerEmail`, `buyerName`, `buyer`) each decide something about whose money moves or where a card is stored, so a publishable-key request carrying any of them is refused rather than having the field dropped. Also fires on sandbox/live-mode mismatches, in both directions: a sandbox key attempting to create a live payment session, and a **live key used on a sandbox account** (`POST /v1/sessions`, `POST /v1/payment_intents`, `POST /v1/tokens` and `GET /v1/capabilities`). A sandbox account holds test keys only; a live key there is refused outright and is never simulated. The `fix` field on the response tells you exactly what to switch to. ### merchant_not_onboarded **HTTP:** 403. You tried to create a live-mode API key before onboarding review completed. The message reads "Your account is being reviewed. Live keys unlock once we approve it.", or "This account is not approved for live payments." if the application was declined. Test keys come from your business's [sandbox](../guides/sandbox.md) at any time; live keys can be created once the account is ready to transact. Distinct from `auth_merchant_inactive` (401), which fires on a previously-approved account that has since been deactivated. A live key needs approval **and** a connected payment processor. This code covers the first condition only; [`live_keys_need_processor`](#live_keys_need_processor) covers the second. ### live_keys_need_processor {#live_keys_need_processor} **HTTP:** 403. The account is approved, but no payment processor is connected to it yet, so a live key would have nothing to charge against. Connect one and live keys unlock; no second approval is needed. Rotating an existing live key is not gated. ### test_keys_are_sandbox_only {#test_keys_are_sandbox_only} **HTTP:** 403. Test keys were requested on a live business. Test keys belong to the business's [sandbox](../guides/sandbox.md): open **Developers**, activate the sandbox, and create them there. Test keys you already hold on the live business keep working. ### live_keys_not_on_sandbox {#live_keys_not_on_sandbox} **HTTP:** 403. A live key was requested on a sandbox. Switch to your live business to create live keys. Live keys you already hold keep working. ### processor_check_unavailable {#processor_check_unavailable} **HTTP:** 503. We could not confirm the account's payment setup in time. This is our side, not your request: nothing about the account has changed and no decision has been made. Retry shortly. A repeat is worth reporting to support. ### auth_merchant_inactive **HTTP:** 401. The merchant account has been disabled or suspended. Check `/dashboard` for status banners; contact support if unexpected. ### auth_service_unavailable **HTTP:** 503. The authentication service is temporarily unavailable. This is retriable — the SDK auto-retries with backoff. ### session_not_found **HTTP:** 404. The session ID does not exist. Sessions are scoped to the merchant; you cannot look up another merchant's session with your key. Confirm the ID was created with the same key mode you're now querying with. ### checkout_address_not_found **HTTP:** 404. The request reached a `{name}.vonpay.com` address that is not serving checkout. Send it to the host of the `checkoutUrl` that `POST /v1/sessions` returned, and use that URL exactly as returned: it is on `checkout.vonpay.com` unless the merchant has turned on a branded address. Building the hostname yourself is what produces this error, and retrying the same host returns it again. ### session_expired **HTTP:** 410. The session expired (TTL configurable at create — default 30 minutes, range 5 minutes to 7 days), **or** its status is `failed` or `cancelled`. A session reopened for another card after a decline is `pending`, not `failed`, and does not get this error. Create a new session. (If the session already *succeeded*, you get `session_already_completed` — HTTP **409** — instead; see below.) ### session_already_completed **HTTP:** 409. A completion call (`POST /v1/public/tokens` or `…/charge`), or a session retrieve, hit a session that is **already finished or still finishing**. The code covers two situations and does not tell you which one you are in: - the session already reached `succeeded`, so the buyer was charged exactly once (on a `captureMethod: "manual"` session, authorised exactly once: read `charged` on the response); or - **a charge for this session is already in flight** and your request lost the race to it. That charge has not finished, and it may still decline. **Do not** retry, and **do not** create a new session for the same purchase: either would risk charging the buyer again. The right next step is the same for both situations, so you never need to distinguish them here. **Read the outcome** rather than assuming it, from your `payment_intent.succeeded` / `charge.succeeded` / `charge.failed` webhook, and show the buyer whatever it reports. That may be a decline. Do not treat this code by itself as proof of payment. Polling `GET /v1/public/sessions/:id` does not settle it either: while a charge is in flight that endpoint answers `200` with no charge-status field of its own, so a completed payment and an unfinished one read alike from the browser. Your server sees the real outcome on the webhook. This is a distinct **409 Conflict**, not `session_expired`'s **410 Gone**, so even a status-only client will not "start over" into a re-charge. Branch on the `code` for the remediation. ### charge_in_progress **HTTP:** 409. A payment for this request is already under way, has already completed, or has an outcome we cannot confirm yet, and this request was refused so it is not taken twice. The code is keyed on one of two things, depending on the surface: | Surface | What the code is keyed on | Read this to resolve it | |---|---|---| | Hosted checkout / Embedded Fields | **the session** | Retrieve the session, or wait for the `charge.*` webhook | | `POST /v1/payment_intents` | **your `Idempotency-Key`** for this merchant | Read the original payment's status | :::danger Do not retry, do not create a new session, do not reissue with a fresh `Idempotency-Key` On some provider configurations a second attempt becomes a **second charge**, and once both land nothing can merge them. A `409` here is not a key collision to route around: an `Idempotency-Key` names the **operation**, never the **attempt** — see [Idempotency](../integration/idempotency.md). ::: On `POST /v1/payment_intents`, depending on your account's payment connection, it has up to three causes. Each one means **do not create another payment for this order**: | Cause | What the `error` message tells you | |---|---| | Your `Idempotency-Key` was already used for a charge | Whether that charge is still running, has completed (approved or declined), or may have reached the buyer's card with no confirmed outcome. In that last case, contact support before doing anything else | | This request reached the payment provider, and its outcome is not confirmed yet | The buyer may have been charged. A charge that settled for a different amount than requested is reversed automatically where we can identify it; where we cannot, our team is alerted and reverses it by hand. Contact support with the payment intent id to confirm | | An earlier charge on the same saved card has not finished resolving | Nothing was sent for this request. Do not charge the card again until the earlier payment resolves | When we can identify the earlier payment, the response carries `original_payment_intent_id` and the message names it: read it with `GET /v1/payment_intents/{id}`. It is omitted when it could not be read, or when that payment is in the other mode (live or test) from your key, so treat it as optional. **If you never received the original response and the 409 carried no `original_payment_intent_id`** — the call reached us, the connection dropped, and you hold a key but no id — there is no lookup by `Idempotency-Key`, and the `payment_intent.*` webhooks carry the payment id rather than your key. Design for it: send your own order reference in `metadata` on every charge and record the `Idempotency-Key` against your order *before* you send the request, so a dropped response is recoverable from your records plus [reconciliation](../integration/reconciliation.md). If it has already happened, stop and contact support with the `Idempotency-Key` you sent; a retry with a fresh key cannot resolve it. The same applies if the code persists beyond about a minute. The browser SDK surfaces the session-scoped condition as `frame_charge_in_progress` — see [Embedded Fields — errors](../embedded-fields/errors.md). **A different code, one word apart** — [`charge_already_in_flight`](#charge_already_in_flight) is a 409 you **do** retry, with the same key: | | `charge_already_in_flight` | `charge_in_progress` | |---|---|---| | **Retry?** | **Yes** — same body, same key | **No** — do not retry, do not reissue | | **What to do** | Wait the advertised interval, send it again | Read the original payment's status, or wait for the `charge.*` webhook | --- ### charge_already_in_flight **HTTP:** 409. **Retryable — but only with the *same* `Idempotency-Key`.** Another request carrying this key is still running, or an earlier one stopped before we could record its outcome; we refused rather than send a second charge for the same key. **This request did not charge the buyer.** Wait `retryAfterSeconds` (carried on the response) and retry with the **same request body and the same `Idempotency-Key`**. A fresh key is the one action that turns this into a double charge — an earlier attempt on the existing key may already have charged the buyer. When we know which earlier attempt holds the key, and it is in the same mode (live or test) as your key, the response also carries `original_payment_intent_id`: read that payment with `GET /v1/payment_intents/{id}`. If the code keeps coming back for **more than about a minute** and no id was returned, stop: there is no lookup by `Idempotency-Key`, so contact support with the key you sent. Contrast with [`charge_in_progress`](#charge_in_progress), which you do not retry at all. --- ### charge_reservation_unavailable **HTTP:** 503. **Retryable — with the same `Idempotency-Key`.** The durable record we write for your key before charging could not be written, so the request was refused. **The buyer was not charged — nothing was sent to the payment processor**, so there is no in-flight attempt to collide with. Retry after `retryAfterSeconds` with the same request body and the same key. A charge sent **without** an `Idempotency-Key` is reserved too and can get this code. Retry the same body, and send a key this time: without one, a retry is treated as a new payment. See [Idempotency](../integration/idempotency.md). --- ### expected_amount_mismatch {#expected_amount_mismatch} **HTTP:** 409, from a wallet payment (`POST /v1/public/tokens`) or a card payment (`POST /v1/public/sessions/{sessionId}/charge`). **The buyer was not charged**: the refusal happens before anything reaches the payment provider. `expected_source` says why: - `buyer_approved`: the `expected_amount` sent with the payment (the total shown to the buyer) differs from the session's current total, usually because it was changed with [`PATCH /v1/sessions/{id}`](api.md#update-session-total) after the buyer saw it. - `request_start`: no `expected_amount` was sent, and the total changed while the request was being processed. The response also carries `expected`, `actual` (the session's current `amount` and `currency`) and `mismatched` (`["amount"]`, `["currency"]` or both). Show the buyer the current total and have them approve it again. Not retryable as-is. The `express-checkout` element reports this to your page as `frame_wallet_total_mismatch`. --- ### session_not_expirable {#session_not_expirable} **HTTP:** 409, from `POST /v1/sessions/{sessionId}/expire`. **Nothing changed, and this is not a safe signal that no money moved.** A session is expirable while it is `pending`, `unpaid`, and carries no recorded payment, and a declined `elements` + `chargeAtSubmit` session can be closed once it has been quiet for 10 minutes. On a declined session the response carries a `reason`: `recent_activity` (with `retryAfterSeconds` and a `Retry-After` header) means call again after that many seconds; `payment_may_be_in_flight` may never clear, and an unrecognised `reason` means the same. Any other state returns this code, including a session that expired while a payment was in progress (`expiredFrom: "processing"`) or before the platform recorded how sessions expired (`expiredFrom: null`). A payment may exist for those that has not been reported yet. Do not take payment for the order another way after a `409`, and **do not rely on polling the session or on a webhook** to tell you what happened: there is no session-expiry event, and otherwise a payment that lands on an already-expired session is not reported today. On an `elements` + `chargeAtSubmit` session, `expiredFrom: "processing"` can later change to `failed` once the provider confirms the payment was never authorized after a timed-out bank check, and that attempt's `payment_intent.failed` / `charge.failed` webhooks announce it. Read the `session` object in the response, then contact support with the session id. Contrast `200`, which does mean the session is closed and nothing can start on it. See [Close a session so you can charge another way](api.md#expire-session). --- ### session_not_modifiable {#session_not_modifiable} **HTTP:** 409, from `PATCH /v1/sessions/{sessionId}`. **Nothing was changed.** The response carries `reason` and a `session` object (`id`, `status`, `paymentStatus`): - `not_open`: a payment is in progress, the session was paid, held, declined or expired, or it has passed its time limit. Past its time limit, the echoed `session` can still read `pending` and `unpaid` for a short while. Do not take payment for the same order another way while a payment is in progress. - `integration_mode`: only sessions created with `integrationMode: "elements"` can change their total, because hosted and embedded-form sessions fix it when the payment form loads. Create a new session with the new total. - `setup_session`: a no-charge setup session has no total. - `store_order`: the session is linked to a connected-store order, whose total is the store's. Change the order instead. - `in_page_bank_check`: the buyer's bank checks the amount on the page before paying, and the charged total must match the checked one. Create a new session with the new total. Not retryable. See [Change an open session's total](api.md#update-session-total). --- ### session_wrong_state **HTTP:** 409. The session is in a state that forbids this operation (e.g. the session already `succeeded` and cannot be cancelled). Read the response body — the `fix` field describes the allowed state transitions. ### session_integrity_error **HTTP:** 500. Internal session state mismatch. Capture the `X-Request-Id` and contact support — not safely retriable without investigation. ### currency_not_supported **HTTP:** 422. The request named a currency that is not accepted for new payments yet: today IQD, LYD, ISK, MGA, CLF, UYW and UYI. How many decimal places their amounts carry is not yet confirmed, and a wrong guess would show or pass on an amount 10× or 100× off what was charged, so they are refused instead. **Nothing was charged, saved or created.** The body carries `currency`, the refused code. Returned by `POST /v1/sessions`, `PATCH /v1/sessions/{id}` and `POST /v1/tokens` when they send `currency`, and by `POST /v1/payment_intents` and `POST /v1/mirror/quote`. Payments already made in these currencies can still be read, captured, voided and refunded. Not retryable as sent: use a different currency, or contact support if you need this one. ### validation_error **HTTP:** 400. The request body failed schema validation. The response `error` field contains the validator's output — a JSON array of issue objects, each carrying the `path` to the bad field (for example `path: ["amount"]`) and a message such as `Invalid input: expected number, received string`. On `POST` and `PATCH /v1/webhook_subscriptions` it is also the answer when the `enabledEvents` list contains any unrecognised event name: the message is the fixed sentence "verify the URL and that every event type is a recognized event", nothing is stored, and no signing secret is issued. See [Webhook events](../integration/webhook-events.md#forward-compatibility). On `POST /v1/payment_intents` it is also the answer to an `Idempotency-Key` starting with `vp_sys:` (any letter case), a prefix reserved for platform use: nothing is charged, and the message is "Idempotency-Key values starting with 'vp_sys:' are reserved for platform use. Choose a different key and retry." Send the request again with a different key. ### validation_missing_field **HTTP:** 400. A required field is missing. See [Create a Session](../integration/create-session.md) for the required fields. ### validation_unknown_field **HTTP:** 400. The request body carried one or more fields the endpoint does not accept. The `error` string names them in a readable sentence and `unknownFields` lists them. This fires on **every** endpoint with a strict schema — payment intents, refunds, tokens, buyers, webhook subscriptions, sessions. **Unknown fields are refused, never ignored.** Two causes, in rough order of frequency: 1. **Casing.** The session surface is camelCase — `success_url` → `successUrl`, `line_items` → `lineItems`, `unit_amount` → `unitAmount`. Where a `suggestions` map is present it gives the canonical name for each offending key. 2. **Right field, wrong endpoint.** The field is real but belongs on a different call, and the `error` string says where. The most common instance: `setup_for_future_use` sent to `POST /v1/payment_intents`. Reuse consent is captured when the card is **vaulted** and read back automatically at charge time, so the charge call refuses it. See [Preventing a double charge → common mistake](../integration/payment-intents.md#common-mistake--setup_for_future_use-does-not-belong-here). Do not retry until the field is moved or renamed — `retryable: false`, `nextAction: fix_request`. > Wanting to attach arbitrary custom data? Nest it under the top-level `metadata` object, which is stored and round-tripped verbatim. ### validation_invalid_amount **HTTP:** 400. Amount is not a positive integer, is zero, or exceeds the 99,999,999 maximum. Remember: amounts are in **minor units** — `1499` = $14.99, not $1,499. ### merchant_not_configured **HTTP:** 422. The merchant has not completed onboarding for this operation — usually payment-provider credentials are not yet provisioned. Complete boarding via the merchant dashboard, or contact support. ### binder_unavailable **HTTP:** 409. The merchant has no payment provider configured for this operation, so the request cannot be routed to one. The merchant (not the integrator) must complete onboarding to attach a payment provider. A test key does not get around it: a test payment runs on the account's own provider test environment, so an account with no provider cannot take test payments either. To build before onboarding is done, the account holder applies for a sandbox account in the dashboard (see [`sandbox_account_required`](#sandbox_account_required)). Not retriable as-is. ### sandbox_account_required {#sandbox_account_required} **HTTP:** 422. Test payments run only on a sandbox account backed by a payment provider's test environment. This request had no test environment to reach, for one of two reasons: | You sent | On | Do this | |---|---|---| | A test key (`vp_sk_test_*`, `vp_pk_test_*`) | A live merchant account | Use your sandbox account's test keys. A live account's test keys cannot take a test payment | | A test key | A sandbox whose processor test account is not set up yet | Open the sandbox in Developer Tools and click **Set up dashboard access**, which finishes setting it up, then send the request again | Returned by `POST /v1/sessions`, `POST /v1/payment_intents`, `POST /v1/tokens`, `GET /v1/capabilities` and the browser session, token and checkout calls. Nothing was charged. Retrying, or switching between test and live keys on the same account, will not help, and a live key on a sandbox account is refused with [`auth_key_type_forbidden`](#auth_key_type_forbidden). ### capability_not_supported **HTTP:** 422. The merchant's payment provider does not support the operation you invoked — for example partial refunds, ACH, or network tokens — even though the route exists. Call `GET /v1/capabilities` for the merchant's `supported_operations` matrix and gate capability-dependent calls on it; the matrix is provider-dependent. Not retriable without a different capability or provider. It is also returned by `POST /v1/sessions` when the request asks for `captureMethod: "manual"` on an account whose payment connection cannot hold a payment for later capture. That refusal happens before any session exists, so nothing is charged. Create the session with automatic capture, or contact support about a connection that can hold. ### rate_limit_exceeded **HTTP:** 429. The generic rate-limit code, emitted on the IP-axis limiter (and the global primary limiter). Wait the `Retry-After` interval (1 to 60 seconds; the same number is in `selfHeal.actions[].retryAfterSeconds`), then retry with jittered exponential backoff. The SDK auto-retries up to `maxRetries` times. The per-API-key axis has its own distinct code, `rate_limit_exceeded_per_key`. On `POST /v1/sessions` this per-IP limit (10/min) applies only to calls with a publishable key or no key. Create sessions from your server with a secret key (`vp_sk_*`, or a legacy `vp_key_*`): those calls are limited per key, plus a loose 300/min per IP, so integrations sharing one outbound address (serverless or edge workers) do not share a quota. See [Rate Limits](rate-limits.md). ### rate_limit_exceeded_per_key **HTTP:** 429. Per-API-key bucket exceeded on `POST /v1/sessions` (30 session creates/min). Wait the `Retry-After` interval (1 to 60 seconds), then retry with jittered exponential backoff. A single key should not exceed this under normal traffic. If your integration legitimately does, contact support for a ceiling increase. Distinct from `rate_limit_exceeded` so SDKs can tell them apart. ### provider_unavailable **HTTP:** 502. Upstream payment provider is not responding. ⛔ **Retriable on a charge only with the same `Idempotency-Key`.** A 502 does not mean nothing happened — the provider was called and the response was lost, which is why the charge is recorded server-side as outcome-unknown. Send the retry with the **same** key you used the first time and it collapses onto the original attempt. Without a key there is nothing to collapse onto, and not every connection provides idempotency of its own, so a keyless retry can charge the buyer twice. The SDKs apply the same rule to their built-in retry: they withhold it on money-moving calls unless you supplied a key. On a capture or void, check `code` before retrying: a 502 carrying [`payment_outcome_unknown`](#payment_outcome_unknown) means the provider accepted the operation, and it must not be retried. :::danger On a refund, this is "state unknown" — not "it didn't happen" On `POST /v1/refunds` a `provider_unavailable` (or a timeout) does **not** tell you the reversal was refused. It may already have gone through. **Retrying it can pay the buyer twice.** [`GET /v1/refunds`](refunds.md#list-refunds) lists the refunds that were recorded, but an ambiguous refund may not appear there, and it does not arrive as a [`refund.failed`](../integration/webhook-events.md#refundfailed) webhook today. Record the refund as **unresolved** in your own ledger and settle it with support rather than re-issuing it. ::: ### payment_outcome_unknown {#payment_outcome_unknown} **HTTP:** 502, from `POST /v1/payment_intents/{id}/capture` or `POST /v1/payment_intents/{id}/void`. The payment provider **accepted** the operation, but its result could not be recorded. The body carries `capture_outcome: "unknown"` or `void_outcome: "unknown"`. The money may have moved (capture), or the hold may already be released (void). ⛔ **Do not retry.** A retried capture can capture twice. This is the opposite of [`provider_unavailable`](#provider_unavailable), the other 502 these routes return, which may be retried with the same `Idempotency-Key`: branch on `code`, never on the status alone. Read the payment intent back with `GET /v1/payment_intents/{id}`; the payment webhook reports the final outcome. Do not tell the buyer a hold was released until the intent confirms it. The response's `selfHeal.nextAction` is `reconcile`, and the server SDKs never retry this code on their own from 2.11.0. ### provider_attestation_failed **HTTP:** 403. The payment provider rejected the session-bound attestation token during the charge step. The most common cause is amount or scope drift between session create and complete — the attested amount or scope no longer matches the session. Fix: verify the session amount matches the attestation payload, or create a new session and re-attest. Not safely retriable without investigation when the cause is a scope / integrity mismatch — capture the `X-Request-Id` before retrying. The specific provider-native reason (e.g. `ATTESTATION_EXPIRED`, `ATTESTATION_INVALID`, `ATTESTATION_MERCHANT_MISMATCH`) is included in the error `message` so the buyer can be shown a more specific hint in your UI. ### provider_charge_failed **HTTP:** 402. The upstream payment provider returned a terminal charge failure — card declined, insufficient funds, fraud-rule block, or a network-side decline (issuer, scheme, or processor). Fix: prompt the buyer to try a different payment method. Retrying the same card/session is unlikely to succeed and may trigger additional issuer-side flags. This is distinct from `provider_unavailable` (transient infrastructure) and `transaction_verification_failed` (post-charge reconciliation mismatch). ### provider_request_rejected **HTTP:** 422. The merchant's payment provider rejected the request as invalid **before** any money movement was attempted — this is neither a card decline (`provider_charge_failed`, 402) nor a transient outage (`provider_unavailable`, 502). It is a permanent request-validation failure: retrying the identical body will fail again, and because the buyer's card was never the problem, **sending them for a different card fails identically**. Inspect the fields a provider most commonly constrains beyond our own schema — `currency` (must be enabled on the merchant's provider account), `amount` (within the provider's minimum/maximum for that currency), and any statement-descriptor or `metadata` values (provider length/character limits) — then retry with corrected values. If *every* charge on this merchant fails this way, the merchant's provider configuration is likely incomplete; capture the `X-Request-Id` and escalate. Not retriable as-is. **On `POST /v1/public/sessions/{sessionId}/charge` (charge at submit)** this code has one dominant cause: the provider had **no 3-D Secure return target**. The hosted challenge needs somewhere to send the buyer back to, which comes from the session's `successUrl`. Create the session with a `successUrl` and retry. The card was **neither charged nor declined**. See [Charge at submit → 3-D Secure](../embedded-fields/charge-at-submit.md#3-d-secure). **On `POST /v1/tokens`** this code covers provider rejections during vaulting. The `422` is permanent — correct the request rather than retrying. ### payment_method_consent_missing **HTTP:** 422. Your server attempted a merchant-initiated charge (`mit.initiator: "merchant"`) against a token whose `setup_for_future_use` is `null` or `"on_session"` — the buyer never consented to off-session reuse. Consent is **write-once**: it is recorded at vault time and no endpoint updates it, so retrying with the same token cannot succeed. The card must be collected **again**, with `setup_for_future_use: "off_session"` sent on the call that vaults it: | Your flow | Send it on | |---|---| | Embedded fields, charging at submit | `POST /v1/public/sessions/{id}/charge` | | Embedded fields, vault without charging | `POST /v1/public/tokens` | | Server-to-server, already holding a provider-side card handle | `POST /v1/tokens` | `POST /v1/tokens` is the wrong endpoint for the common case — for a card collected by embedded fields it needs a provider-side handle you don't hold. Use the route that collected the card. Obtain explicit buyer consent before that call — the consent record must match what the buyer agreed to — then charge the new token normally. `retryable: false`, `nextAction: fix_request`. Full model: [Tokenization → reusability](../embedded-fields/tokenization.md#reusability--setup_for_future_use). ### internal_error **HTTP:** 500. Unexpected server error. Capture the `X-Request-Id` and contact support. ### webhook_missing_signature **HTTP:** 401. Answered to a payment **provider** whose call to our inbound webhook endpoint arrived without its signature header — not something your integration receives. ### webhook_invalid_signature **HTTP:** 401. Two meanings, one code. Raised **to you** by the server SDK's `webhooks.constructEvent` / `construct_event` when your verification of a webhook we sent fails — wrong `whsec_*` secret (never your API key), body parsed before hashing, or a timestamp outside the window (5 minutes past, 30 seconds future); diagnosis on [Troubleshooting](../troubleshooting.md#webhook_invalid_signature--http-401). Answered to a payment **provider** when its call to our inbound endpoint carries a signature that does not match. ### webhook_not_configured **HTTP:** 503. The verification secret for an inbound provider webhook is not configured on our side — answered to the provider, not to you. Contact support with the `X-Request-Id`. ### webhook_test_delivery_failed **HTTP:** 502. A synchronous webhook *test-send* probe ran but the delivery could not be completed — a transport error or timeout reaching your subscription endpoint. The API marks it **retriable**: calling the API directly, retry with exponential backoff starting at 3 seconds, and if it persists, confirm the subscription endpoint is reachable. Note the distinct outcome: if your endpoint *is* reached but returns a non-2xx, that comes back as a `200` with `delivered: false` and the endpoint's `response_status` — **not** this code. The server SDKs (3.6.0 and later) answer this differently: they do not repeat the call automatically, and their help for this code says `fix_input`, because a repeat usually fails the same way, so check the endpoint and then send the test again yourself. ### origin_forbidden **HTTP:** 403. The endpoint is internal and only callable from the hosted checkout page; an external caller hit it directly. If you are building a server-side integration, use the public API (e.g. `POST /v1/sessions`) instead of the internal checkout endpoints. ### transaction_verification_failed **HTTP:** 403. Transaction could not be verified with the payment processor. Contact support with the `X-Request-Id` — this is not safely retriable without investigation (a transaction either exists on the processor or it doesn't). ### unsupported_media_type **HTTP:** 415. The `Content-Type` header is missing or not `application/json`. This check is enforced on the browser-facing public endpoints — `POST /v1/public/tokens` and the public confirm endpoint — so set `Content-Type: application/json` when calling them. (A trailing `; charset=…` parameter is tolerated.) ### endpoint_not_implemented **HTTP:** 501. The requested payment-intent operation is not available for this merchant's payment provider. Read `client.capabilities.get()` and check `supportedOperations` before invoking optional operations like `paymentIntents.capture`, `paymentIntents.void`, or `refunds.create` with partial amounts. Not retriable — the operation will not succeed without a different capability or provider. ### endpoint_retired {#endpoint_retired} **HTTP:** 410. Returned by an endpoint that has been permanently retired: today, only the dashboard's former "Create test session" tool, so no public API call returns it. Do not retry it. To run a test payment, create a checkout session with your sandbox account's test keys. ### idempotency_replay_incompatible **HTTP:** 422. The same `Idempotency-Key` was sent with a different request body. For payment-intent creation, the server compares the new request against the original on `amount`, `currency`, and `capture_method`; if any of those differ, the replay is rejected to prevent silent state corruption. Either retry with the original values or generate a fresh idempotency key for the new request. ### idempotency_key_required **HTTP:** 400. The request carried no `Idempotency-Key` header where one is required. It has **two separate triggers**, on two different surfaces, for the same reason — there the header is the only duplicate guard available: **On a refund** — it fires only when the payment being refunded was taken on a **provider connection whose provider keeps no queryable record of a reversal**. On that kind of connection our own record is the only evidence a refund happened, so without the header a repeated call creates a new refund record, a new downstream request, and a **second real reversal to the buyer**. Every other provider connection still accepts a refund with no key. **On a charge** — today it fires on **payment routes that keep no queryable record of their own**, where our key is the only thing standing between a retry and a second charge to the buyer. **From the 28 October 2026 cut-off any keyless charge** to `POST /v1/payment_intents` can be refused with it, before anything is reserved or charged. Until then a keyless charge still succeeds, and its response carries `Deprecation: true`, `Sunset: Wed, 28 Oct 2026 00:00:00 GMT` and a `Link` header pointing at this entry. A `Sunset` header on your charge responses means that call needs a key. See [Idempotency](../integration/idempotency.md#idempotency-required). **Not retryable as sent, on either surface.** The identical keyless request fails identically, forever. The header has to be added first. **The fix:** send one stable value per logical operation and reuse it verbatim on every retry of that operation. A UUID is fine, as is `_`. Use a fresh value only for a genuinely different operation — a second, intentional refund of the same payment, or a different charge. ⛔ **Do not vary the key to get past an error.** Adding a key satisfies *this* code; changing one between retries is what causes the duplicate both triggers exist to prevent. If you are already sending a key and getting a `409` [`charge_in_progress`](#charge_in_progress), the answer is *not* a new key. Send a key on every connection and every route, not just where it is enforced — see [Refunds](refunds.md). --- ### invalid_transition **HTTP:** 409. The payment intent is in a state that forbids the requested operation — e.g., trying to `capture` an intent that's already `succeeded`, or `void` one that's already `voided`. The error envelope carries `current_status` (the intent's actual state) + `reject_reason` — a discriminator the SDK exposes as `currentStatus` / `rejectReason` — so you can branch without a follow-up retrieve. `reject_reason` takes one of five values: | Value | What it means | |---|---| | `intent_not_found` | No intent with that id for this account | | `terminal_state` | The intent is in a state the operation cannot act on | | `invalid_transition` | The operation is not legal from the current state | | **`concurrent_update`** | **Another capture or void is in flight for this intent right now** | | `lookup_failed` | The intent could not be read to decide | **`concurrent_update` is the one to add a branch for** — it is how you detect a race between two operations on the same payment. Treat it as "unknown outcome, retry the read", never as a terminal state: a payment still in flight read as terminal is how a live payment gets cancelled or refunded. A value the server SDK does not recognise arrives as `rejectReason: undefined` — treat that the same way. The others are not retriable as-is — fix the request (e.g., call `refunds.create` instead of `paymentIntents.void` once the intent has captured). ### capture_amount_exceeds_authorized **HTTP:** 422. The `amount_to_capture` you sent is larger than the amount authorized on this payment intent. Nothing was captured. You can capture **less** than was authorized — that is a partial capture and it is supported. You cannot capture more. **Decide the amount before you capture: a partial capture is final on that intent.** There is no incremental capture. Capturing less moves the intent straight to `succeeded` with `amount` set to what you took, and any further capture on it is rejected as [`invalid_transition`](#invalid_transition) — the difference can only be collected as a new charge. If the final amount is not known yet, capture once you know it. The error response carries `authorized_amount`, so you can re-issue immediately with `amount_to_capture` at or below that figure — or omit the field entirely to capture the full authorized amount. Not retriable unchanged; fix the amount first. ### refund_intent_not_refundable **HTTP:** 422. The payment intent named in `payment_intent` is not in a refundable state — refunds require the parent intent to have `status: "succeeded"`. An intent in `requires_action` / `authorized` / `captured` / `failed` / `voided` cannot be refunded: call `/capture` or `/void` instead, or — if the intent failed — no refund applies because no money moved. Check the parent intent's current status before retrying. Not retryable as-is. The same code refuses a refund on a payment whose settlement records a **dispute or chargeback**, with `current_status` reading `disputed` or `chargeback` even though the intent itself reads `succeeded`. The buyer gets the money back through the dispute, so do not refund it another way. --- ### refund_amount_exceeds_remaining **HTTP:** 422. The requested refund amount is greater than what's left to refund on this intent. The error envelope carries `remaining_refundable` (the captured amount minus what's already been refunded) so the SDK / agent can re-request with a valid amount, or you can omit `amount` to refund the full remaining balance. Fix the input and retry. --- ### refund_before_settlement **HTTP:** 422. The payment has not **settled** yet, so there is nothing to send back and the provider refuses the refund. **No money moved and the buyer was not refunded.** **Wait for settlement** — usually the provider's overnight batch, however long ago the capture succeeded — then retry the **same** refund request unchanged. The refund is recorded as `failed`, so no refundable balance was consumed. Retrying immediately fails identically. - **Do not treat it as a completed refund** — nothing was refunded. - **Do not void or cancel the payment as a workaround.** Any payment that can reach this error has been captured, so the cancel call rejects it with `invalid_transition`. --- ### refund_target_is_duplicate **HTTP:** 422. The `transaction` you named is a duplicate record of a payment that is recorded under a different transaction, so refunding it would not reverse the payment the buyer actually made. The response names the transaction to use instead in `refund_instead`. Refund that one. Not retryable as-is; retrying with the same `transaction` fails the same way. --- ### refund_target_not_refundable **HTTP:** 422. The `transaction` you named cannot be refunded through the API. This is a property of that payment, not of your request, so a retry will not change it. Contact support with the transaction id; do not refund the buyer another way first, or you risk paying them twice. --- ### refund_currency_mismatch **HTTP:** 422. The `currency` in the refund request differs from the parent intent's currency. Refunds always settle in the original capture currency — either drop the `currency` field (it defaults to the intent's currency) or supply the matching ISO-4217 code. Fix the input and retry. ### refund_declined **HTTP:** 422. **No money moved and the buyer was not refunded.** The provider accepted the request and then resolved it to a terminal failure without raising an error — a synchronous decline. Read this as *the refund did not happen*, not as "the refund is pending". The refund is recorded as `failed`, so the intent's refundable balance is untouched. **Read `error` first.** If it says the payment was disputed, the buyer's bank has already returned the money through the chargeback. Do not retry, and do not refund the buyer any other way: they would be paid twice. Otherwise a corrected retry is possible once the cause below is fixed. Common causes, in the order worth checking: the processor sub-account has insufficient balance to fund the refund; the original card is closed; or a fraud or compliance hold is blocking refunds. All three are gateway-side — retrying the same call unchanged will decline again. Resolve the cause on the gateway account first, then retry. ### buyer_required_for_saved_card **HTTP:** 422. **Nothing was charged.** This session's provider vaults the card as part of taking the payment, and a vaulted card has to be saved against a buyer — but this session has no buyer attached. **Retrying cannot succeed.** The buyer is fixed when the session is created and cannot be added afterwards, so the same call will fail the same way however many times you send it. Create a **new** session with `buyerId` set, and have the buyer enter the card again. In the browser SDK this arrives as a **thrown** `frame_buyer_required_for_saved_card` — your `submit()` rejects. Do not confuse it with `frame_consent_requires_buyer`, the same missing buyer on a gateway that charges anyway: that one never throws and **the buyer has been charged**. Branch on the code — see [the two codes](../embedded-fields/errors.md#buyer-action-errors-surface-a-message) and [Recurring & saved cards](../guides/recurring-and-saved-cards.md). --- ### payment_method_required **HTTP:** 422. This merchant's payment provider requires a previously-vaulted payment method on every charge, but the request omitted one. Use the two-step flow: (1) `POST /v1/tokens` with the buyer's payment data to vault it, then (2) `POST /v1/payment_intents` with `payment_method: { id: }`. Some providers also support a browser-side confirmation flow (omit `payment_method`, then confirm in the buyer's browser) — call `GET /v1/capabilities` to see whether the merchant's current provider supports that path. Not retriable as-is. ### payment_method_not_found **HTTP:** 404. The `payment_method.id` is unknown to this merchant. Verify the id was not truncated, that it was created against the same merchant whose API key you're using, and that the test/live mode matches the key prefix. Payment-method ids are scoped to the merchant; you cannot reference another merchant's token. Not retriable as-is — fix the id and retry. It is also returned for a card saved **single-use** (no `setup_for_future_use`) with **no buyer** identified, once it is more than 24 hours old: such a card can only be charged in the window right after it was saved. To charge a card again later, save it with `setup_for_future_use` on a session that identifies the buyer (`buyerId`, `buyerEmail` or `buyer`). ### saved_card_buyer_required **HTTP:** 422. A request asked to save a card for later use (`setup_for_future_use`) but identified no buyer, so the card would have no owner. This check is off by default; when it is on, it is returned only where nothing is being charged: `POST /v1/tokens`, and saving a card without charging it through `POST /v1/public/tokens`. **Nothing was charged and nothing was saved.** Where the buyer is charged and the card saved in the same step, the payment is never refused for this: the card is saved for that payment only and `setup_for_future_use` comes back `null`. Identify the buyer: `buyerId`, `buyerEmail` or a `buyer` object on `POST /v1/sessions` (then collect the card again on that session), or `buyer_id` / `buyer` on `POST /v1/tokens`. If the card is only for this payment, omit `setup_for_future_use`. ### required_fields_missing **HTTP:** 422. A card payment (`POST /v1/public/sessions/{id}/charge`) was missing buyer details your account requires, so **the buyer was not charged** (`charged: false`). This check is off by default; when on, it applies to every card payment. `missing_fields` lists required fields never supplied and `invalid_fields` lists supplied values that are unusable, such as a phone number with too few digits. Field names are `name`, `email`, `phone`, `shipping_address` and `billing_address`. The requirements are your dashboard's buyer-field settings, fixed onto the session when it was created, and the details are read from the session. Set them at create (`buyerName`, `buyerEmail`, and `buyerContact` for phone and addresses, secret key only), or create a new session with them. A billing address may also be sent on the payment as `billing_address`. Not retryable as sent. --- ### buyer_not_found **HTTP:** 404. The buyer id on `GET /v1/buyers/{id}` or `PATCH /v1/buyers/{id}` is unknown to this merchant. The response is **opaque** — the same body whether the id is missing, belongs to a different merchant, or its test/live mode doesn't match the key (buyer ids are mode-prefixed `vp_by_test_*` / `vp_by_live_*`) — so buyer ids cannot be probed across merchants or modes. To find a buyer by your own reference, use `GET /v1/buyers?external_id=` or `?email=`. See [Buyers](buyers.md). --- ### buyer_email_conflict **HTTP:** 409. A `POST /v1/buyers` sent an `external_id` with an email that belongs to a buyer carrying a different `external_id`, or a `PATCH /v1/buyers/{id}` tried to set an `email` that already belongs to another of your buyers. Nothing was changed. On `POST`, an email owned by a buyer with **no** `external_id` is not a conflict: the reference is attached to that buyer ([Buyers](buyers.md)). Emails are unique per merchant across buyer records, and rejecting beats silently merging two buyer records into one. Look up the existing owner with `GET /v1/buyers?email=` and update that record, or keep the two buyers distinct by `external_id` and drop the conflicting email. See [Buyers](buyers.md). --- ### service_unavailable **HTTP:** 503. The endpoint is temporarily unavailable. Retriable — back off exponentially and retry. Distinct from `provider_unavailable` (the payment provider is unreachable) and `auth_service_unavailable` (the key-verification service is down); this one covers the endpoint itself. **On `POST /v1/payment_intents` it carries a stronger guarantee:** a check we run before charging could not complete, so **nothing was sent to your payment provider and the buyer was not charged.** Retry shortly **with the same `Idempotency-Key`**; `selfHeal.retryable` is `true`. Branch on the code, not the class: this `503` and [`charge_reservation_unavailable`](#charge_reservation_unavailable) both mean nothing happened, a `409` [`charge_in_progress`](#charge_in_progress) means something already did, and a `400` [`idempotency_key_required`](#idempotency_key_required) means add a key — never vary one. ### feature_unavailable **HTTP:** 503. The capability you called is switched off. **This is a deliberate configuration, not an outage** — retrying the same call will keep returning this response, however long you back off. Branch on it: - [`service_unavailable`](#service_unavailable) (503) means *try again shortly* — the endpoint is briefly down. - `feature_unavailable` (503) means *stop trying* — the capability is off and will stay off until it is turned on. - [`endpoint_not_implemented`](#endpoint_not_implemented) (501) means the operation does not exist for your payment provider at all. **Fall back to your asynchronous path:** reconcile the outcome from your webhook rather than looping on the call. The response is not a failed payment — it is a refusal to answer synchronously. It is returned by the public guest-completion endpoint (`POST /v1/public/sessions/{id}/complete`). The check runs **before** your key is authenticated, so a valid publishable key still sees it; it is not a credentials problem. ### rate_limit_service_unavailable **HTTP:** 503. The rate-limiting service is temporarily unavailable, so the request could not be admitted. Retriable in a few seconds. Not the same as `rate_limit_exceeded` (429) — you have not exceeded anything, the limiter simply could not answer. ### charge_at_submit_not_enabled **HTTP:** 422. The session is not eligible for Embedded Fields charge-at-submit. Vault the card with `POST /v1/public/tokens` and charge server-side with `POST /v1/payment_intents` instead. Distinct from `endpoint_not_implemented` (501), which means the capability is off platform-wide or for your gateway rather than wrong for this particular session. ### validation_url_scheme_forbidden **HTTP:** 400. A `successUrl` or `cancelUrl` used a scheme or host that live mode does not allow. Live mode requires **HTTPS on a public host**; `localhost` is accepted in test mode only. Fix the URL and retry. --- ### webhook_subscription_not_found **HTTP:** 404. The webhook-subscription id is unknown to this merchant. Check the id format and that it belongs to your account — subscription ids are merchant-scoped, so another merchant's id reads as missing rather than forbidden. ### webhook_subscription_conflict **HTTP:** 409. A webhook subscription already exists for this URL. Update or delete the existing one rather than creating a duplicate — list your subscriptions with `GET /v1/webhook_subscriptions` to find it. ### webhook_subscription_url_immutable **HTTP:** 400. A `PATCH /v1/webhook_subscriptions/{id}` included `url`. A subscription's delivery URL is fixed once it is created, and nothing was changed by this request. Not retryable as sent. To deliver to a new URL, create a new subscription for it. It gets a **new signing secret**: store it and verify deliveries with it. Confirm the new subscription receives events, then delete the old one. To change only the event list, description or status, send the update without `url`; that keeps the existing URL and signing secret. ### unexpected_redirect **Raised by the server SDKs, never sent by the API** (Node and Python 3.0.0 and later). The API answered with a redirect, which it never does, so something else is answering: a proxy, a mistyped base URL, or a hijacked connection. The SDK does not follow it. `retryable` is false and `nextAction` is `check_configuration`. Do not retry: check that the SDK's base URL is `https://checkout.vonpay.com` with no path, then any proxy and the network. ### webhook_event_not_found **HTTP:** 404. The webhook-event id is unknown, or belongs to a session your account does not own. Same merchant-scoping rule as subscriptions above. --- ### wallet_domain_not_found **HTTP:** 404. The wallet-domain id is not registered to this merchant, or has been removed. ### wallet_domain_invalid **HTTP:** 422. The request body failed validation. Two things can trigger it, and the message does not distinguish them: the `domain` was not a bare RFC 1123 hostname (letters, digits, hyphens and dots, 253 characters max — **no scheme, no path, no port, no IP literal**), or `wallet_type` was something other than `apple_pay` / `google_pay`. Check both before assuming it is the hostname. ### wallet_domain_wrong_type **HTTP:** 404. You asked for the verification file on a Google Pay row. Only Apple Pay registrations host a verification file, so the download endpoint is meaningful for those alone. ### wallet_domain_no_binder **HTTP:** 409. The account has no active wallet-capable payment gateway, so there is nothing to register a domain against. Complete merchant onboarding first. ### wallet_domain_rate_limited **HTTP:** 429. Verification re-runs for a single domain are limited to one every 30 seconds. Poll `GET /v1/wallet-domains/{id}` for status instead of calling verify repeatedly. ### wallet_domain_provider_unavailable **HTTP:** 503. The upstream provider that performs domain registration is temporarily unreachable. Retriable with exponential backoff. --- ### mirror_settle_only_disabled **HTTP:** 400. `mirror.settle_only` was refused **before the card was charged — the buyer was not charged.** Either send the standard upsell instead (`parent_order_ref` with the add-on's `line_items`), or see [Settle-only](/connected-platforms/next-commerce#settle-only) for the conditions a settle-only request has to meet. --- ## Order-before-charge refusals (`order_first_*`) These apply when your account creates the order in the connected store **before** the card is charged. All eleven refuse **before any money moves** — on every one of them, **the buyer was not charged**. They divide into three responses, and the difference matters: | Response | Codes | | --- | --- | | **Retry with the identical body** | `order_first_lookup_unavailable`, `order_first_record_unavailable`, `order_first_repoint_unavailable`, `order_first_store_timeout`, `order_first_store_unavailable` | | **Fix the request, then resend** | `order_first_order_rejected`, `order_first_amount_mismatch`, `order_first_currency_mismatch` | | **Contact support** | `order_first_unsupported_destination` | | **Do not retry: an earlier payment for this purchase exists** | `order_first_already_paid`, `order_first_payment_unresolved` | :::warning Retry with the **identical** basket, or you create a second order On `order_first_store_timeout` and `order_first_store_unavailable` the store may have created the order already — it simply never told us. An unconfirmed attempt is matched by the **purchase** (buyer, line items, quantities, prices, voucher): an identical retry **adopts** that order, a retry with any of those changed creates a second one. If the basket has to change, treat it as a new purchase — the abandoned order is released automatically when no payment completes. ::: ### order_first_lookup_unavailable **HTTP:** 503. Our record of earlier attempts for this purchase could not be read, so the request was refused rather than risk a duplicate order. Retry the identical body after ~3s. --- ### order_first_record_unavailable **HTTP:** 503. The durable record written before contacting the store could not be written, so the request was refused. Retry the identical body after ~3s. --- ### order_first_repoint_unavailable **HTTP:** 503. This is a retry, an order for it already stands in your store, and the link from that order to this attempt could not be written. The existing order is untouched and is released automatically if no payment completes. Retry the identical body after ~3s. --- ### order_first_store_timeout **HTTP:** 503. The store did not answer in time while creating the order, so the charge was refused rather than billed against an order Von could not confirm. **The order may exist.** Retry the identical basket after ~5s — see the warning above. --- ### order_first_store_unavailable **HTTP:** 503. The store was throttling or temporarily unavailable. It did **not** reject the order's contents, so there is nothing to correct in your request. **The order may exist.** Retry the identical basket after ~10s — see the warning above. If it persists for several minutes the store is likely rate-limiting a burst: reduce concurrency rather than retrying harder. --- ### order_first_order_rejected **HTTP:** 400. The store refused the order outright, so nothing was created. This is a store-side validation failure about the order's **contents** — usually a product or variant reference that does not exist in that store, a quantity it will not accept, or a malformed price. It is not a discount problem. Reconcile your `line_items` against the store's catalogue and resend; an identical retry is rejected identically. --- ### order_first_amount_mismatch **HTTP:** 400. The store created the order and priced it at a total that does not equal the `amount` you are charging, so Von refused rather than record a payment that does not match the order. **Charge the `order_total` returned on this error** — the store's number is authoritative. The order is left standing, so a corrected retry attaches to it instead of creating a second one. ⚠️ **Do not call [`POST /v1/mirror/quote`](/connected-platforms) to resolve this.** Quote prices a *cart*; order-before-charge exists precisely because a store's cart total and its order total can disagree — a standing site-wide offer can apply to one and not the other. --- ### order_first_currency_mismatch **HTTP:** 400. The store priced the order in a **different currency** than the one being charged. Charge in the currency named by `order_currency` on this error, or reconfigure the store to price in your intended currency. This is deliberately separate from an amount mismatch because **the numbers can be identical while the money is not** — a ¥1000 order and a $10.00 charge are both `1000` in minor units. --- ### order_first_already_paid **HTTP:** 409. A new charge arrived for the **same purchase** (same buyer, items and store) as an earlier payment that **already went through**, while the store order is still open (usually because marking it paid had not finished). **This request did not charge the buyer.** The response carries `original_payment_intent_id`, the earlier payment, whenever it can be read, and the earlier payment is recorded on the store order automatically. **Not retryable. Do not retry with a new `Idempotency-Key`, a different card or a changed basket:** each of those charges the buyer a second time for something already paid. Treat the purchase as paid, read `GET /v1/payment_intents/{original_payment_intent_id}` and show the buyer their confirmation. If the buyer genuinely wants the same items again, contact support. ### order_first_payment_unresolved **HTTP:** 409. A new charge arrived for the **same purchase** as an earlier payment that has **not finished** (for example, the buyer is still completing a bank check) or whose result has not been confirmed. If that earlier payment goes through, a second charge would bill the buyer twice, so this one was refused. **This request did not charge the buyer.** `original_payment_intent_id` is included when the earlier payment has a record, and omitted (not `null`) when it does not yet. **Not retryable as is.** When `original_payment_intent_id` is present, read it: `succeeded` means the purchase is paid; `failed` means the same basket can be retried. Do not get past this with a new key, card or basket. If it persists beyond a few minutes, contact support so the earlier payment can be resolved. ### order_first_unsupported_destination **HTTP:** 503. Your account is configured for order-before-charge, but the platform named in `mirror.destination` cannot create an order ahead of the charge, and there is no fallback to charge-first. **Not retryable** — a configuration mismatch on our side. Contact support. --- ## Decline reasons (`failure_code`) A declined charge surfaces two ways: - **Synchronously** — the API returns `402` [`provider_charge_failed`](#provider_charge_failed) on the charge routes. ⚠ **A declined `POST /v1/payment_intents` is different: it returns `201` with `status: "failed"`**, not a `4xx` — see [A decline is a 201](payment-intent-object.md#decline-codes). A client that only reads the body on an error status never sees the decline fields at all. - **Asynchronously** — a [`charge.failed`](../integration/webhook-events.md#chargefailed) or [`payment_intent.failed`](../integration/webhook-events.md#payment_intentfailed) webhook fires, carrying seven fields (all `string | null`): - `failure_code` — the **normalized** decline reason to branch on. - `decline_code` — the same value under the API response's name. See [the two names](#two-names). - `action` — `soft` / `hard` / `fix_and_retry` / `generic`, the retry classification. - `decline_message` — buyer-safe wording for this decline. See [Showing a decline to the buyer](#buyer-facing-copy). - `failure_reason` — a human-readable summary of the decline, written for **you**, not your buyer. See [Who `failure_reason` is for](#failure-reason-audience). - `network_decline_code` — the raw issuer code (e.g. `05`, `51`); for logging/analytics only. - `rule_code` — which of *your own* rules refused it, when one did. `null` on every other outcome. See [Telling your rules apart](#rule_code). ### The same value under two names {#two-names} The normalized decline arrives as **`failure_code`** and **`decline_code`** on every failed-family webhook payload — the same value under both names, so one handler can read the webhook and the API response by the same key. It is normalized to a fixed vocabulary; the full set and recommended handling follow. Every decline also carries [`action`](payment-intent-object.md#decline-action) (`soft` / `hard` / `fix_and_retry` / `generic`), derived from the same mapping on both surfaces — branch on that for retry or dunning logic rather than maintaining the column yourself. | `failure_code` | `action` | Meaning | Retry the same card? | Recommended next step | |---|---|---|---|---| | `card_declined` | `generic` | Declined with no further detail supplied. | Unlikely to help | Prompt for a different payment method. | | `insufficient_funds` | `soft` | The card lacks available funds. | Not now | Suggest a different card; the same card may succeed later. | | `expired_card` | `fix_and_retry` | The card has passed its expiry date. | No | Ask the buyer to re-enter a valid card. | | `incorrect_cvc` | `fix_and_retry` | The CVC/security code was wrong. | Only after correction | Ask the buyer to re-enter the security code. Retrying the same value fails identically. | | `incorrect_zip` | `fix_and_retry` | The billing postal code did not match. | Only after correction | Ask the buyer to re-enter the billing ZIP/postal code. Retrying the same value fails identically. | | `card_velocity_exceeded` | `soft` | The card hit an issuer velocity limit. | Not now | Suggest a different card or trying again later. | | `fraudulent` | `hard` | The issuer or a fraud rule blocked the charge. | No | Show a generic decline message — do not reveal the fraud signal. Suggest a different method or contacting the issuer. | | `stolen_card` | `hard` | The card was reported stolen. | No | Show a generic decline message; suggest a different method. | | `lost_card` | `hard` | The card was reported lost. | No | Show a generic decline message; suggest a different method. | | `do_not_honor` | `hard` | The issuer declined without a specific reason. | Unlikely to help | Prompt for a different payment method or contacting the issuer. | | `issuer_unavailable` | `soft` | The issuer could not be reached. | Yes | Safe to retry shortly; if it persists, try a different method. | | `processing_error` | `soft` | A transient processing failure. | Yes | Safe to retry; if it persists, try a different method. | | `blocked_by_rule` | `hard` | A rule **you** configured on your own account refused the charge. No issuer was asked. | No | Prompt for a different payment method. Do not suggest contacting the bank — no bank saw this charge. | | `generic_decline` | `generic` | An unmapped or unrecognized raw decline. | Unlikely to help | Prompt for a different payment method. | An unmapped raw code is normalized to `generic_decline` before it reaches you, so a webhook always carries one of the values above. ### Who `failure_reason` is for {#failure-reason-audience} `failure_reason` is written for **you**, not your buyer. It is the most informative single string we return about why a charge failed — put it in your logs and your support tooling. Three of its values state that a card is reported lost or stolen, or that fraud is suspected. Present those to a buyer as an ordinary decline, exactly as you would `generic_decline`: | `failure_code` | What `failure_reason` says | What the buyer should see | |---|---|---| | `fraudulent` | "The payment was declined as suspected fraud." | A generic decline | | `stolen_card` | "The card was reported stolen." | A generic decline | | `lost_card` | "The card was reported lost." | A generic decline | `fraudulent` is a risk score, not a finding. `network_decline_code` stays out of buyer-facing copy entirely — it is for logging and analytics. ### Showing a decline to the buyer {#buyer-facing-copy} `decline_message` is the field written for your buyer. It accompanies the decline on the charge response and on the failed-family webhooks, and it is the one you can render or put in a dunning email without leaking a fraud signal. **Show `decline_message`; log `failure_reason`.** They are not the same string and should not be collapsed into one. Where `failure_reason` names a fraud, lost or stolen signal, `decline_message` gives all of those the ordinary decline wording — byte for byte identical to `card_declined` and `generic_decline` — so the five cannot be told apart: > The card was declined. Please use a different payment method. On a successful charge the decline fields are not there to read: the API response returns them explicitly `null`, and the success webhooks (`charge.succeeded` and its siblings) omit the keys entirely — so `decline_code === null` is `true` on one surface and `false` on the other. Test that the charge actually failed before rendering `decline_message`; printing it unconditionally shows a decline to a buyer who just paid. Codes the buyer can actually act on keep their own wording — `insufficient_funds`, `expired_card`, `incorrect_cvc` and `incorrect_zip` each name what to fix, because a generic string there costs a sale the buyer was willing to complete. ### Decline reasons in the browser {#browser-decline-codes} A decline reported to the browser, by the [embedded card form](/embedded-fields/errors) or the hosted checkout page, uses a smaller set of codes. It never says which card detail was wrong, or that the card was flagged: | Server `decline_code` | Browser code | |---|---| | `incorrect_cvc`, `incorrect_zip`, `expired_card` | `incorrect_card_details` | | `fraudulent`, `stolen_card`, `lost_card`, `blocked_by_rule` | `card_declined` | | Any other code | Unchanged | The exact reason still reaches your server on the [`charge.failed`](../integration/webhook-events.md#chargefailed) webhook and on secret-key responses. In vora.js 1.38.1 and earlier, the embedded card form does not pass `incorrect_card_details` or `blocked_by_rule` to your page: those declines arrive as "The payment was declined." with no code. Read the exact reason on your server. :::warning Coarsen `blocked_by_rule` before rendering it in a browser `decline_message` is buyer-safe, but for `blocked_by_rule` it is not *browser*-safe: its wording differs from the generic decline, which makes a refusal by **your own rule** distinguishable from an issuer decline. A scripted caller could use that difference to map which cards you refuse. Our browser-facing responses do not carry `decline_message` at all, so this cannot reach a buyer's page by itself — every copy of it you hold came from a secret-key response or a webhook. That makes the coarsening yours to do: if you echo the field into a page, replace it with your generic decline copy when `failure_code` is `blocked_by_rule`. ::: Your own copy keyed off `failure_code` is the option for tone control or a language other than English — `decline_message` is English only. Either way, the charge response carries the same fields; do not wait on the webhook to tell someone their card failed. ### `blocked_by_rule` — your own rule, not a bank decline {#blocked_by_rule} This is the one value in the list that is not a decline in the usual sense. The charge never reached an issuer: a routing rule on your own account refused it first. It is not limited to card brands — the rule may key on a brand you have chosen not to accept, a card country you block, prepaid cards, or be a catch-all with no name of its own. **Which** of your rules fired is reported as `rule_code` — but only if you gave the rule a code. See [Telling your rules apart](#rule_code) below. :::warning Availability depends on your payment provider Reporting a rule refusal as its own code requires provider support, as [rule tags](../guides/rule-tags.md#provider-support) do. On a provider that does not distinguish it, a refusal by your own rule arrives as `generic_decline` — so the absence of `blocked_by_rule` is not proof no rule fired. Confirm your provider reports it before building decline-rate reporting on it. ::: Its `action` is `hard`: the same card is refused for as long as the rule stands, so the buyer needs a different payment method. ### Telling your rules apart — `rule_code` {#rule_code} Every rule produces the same `decline_code`, so a brand rule and a country rule are indistinguishable by that field alone. `rule_code` carries the **code you gave the rule** in your provider dashboard — or, on a provider that assigns its own rule identifiers, that identifier — minus any provider prefix. It is what lets you branch on them separately. ```json { "status": "failed", "decline_code": "blocked_by_rule", "action": "hard", "rule_code": "amex_not_accepted" } ``` **You have to name your rules for this to work.** Left blank, every rule emits one identical value and `rule_code` comes back `null` — set a custom code on each rule before you write any handling. A `null` on a `blocked_by_rule` payment means the rule has no custom code, or (see the availability note above) your provider does not report rule refusals separately at all. - **It is echoed back as you typed it, minus any provider prefix.** We do not interpret it. - **A code that is not a plain identifier comes back `null` rather than altered.** Letters, digits, `_` and `-`, up to 64 characters, and **the first character must be a letter or a digit** — `_amex_block` is rejected to `null`, which looks identical to never having named the rule. `rule_code` appears wherever `decline_code` does on a secret-key surface: the payment intent, and the `charge.failed` / `payment_intent.failed` / `session.failed` webhooks. **Browser-facing responses** — every response a buyer's browser can receive, including `POST /v1/public/sessions/{id}/charge` — report a rule refusal as the generic `card_declined` and carry no `rule_code`: a publishable key is readable from your page source, and naming the rule there would let anyone map which cards you refuse. Read the full value from your server, on the payment intent **create/confirm response** (`decline_code`) or the `charge.failed` webhook (`failure_code`). Retrieving the intent later with `GET /v1/payment_intents/{id}` does **not** return the decline — capture it when the charge fails or you cannot recover it from the API afterwards. For a worked example — configuring the rule, branching on it, and what to tell the buyer — see [Handling rule declines](../guides/rule-declines.md). If you branch on `failure_code` directly, treat any value you do not recognize as `hard` and prefer [`action`](payment-intent-object.md#decline-action), which is a closed set. ### When the decline block is absent {#decline-block-absent} :::warning An absent `action` is not a classification `action` accompanies the decline on the payment intent create/confirm response and on the `charge.failed` / `payment_intent.failed` / `session.failed` webhooks; the browser charge-at-submit response does not carry it. Where present it can still be `null` — the decline carried a code we hold no classification for, or the failure never resolved a normalized code at all — and it is deliberately not reported as `generic`. `action`, `decline_code` and `decline_message` derive from the same code, so in the second case they are **all** `null` together. **Never treat a missing `action` as permission to retry.** ::: Trigger the declines above by [order total](test-cards.md#processor-test-account) in test mode; the processor's test environment decides by the total, not by the card number. Note that the fraud decline surfaces `failure_code` as `fraudulent` on the wire. **`blocked_by_rule` is the exception — no order total produces it**, because it depends on a rule configured on your account rather than on the payment. Exercise your handling for it by configuring a rule in your provider dashboard. --- ## /reference Source: https://docs.vonpay.com/reference # Reference Specs, lookup tables, and behavioral references for the Von Payments API. ## API surface - **[API reference](./api.md)** — complete endpoint catalog (OpenAPI 3.1) - **[Session object](./session-object.md)** — full schema, fields, lifecycle states - **[Payment intent object](./payment-intent-object.md)** — the six-value status set, what each state permits, and which ones a refund requires - **[Buyers](./buyers.md)** — the buyer record, the name fields that belong to the charge rather than the record, and erasing a stored email - **[Tokens](./tokens.md)** — `vp_pmt_*` payment-method tokens: minting, reuse, and their single-use default - **[Refunds](./refunds.md)** — full and partial refunds, the four 2xx shapes, and which of them moved money - **[Capabilities](./capabilities.md)** — what your connection supports, and why `succeeded` is not evidence a payment was never disputed - **[Statement descriptors](./statement-descriptors.md)** — what the buyer sees on their statement - **[Discovery endpoint](./discovery.md)** — runtime API discovery (`GET /.well-known/vonpay.json`) - **[Versioning](./versioning.md)** — API version policy + deprecation timeline ## Operational reference - **[API keys](./api-keys.md)** — key formats (`vp_sk_*` secret, `vp_pk_*` publishable, `whsec_*` webhook-signing), rotation, scoping - **[Security](./security.md)** — webhook signatures, TLS, encryption at rest, key rotation - **[Rate limits](./rate-limits.md)** — per-key limits, backoff guidance - **[Error codes](./error-codes.md)** — every error code, what triggers it, how to recover ## Testing - **[Test cards](./test-cards.md)** — sandbox card numbers + decline-trigger PANs --- ## Metadata Source: https://docs.vonpay.com/reference/metadata # Metadata `metadata` is a free-form map you attach to a payment so you can find it again in your own systems — an order number, a customer reference, an internal ledger key. We store it, echo it back, and return it whenever you read the object. It is the answer to *"which of my orders is this payment?"* — and it is deliberately **not** an answer to *"can I prove what happened?"* or *"is this private?"*. Both of those are covered below, because getting them wrong costs real money. ## Where you can set it | You are calling | Key limit | Value limit | Whole-map cap | |---|---|---|---| | `POST /v1/sessions` (hosted checkout) | none | **500 characters**, strings only | **none** — see below | | `POST /v1/payment_intents` | none | any JSON value | 8 KB serialized | | `POST /v1/refunds` | none | any JSON value | 8 KB serialized | | `POST /v1/tokens` | none | any JSON value | 8 KB serialized | | `POST /v1/buyers` | none | any JSON value | 8 KB serialized | | `PATCH /v1/buyers/{id}` | none | any JSON value | 8 KB serialized | | Order mirroring | **1–64 characters** | **500 characters**, strings only | **4 KB on the whole mirror block** | The limits genuinely differ. A value that a payment intent accepts can be rejected by a checkout session, and a key a payment intent accepts can be rejected by order mirroring. If you write one helper that builds metadata for every call, size it to the **strictest** row you use. ### The size cap is not the same on every call On `/v1/payment_intents`, `/v1/refunds`, `/v1/tokens` and `/v1/buyers` the whole map, serialized, is capped at **8 KB**. Over that the request is rejected with `validation_error` — nothing is truncated and nothing is stored, so you find out at the call rather than discovering a half-written record later. ⛔ **Two surfaces do not work that way, and both will surprise you:** - **`POST /v1/sessions` has no metadata size cap at all.** Nothing rejects a large map on the hosted-checkout path. That is not permission to send one — see the forwarding section below, because the whole map travels onward from there and the receiving system has its own limits you cannot see. - **Order mirroring is capped at 4 KB across the WHOLE mirror block**, not at 8 KB on metadata alone. That budget is shared with line items, addresses, shipping, coupon and voucher data, and going over returns **`mirror_too_large`** — a different error from `validation_error`, so a handler branching only on the latter will not catch it. The session is never created and the buyer never reaches a pay page. ⚠ **A nested `buyer.metadata` is capped separately.** Some calls let you send a buyer inline, and that buyer carries its own metadata map with its own 8 KB budget rather than sharing the top-level one — so a request can be within the cap at the top level and still be rejected for the nested map, or vice versa. ## What comes back is not always what you sent On `/v1/payment_intents`, `/v1/refunds`, `/v1/tokens` and `/v1/buyers`, a fixed list of **key names** is removed before your metadata is stored. It exists to keep card and buyer data out of our records. ⛔ **It matches on the NAME, not on what you put in the value — and that cuts both ways.** Two consequences, and the first one bites ordinary integrations: - **Common names are removed even when your value is harmless.** `number`, `address`, `phone`, `email`, `shipping`, `card`, `last4` and `buyer` are on the list. So `metadata: { number: "SO-4471" }` — an order number — is **silently deleted before storage**, and you still get a `2xx`. Qualify your keys: `order_number`, `customer_ref`, `ledger_key`. Names that are clearly yours are never touched. - **It is not a privacy control and must not be used as one.** A key named `customer_email` holding a real email address is **not** on the list and is stored exactly as sent. The list protects our records from a handful of known names; it does not inspect your data and cannot protect you. ⛔ **The order-mirroring block is exempt on every connection.** If you send a `mirror` block, it is written back into stored metadata **unfiltered** — so a buyer email inside a mirrored order is held in plain text regardless of the list above. Treat mirrored order data as data you have chosen to store. ⛔ **And the filter does not run at all on `POST /v1/sessions`.** Top-level metadata on the hosted-checkout path is stored as sent. A nested `buyer.metadata` on that same call *is* filtered; the top-level map is not. So: **read the response rather than trusting the echo.** If a key matters, confirm it is in the object you get back. And keep personal data out of `metadata` entirely — nothing here will do that for you, and the next section is the reason it matters. ## ⛔ It is not private **How much of your metadata reaches the payment processor depends on which call you made AND which connection your account is on.** There is no single answer: | You called | What travels | |---|---| | `POST /v1/sessions` (hosted checkout) | **the whole map** | | `POST /v1/payment_intents` | narrowed to a single order reference on one connection; **every key except a handful we reserve** on the others | We are deliberately not telling you which connection you are on, because you should not build on the answer: it is a property of your account's setup, it can change without your integration changing, and the safe assumption is the permissive one. There is no per-request flag that changes this, and no way to mark a key local. So the rule is simple: > Do not put anything in `metadata` that you would not be comfortable handing to > your payment processor. If you need something to travel to the processor *on purpose* — so their rules can route, challenge or decline a charge — that is what [`rule_tags`](../integration/payment-intents.md#rule-tags) is for. It is the explicit "forward this" channel, and it is matched on. `metadata` is not. ### Some keys are reserved, and some values are silently dropped ⛔ **`mirror` is not yours to use** — and the two surfaces fail differently: - On **`/v1/payment_intents`** that key is the order-mirroring slot. An object there is read as an instruction to push an order to a connected store; a string is rejected outright with `mirror_malformed`, so the charge never runs. - On **`/v1/sessions`** a string under that key is the *legacy* mirroring shape. The request succeeds with a `Deprecation` header and the string is **never parsed** — no order is mirrored. (An object cannot be sent there: session metadata values must be strings.) Either way, pick a different name. ⚠ **On one connection, any value longer than 80 characters is dropped rather than truncated** before it reaches the processor — even though this page's table lets you store 500. It is still stored on our side; it just will not be on the processor's record. A handful of internal reconciliation keys are dropped the same way. So a key you expect to see on the processor may simply not be there: **reconcile against our API, not against your processor's dashboard.** ## ⛔ It is not evidence ⚠ **And do not read the table above as saying the opposite either.** On the discrete charge path with the narrowing connection, only the order reference reaches the processor — so anything else you filed under `metadata` is invisible when you contest a chargeback. You cannot tell from your own code which case you are in, which is exactly why metadata is not dependable evidence in either direction. Evidence has to travel as a real field (`billing_address`, `buyer_email`) or as a `rule_tags` label to be dependable. [What reaches your payment provider](../integration/what-reaches-your-provider.md#metadata-not-evidence) covers which fields travel and why. ## Buyer metadata merges — it does not replace On a buyer record, writing `metadata` **adds to** what is already stored. A write never bulk-replaces the map, and keys are never removed. ⚠ **"Never removed" is not "never changed".** Send a key that already exists and your new value **overwrites** the old one — that is deliberate, so you can correct a stored value. What you cannot do is delete one. This is the opposite of what most APIs do with a map field, so it is worth stating plainly: **you cannot clear a buyer's metadata by sending an empty object.** If you are storing something you may later need to remove, keep it somewhere you control instead. ## Related - [What reaches your payment provider](../integration/what-reaches-your-provider.md) — which fields travel, and which never do - [Payment intents](../integration/payment-intents.md#rule-tags) — `rule_tags`, the deliberate forward-to-processor channel - [Buyers](buyers.md) — the buyer record and its merge behaviour - [Statement descriptors](statement-descriptors.md) — what the buyer actually sees on their statement --- ## Payment Intent Object Source: https://docs.vonpay.com/reference/payment-intent-object # Payment Intent Object A **payment intent** is the server-side record of one payment's lifecycle — authorize, capture, void, refund — driven entirely from your backend. You create one with [`POST /v1/payment_intents`](../integration/payment-intents.md); it's what every [integration path](../integration-paths.md) ultimately charges through. This page documents the object's shape. For the how-to (auth/capture/void/refund, MIT, 3DS), see the [Payment Intents guide](../integration/payment-intents.md). ```json { "id": "vpi_live_8x4n2pq7m1", "status": "succeeded", "amount": 4999, "currency": "USD", "capture_method": "automatic", "card": { "brand": "visa", "last4": "4242" }, "created_at": "2026-06-23T15:30:00.000Z" } ``` ## Core fields | Field | Type | Always Present | Description | |---|---|---|---| | `id` | string | Yes | Payment intent ID — `vpi_test_*` (sandbox) or `vpi_live_*` (live). | | `status` | string: `requires_action` \| `authorized` \| `captured` \| `succeeded` \| `voided` \| `failed` | Yes | Current lifecycle status — see [Status lifecycle](#status-lifecycle). | | `amount` | integer | Yes | Amount in minor units (cents), `≥ 0`. | | `currency` | string | Yes | ISO 4217 currency code (3-letter uppercase). | | `capture_method` | string: `automatic` \| `manual` | Yes | `automatic` — capture immediately on a successful authorization. `manual` — authorize now, capture later via `/capture`. | | `next_action` | object \| null | No | Present when a **server-redirect** 3DS challenge is required: `{ "type": "redirect_to_url", "redirect_to_url": { "url": "…" } }`. Redirect the buyer to `url`; the intent settles after the challenge. | | `client_confirm` | object \| null | No | Browser-confirmation handle: `{ binder, client_secret }`. Do not build on it: handle a 3-D Secure challenge with `next_action`, which is the path for a charge sent with `payment_method`. ⛔ **`client_secret` is a credential** whenever present: it authorises the 3-D Secure step-up for this intent. Forward it only to the buyer's browser and redact it everywhere else: a payment result written whole to a log, an APM breadcrumb or an error tracker takes this field with it. | | `decline_code` | string \| null: `card_declined` \| `insufficient_funds` \| `expired_card` \| `incorrect_cvc` \| `incorrect_zip` \| `card_velocity_exceeded` \| `fraudulent` \| `stolen_card` \| `lost_card` \| `do_not_honor` \| `issuer_unavailable` \| `processing_error` \| `blocked_by_rule` \| `generic_decline` | No | Normalized decline reason on a `failed` intent (see [Decline codes](#decline-codes)). | | `rule_code` | string (≤ 64) \| null | No | Which of **your own** provider rules refused the charge. Present only when `decline_code` is `blocked_by_rule`; `null` on every other outcome. Carries the custom code you set on that rule, with the provider's own prefix stripped — max 64 chars, and `null` if you left the rule unnamed. See [Telling your rules apart](error-codes.md#rule_code). | | `action` | string \| null: `soft` \| `hard` \| `fix_and_retry` \| `generic` | No | What to do about a decline — `soft`, `hard`, `fix_and_retry` or `generic`, derived from `decline_code` through one published mapping (see [`action`](#decline-action)). | | `decline_message` | string \| null | No | Buyer-safe wording for the decline — the field to show a shopper. Not the same string as `failure_reason`, which is written for you and names the fraud, lost and stolen signals. See [Showing a decline to the buyer](error-codes.md#buyer-facing-copy). | | `payment_method_id` | string \| null | No | The saved card this payment vaulted (`vp_pmt_*`), reusable for later merchant-initiated charges. `null` when the payment vaulted nothing — a guest payment with no buyer attached, or one that never succeeded. **Returned by `GET /v1/payment_intents/{id}` only — it is not on the create response**, because the card is vaulted when the charge completes. That makes this the surface to use when the token never reached your server at charge time: a payment sent through 3-D Secure finishes after your charge call has already returned, so the charge response carries no token for it. Unlike `next_action`, it is not tied to `status` — a saved card outlives the payment that saved it, so later reads keep returning it. Same value as `token` on the [charge-at-submit](../embedded-fields/charge-at-submit.md#getting-the-saved-card-back) response and `id` on a `POST /v1/tokens` response. | | `card` | object \| null | No | PCI-safe card presentation — `{ "brand": …, "last4": … }`. `brand` is one of `visa`, `mastercard`, `amex`, `discover`, `diners`, `jcb`, `unionpay`, `unknown`; `last4` is 4 digits. No PAN, BIN, or fingerprint is ever included. | | `avs_result_code` | string \| null: `match` \| `partial_postal` \| `partial_address` \| `no_match` \| `unavailable` \| `not_supported` | No | Normalized [Address Verification (AVS)](../guides/address-verification.md) result. `null` when no billing address was sent or no result was returned. Gateway-independent — raw processor codes are not exposed. | | `cvv_result_code` | string \| null: `match` \| `no_match` \| `not_provided` \| `unavailable` | No | Normalized card security-code result. | | `created_at` | string \| null | No | ISO 8601 creation timestamp. | | `metadata` | object | No | Merchant-provided key-value pairs you set at creation. | | `buyer_email` | string (≤ 254) | No | **On the create response**, echoes back the `buyer_email` you sent, confirming it was accepted for buyer attribution. **On a retrieve, capture or void it is the effective buyer's stored email**, so it can be present even when that call sent no email (the same effective-buyer rule as `buyer_id` below). Omitted entirely when there is none to report: it is never `null`, so test for the key's presence rather than comparing to `null`. | | `buyer_id` | string (≤ 200) | No | Omitted when no effective buyer exists — never `null`. Echoes the **effective** buyer this charge was attributed to — which is not always the one you sent. If the stored `payment_method` you charged carries its own buyer, that one wins; otherwise it is the `buyer_id` on your request. Present only when an effective buyer exists. ⚠️ **On an idempotency-key replay this echoes what *this* request asked for, not what is stored** — the buyer write is skipped entirely on a replay, so a re-sent create that adds or changes a buyer returns the new value while the stored attribution keeps the original. On a first (non-replay) request it is a genuine confirmation that the best-effort linkage landed; on a replay it is not. | | `buyer` | object | No | Echoes the nested `buyer` object you sent at creation, confirming it was accepted. `buyer.metadata` is echoed **after** privacy scrubbing, so it reflects what was stored rather than what you sent. **Omitted when you did not send one — never `null`.** See [Buyers](buyers.md). | | `buyer_ip_address_forwarded` | boolean | No | Whether the `buyer_ip_address` you sent was actually passed to the processor. **Present only on the create response, and only when you sent one**: the shared projection behind retrieve, capture and void does not carry it, so read it from the charge response rather than a later fetch. `true` means it was forwarded. `false` means we accepted it and did not send it, either because the charge was merchant-initiated (no shopper is present, so no address is asserted) or because the payment connection your account routes through is not one that carries it. Treat `false` as "this charge got no IP-based screening" rather than an error. The IP address itself is never echoed back. See [Buyer IP address](/integration/getting-a-payment-method-token#buyer-ip-address). | | `resolved_descriptor` | object \| null | No | What actually went to the payment provider as the statement descriptor for this charge — present on the create response. One of `{ "kind": "skipped", "reason": … }` when none was applied, `{ "kind": "suffix", "suffix" }` for a dynamic tail on your registered prefix, or `{ "kind": "descriptor", "descriptor": {…} }` for a full block. `kind` never names a payment provider. ⚠️ The brand-name **prefix** is best-effort and is usually fixed by your processor's merchant-account registration, not by this value; the **dynamic tail** is the part you reliably control per transaction. See [Statement descriptors](statement-descriptors.md). | | `pulseToken` | string \| null | No | Short-lived signed token for the **real-time status subscription** — forward it to the browser so the SDK's `subscribeToPaymentIntent` can resolve `handleAction()` over Server-Sent Events the instant a terminal status lands. `null` when the real-time substrate is unavailable; fall back to your timeout path. Opaque to your code — don't log it. ⚠️ **The name is camelCase deliberately** — it is the only field on this object that is, so `pulse_token` silently reads as nothing and keeps doing so. ⚠️ **The stream this token is for is not generally available today, so in practice this arrives `null` and the fallback path is the live path** — see [Streaming endpoints](api.md#streaming-endpoints). | | `three_d_secure` | object \| null | No | What cardholder authentication (3-D Secure) **actually did** on this payment, as opposed to what was requested or configured: `outcome` is one of `authenticated`, `attempted`, `not_authenticated`, `cancelled` (the shopper abandoned the challenge), `failed`, `not_performed` or `unknown`, alongside `method`, `version` and `eci`. Read `eci` for liability; never treat `attempted` as `authenticated`. **After a bank challenge the result is recorded once the payment settles**, from the record kept by whoever ran the check. It stays `null` when the shopper abandons the challenge (no charge, so no result) and on a connection that reports no authentication record. `null` and `outcome: "not_performed"` are **different answers**: `null` means no concluded result was recorded, never "not authenticated"; `not_performed` means the payment was inspected and no authentication took place. | ## Status lifecycle | Status | Meaning | |--------|---------| | `requires_action` | A 3D Secure (or other) challenge must complete before the intent can settle. Send the buyer to `next_action.redirect_to_url.url`. With no `next_action` the payment is still processing: wait for the webhook, which can take up to a day. | | `authorized` | Funds are held but not captured. Only reachable with `capture_method: "manual"` — capture with `/capture` or release with `/void`. | | `captured` | A previously `authorized` intent was captured. **Not terminal** — it transitions on to `succeeded` when settlement confirms (or `failed` if the provider rejects it). ⚠️ **A refund requires `succeeded`**: `POST /v1/refunds` rejects a `captured` intent. Wait for `succeeded` before refunding. | | `succeeded` | Money has moved, and this is terminal. Reached from `captured` when settlement confirms, from `authorized` when a manual capture settles in the same call, or directly on `capture_method: "automatic"`. | | `voided` | An `authorized` intent was cancelled before capture. No money moves. | | `failed` | The authorization or capture failed. See `decline_code` for the reason. | ``` ┌─► succeeded settlement confirmed ┌─► captured ───┤ │ └─► failed provider rejected the capture │ ┌─► authorized ─┼─► succeeded capture settled in the same call │ ├─► voided released before capture │ └─► failed │ capture_method: "manual" │ requires_action │ ├─► succeeded capture_method: "automatic" └─► failed declined (decline_code set) terminal: succeeded · voided · failed refunds: POST /v1/refunds accepts succeeded only ``` `authorized` can reach `succeeded` **without passing through `captured`**: on card networks the capture and the settlement happen in the same call, so the intent moves straight to its terminal status. Do not wait to observe `captured` — read the status you are given, and treat `succeeded` as the signal that money moved. The client-side result of a charge is a UX signal only. Always confirm settlement server-side via the `payment_intent.succeeded` / `charge.succeeded` [webhook](../integration/webhooks.md) before fulfilling. ## Decline codes When `status` is `failed`, `decline_code` carries the normalized reason (branch on this rather than a raw issuer code), and **`action` tells you what to do about it**. Both are `null` on any non-failed intent. :::warning A decline is a `201`, not an error A declined payment intent returns **`201 Created`** with `status: "failed"` — `200` on an idempotent replay. The request succeeded; the payment did not. There is no `4xx` for a decline. So parse the response body on success statuses. **A client that only reads the body when the status is an error will never see `decline_code`, `action`, `rule_code` or `decline_message`** — and the failure is silent: no exception is raised, the decline branch simply never runs, and every decline looks like a blank result. ::: ### `action` — retry or don't {#decline-action} `action` is derived from `decline_code` through one published mapping, so you can build retry and dunning logic **without maintaining your own code table**. | `action` | Meaning | What to do | |---|---|---| | `soft` | Transient. | A later retry of the same card can legitimately succeed. | | `hard` | Terminal. | **Do not retry.** The buyer needs a different payment method. | | `fix_and_retry` | The buyer mistyped something. | Retry only after they correct it — don't retry unchanged. | | `generic` | No reason supplied. | Treat as `hard` unless you have a reason not to. | | `decline_code` | `action` | |---|---| | `insufficient_funds`, `issuer_unavailable`, `processing_error`, `card_velocity_exceeded` | `soft` | | `stolen_card`, `lost_card`, `fraudulent`, `do_not_honor`, `blocked_by_rule` | `hard` | | `expired_card`, `incorrect_cvc`, `incorrect_zip` | `fix_and_retry` | | `card_declined`, `generic_decline` | `generic` | `blocked_by_rule` is a routing rule **you** configured refusing the charge before any issuer saw it — its `action` is `hard`, and the rule that fired is reported as [`rule_code`](#core-fields) (`null` if you gave the rule no code). The full value reaches secret-key surfaces (this object and the `charge.failed` webhook, where it is named `failure_code`); browser-facing responses report it as `card_declined` with no `rule_code`. Capture both on the failure — `GET /v1/payment_intents/{id}` does not re-serve decline detail. On a provider that does not distinguish a rule refusal it arrives as `generic_decline`. [Handling](error-codes.md#blocked_by_rule). ## 3D Secure: `next_action` When a `requires_action` intent carries `next_action`, send the buyer to `next_action.redirect_to_url.url`, having set `return_url` on the create call. That is the same whether you use Embedded Fields or call the API directly. If that redirect never reached the buyer, read the intent again: `GET /v1/payment_intents/{id}` returns the same `next_action`, with `issued_at` for its age, for as long as the intent is `requires_action`. A `requires_action` intent with no `next_action` is still processing: there is nothing for the buyer to do, so do not retry the charge, and wait for the webhook. That can take up to a day, because when the processor sends no webhook a daily reconciliation records the outcome, so do not put a shorter timeout on this state. See [Embedded Fields → 3D Secure](../embedded-fields/3ds.md). ## Related - [Payment Intents guide](../integration/payment-intents.md) — the auth / capture / void / refund lifecycle, MIT, and recurring - [Session Object](session-object.md) — the hosted-checkout session record - [Refunds](refunds.md) — refunding a captured intent - [Error codes](error-codes.md) — `decline_code` handling and recovery - [Webhooks](../integration/webhooks.md) — `payment_intent.*` event payloads --- ## /reference/rate-limits Source: https://docs.vonpay.com/reference/rate-limits # Rate Limits Rate limits are sliding-window counters over 60 seconds, counted per IP or per API key (the table says which). Two routes are metered on **both** axes at once (session create, and the public session read), so a request can be refused by either. ## Buckets | Bucket | Endpoint(s) | Limit | |---|---|---| | `sessionsPerKey` | `POST /v1/sessions`, any key | 30 / 60s per key | | `sessionsSecretKeyPerIp` | `POST /v1/sessions` with a secret key (`vp_sk_*`) | 300 / 60s per IP | | `sessions` | `POST /v1/sessions` with a publishable key or no key | 10 / 60s per IP | | `paymentOpsPerKey` | The payment routes with a secret key: `POST /v1/payment_intents` (+ `/{id}` sub-routes: retrieve, capture, void), `POST /v1/refunds` and `GET /v1/refunds`, `POST /v1/tokens` (+ `/{id}`), [`/v1/buyers`](buyers.md) + `/v1/buyers/{id}`, `GET /v1/payment_methods` | 300 / 60s per key, shared across these routes | | `paymentOpsSecretKeyPerIp` | The same routes with a secret key (per-IP axis) | 600 / 60s per IP | | `paymentOps` | The same routes without a secret key, and `POST /v1/public/tokens` | 100 / 60s per IP | | `cardTokenizePerSession` | Saving a card through `POST /v1/public/tokens`: the embedded card form's save, and the save step of save-then-charge (`elements` or setup-mode sessions); both share one budget | 6 / 60s per checkout session | | `cardTokenizePerIp` | The same card saves (per-IP axis) | 20 / 60s per IP | | `sessionRead` | `GET /v1/sessions/:id`, `GET /v1/capabilities` | 30 / 60s per IP | | `publicSessionRead` | `GET /v1/public/sessions/:id`, `POST /v1/public/binder-load` | 1000 / 60s per key | | `publicSessionReadPerIp` | the same two routes (per-IP axis) | 80 / 60s per IP | | `publicWebhookRead` | `GET /v1/webhook_subscriptions` + `/{id}`, `GET /v1/webhook_events/{id}` | 100 / 60s per key | | `webhook_subscription_writes` | `POST` / `PATCH` / `DELETE /v1/webhook_subscriptions` (+ sub-routes) | 30 / 60s per key | | `webhookListenSseConnections` | [`GET /v1/webhook_listen`](../integration/webhook-events.md) | 30 / 60s per key | | `sdkTelemetry` | `POST /v1/sdk-telemetry` | 30 / 60s per key | | `browserTelemetry` | `POST /v1/public/telemetry/browser` | 30 / 60s per IP | | `healthDeep` | `GET /api/health?deep=true` | 5 / 60s per IP | Shallow `GET /api/health` (no `deep` param) is intentionally unmetered — it's what uptime monitors hit. **Create sessions from your server with your secret key.** A session create sent with a secret key (`vp_sk_*`, or a legacy `vp_key_*`) counts against your key (30/min) and a loose 300/min ceiling for its IP address. It does not count against the strict 10/min per-IP limit, which still applies to calls with a publishable key or no key. This matters on serverless and edge platforms (Cloudflare Workers, for example), where many customers' traffic leaves from one shared address: with a `vp_sk_*` key you do not share a quota with them. The per-IP rejection emits `rate_limit_exceeded`; the per-key rejection emits `rate_limit_exceeded_per_key`, so SDKs can tell them apart. **The public session read is metered the same way**: 1000/min against your publishable key, but only **80/min from a single IP** — the number a single browser or a one-machine load test hits first. `POST /v1/public/binder-load` shares **both** buckets, so binder-load calls and session reads from one browser draw down the same 80/min allowance. ## Response headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` are emitted on **both successful responses and `429`s**. `Retry-After` is **not** exclusive to rate limiting. It also accompanies `503 service_unavailable` — during a maintenance write-freeze, where mutating money routes are refused so no new row lands (`Retry-After: 30`), and on a disabled `/v1/webhook_listen`. Branch on the **status code**, not on the presence of this header: a `429` means slow down, a `503` write-freeze means the write did not happen at all. | Header | When | Description | |---|---|---| | `X-RateLimit-Limit` | success + 429 | Maximum requests allowed in the current window | | `X-RateLimit-Remaining` | success + 429 | Requests remaining in the current window (`0` on a 429) | | `X-RateLimit-Reset` | success + 429 | Unix epoch seconds when the window resets | | `Retry-After` | 429, and also 503 | Seconds to wait before retrying: on a `429`, 1 to 60, the time until a slot frees in the window. A `503` (maintenance write-freeze, disabled webhook stream) sends it too; see the note above | ## Handling 429 ```json { "error": "Too many requests", "code": "rate_limit_exceeded", "fix": "Too many requests from this caller — wait the number of seconds in the Retry-After header (1–60), then retry with jittered exponential backoff", "docs": "https://docs.vonpay.com/reference/api#rate-limits" } ``` Wait at least `Retry-After` seconds (the same number is in `selfHeal.actions[].retryAfterSeconds`), then retry with jittered exponential backoff. Do not retry in a tight loop. The error envelope is flat (not nested). The example above shows the fields you'll branch on (`error`, `code`, `fix`, `docs`); every error response also carries a `selfHeal` object — see [Error Codes](error-codes.md) for the full envelope. ## SDK auto-retry The Node and Python SDKs automatically retry on `429` and `5xx` responses with exponential backoff: - The SDK reads `Retry-After` when present - Retry delay capped at 60 seconds - Default `maxRetries` is 2 — configurable via the constructor On a money-moving call (`paymentIntents.create` / `.capture` / `.void`, `refunds.create`) an ambiguous response — a `5xx` or a timeout — is retried **only if you supplied an `Idempotency-Key`**; without one the SDK stops and raises (Node: `VonPayError` with `retryWithheld: true`) because a repeat could charge the buyer twice. A `429` is retried either way. Full contract: [Node SDK → Auto-retry](../sdks/node-sdk.md#auto-retry). ## Hitting the per-key ceiling If you need more than 30 session creates/minute per API key (high-volume batch fulfilment, say), contact support — the limit can be raised. --- ## /reference/refunds Source: https://docs.vonpay.com/reference/refunds # Refunds `POST /v1/refunds` refunds a `succeeded` payment intent — in full or in part. Refund IDs use the `vpr_test_*` / `vpr_live_*` prefix. **Required key:** secret key (`vp_sk_*`). ## Create a refund Omit `amount` to refund the **full remaining balance** (the server computes `captured − previously refunded`). Pass an `amount` below the remaining balance for a partial refund. :::danger Send an `Idempotency-Key`, or a retry issues a second real refund Refund de-duplication only happens **when you send an `Idempotency-Key` header**. Without one there is no replay lookup at all: every call — including a retry your HTTP client makes automatically after a timeout, or a proxy replaying a POST — creates a new refund record and returns real money to the cardholder again. Generate the key server-side, keep it stable across retries of the *same* refund, and never derive it from buyer-supplied input. See [Idempotency](api.md#idempotency). ::: ```bash curl https://checkout.vonpay.com/v1/refunds \ -H "Authorization: Bearer vp_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order_1042_refund_1" \ -d '{ "payment_intent": "vpi_test_abc123", "amount": 500 }' ``` ```javascript const refund = await client.refunds.create( { paymentIntent: "vpi_test_abc123", amount: 500, // omit for a full refund }, { idempotencyKey: "order_1042_refund_1" }, ); ``` ### Refunding a payment that has no payment intent {#refund-by-transaction} Name **exactly one** parent: `payment_intent`, or `transaction` for a capture that has no payment intent, such as a guest Embedded Fields payment. Sending both or neither is refused. This is also how you act on [`refund_target_is_duplicate`](error-codes.md#refund_target_is_duplicate): that refusal names the transaction to use in `refund_instead`, and you refund that one. ```javascript const refund = await client.refunds.create( { transaction: "vp_tx_test_abc123", amount: 500, // omit for a full refund }, { idempotencyKey: "order_1042_refund_1" }, ); ``` In the Node and Python SDKs the transaction target arrived in **2.10.0**; naming both targets, or neither, throws before any request leaves your server. A payment intent id and a transaction id are both returned to the shopper's browser, so neither one authorizes a refund. Never refund an id taken from a client request: look it up on your server from your own order record, and check it belongs to the order and customer asking. ### Request | Field | Type | Required | Description | |---|---|---|---| | `payment_intent` | string | — | ID of the payment intent to refund (`vpi_test_*` / `vpi_live_*`). Optional, but you must supply exactly one parent reference — either `payment_intent` or `transaction`. | | `transaction` | string | — | Settlement-ledger id of the capture to refund (`vp_tx_test_*` / `vp_tx_live_*`). This is the parent for a guest embedded-fields capture, which has no payment intent. Send exactly one parent — `payment_intent` **or** `transaction`; sending both, or neither, is refused. | | `amount` | integer | — | Minor units; positive (≥ 1). Omit to refund the full remaining balance; pass a value below the remaining balance for a partial refund. | | `currency` | string | — | 3-letter alphabetic ISO 4217 currency code. | | `reason` | string: `duplicate` \| `fraudulent` \| `requested_by_customer` \| `expired_uncaptured_charge` | — | Mirrored back on the refund record. | | `metadata` | object | — | Object with string keys and arbitrary JSON values. | ### Response — `Refund` **A 2xx does not mean the buyer was repaid.** Four different success responses are possible and only one of them means the money has moved — branch on `status`, not on the status code alone. | HTTP | `status` | Did the buyer get their money? | |------|----------|-------------------------------| | `201` | `succeeded` | **Yes.** The refund completed and the money has moved. | | `202` | `requested` | **Not yet.** The provider accepted the refund but has not completed it. It is real and in progress. | | `200` | `canceled` | **No, and it never will be.** The reversal was voided before it left — terminal, and no money moved. | | `200` | *(any)* + `idempotent: true` | **No new refund was issued.** This is a replay of an earlier request that used the same `Idempotency-Key`. The `status` is whatever the original refund reached — including `failed`, which means the buyer was never repaid and this key can no longer repay them. | ```json { "id": "vpr_test_JL3xPcFktvsF10Ib", "payment_intent": "vpi_test_abc123", "amount": 500, "currency": "USD", "status": "succeeded", "reason": null } ``` ### A `202` is not a failure — do not retry it {#refund-202} On a provider that settles refunds asynchronously an accepted refund comes back as **`202` with `status: "requested"`** — real and in progress. Treat it as pending, never as failed: reissuing it pays the buyer twice. Resolve it from [`charge.refunded`](../integration/webhook-events.md#chargerefunded) (completed) or [`refund.failed`](../integration/webhook-events.md#refundfailed) (did not; a processor that reports a *declined* refund rather than a failed one also arrives as `refund.failed`, so there is no second event to subscribe to) — there is no refund-retrieve endpoint to poll. Not every connection emits the failure event, so a `requested` refund with no terminal event stays **open in your own ledger** and is reconciled on your side rather than waited on indefinitely. ### `200` with `status: "canceled"` is not a completed refund {#refund-canceled} The provider voided the reversal before it settled: terminal, and **the buyer was not repaid** — do not mark the order refunded. A `canceled` refund consumes no refundable balance, so once you know why it was voided, issue it again **with a new idempotency key**: the old key stays bound to the canceled record and replaying it returns this same response forever without reaching the processor. ### A failed refund's key is spent — but that does not mean reissue `failed` is the other terminal state, and the key behaves exactly as it does for `canceled`: the record stays bound to that `Idempotency-Key`, so replaying it returns the same `failed` refund with `idempotent: true` for as long as the record exists. Retrying under that key can never repay the buyer, however many times you send it. **What to do next depends entirely on how the failure reached you, and the two answers are opposites.** Decide from that before you decide anything else: | How it reached you | What it means | What to do | |---|---|---| | A **typed decline** — you received a [`refund.failed`](../integration/webhook-events.md#refundfailed) webhook carrying `reason_code: refund_declined` | The provider refused the reversal. Nothing moved. | If `retry_available` is `false`, the payment was disputed and the buyer already has the money back: do **not** reissue or refund another way. Otherwise resolve the cause, then reissue under a **new** key. | | A **`provider_unavailable` or a timeout on your original `POST /v1/refunds` call** | **State unknown.** The reversal may already have gone through. | **Do not reissue.** Record it as unresolved in your ledger and settle it with support. | An ambiguous refund never arrives as `refund.failed` — it surfaces only as the synchronous error on your original call, so do not wait for an event to tell the two apart ([`provider_unavailable`](error-codes.md#provider_unavailable)). Reissuing a refund whose first attempt went through pays the buyer twice out of your balance, and the refundable balance still permits it. | Field | Type | Description | |---|---|---| | `id` | string | Refund record ID (`vpr_test_*` / `vpr_live_*`). | | `payment_intent` | string \| null | The payment intent this refund applies to. **Both parent keys are always present and exactly one is non-null** — this is `null` on a refund anchored to a transaction. Branch on which one is non-null; do not test for the key's presence, because both are always there. | | `transaction` | string \| null | Settlement-ledger id this refund applies to (`vp_tx_test_*` / `vp_tx_live_*`). **Both parent keys are always present and exactly one is non-null** — this is `null` on a refund anchored to a payment intent. | | `amount` | integer | Refunded amount, in minor units. | | `currency` | string | ISO 4217 (uppercase on response). | | `status` | string: `requested` \| `succeeded` \| `failed` \| `canceled` | The refund's own lifecycle state, distinct from the parent payment intent's. See [Status](#status). | | `reason` | string \| null: `duplicate` \| `fraudulent` \| `requested_by_customer` \| `expired_uncaptured_charge` | The reason supplied on create, or `null`. | | `idempotent` | boolean | `true` when this response is a replay of an earlier request that used the same `Idempotency-Key` — meaning **no new refund was issued**. Read it before treating the response as a fresh refund, or a retried call will look like a second refund that never happened. ⚠️ **This field is not a safety net.** It only ever appears because you sent an `Idempotency-Key`; without one there is no replay check and a retry issues a second real refund. | | `idempotency_key` | string \| null | On a refund read through [`GET /v1/refunds`](#list-refunds): the `Idempotency-Key` it was created with. For a refund you made with `POST /v1/refunds` it is your key exactly as sent (only leading and trailing spaces removed), so you can match refunds to your own keys. A refund issued from the dashboard or a connected store carries the key that product used; `null` for a refund made directly at the payment provider or created without a key. | | `created_at` | string | On a refund read through [`GET /v1/refunds`](#list-refunds): when the refund record was created. | ## List a payment's refunds {#list-refunds} `GET /v1/refunds` · secret key. Name the payment with exactly one of `payment_intent` (`vpi_…`) or `transaction` (`vp_tx_…`, the `vp_tx_id` on the session). Refunds made through either id are listed whichever one you ask with. ```bash curl "https://checkout.vonpay.com/v1/refunds?payment_intent=vpi_live_EXAMPLE" \ -H "Authorization: Bearer vp_sk_live_YOUR_KEY" ``` ```json { "object": "list", "data": [ { "id": "vpr_live_EXAMPLE", "payment_intent": "vpi_live_EXAMPLE", "transaction": null, "amount": 1000, "currency": "USD", "status": "succeeded", "reason": "requested_by_customer", "idempotency_key": "your-refund-id", "created_at": "2026-10-05T14:02:11Z" } ], "has_more": false, "next_cursor": null, "amount_refunded": 1000, "remaining_refundable": 3999, "refundable": true } ``` - `data` holds every refund of the payment, newest first, in any status. Check each one's `status`: a `requested` refund is still in progress and can still fail. - `amount_refunded` counts refunds that succeeded or are still in progress; failed and canceled refunds are not counted. The totals cover all of the payment's refunds, not just this page. - `remaining_refundable` is what a refund with no `amount` would return now, and `0` when `refundable` is `false`. - `refundable` says whether the payment itself qualifies (settled, not disputed, not already fully refunded). The refund call still checks account conditions such as refunds being switched on for your provider. - Page with `limit` (1–100, default 25) and `starting_after`: pass `next_cursor` while `has_more` is `true`. From SDK 3.6.0 this is `refunds.list()` in [Node](../sdks/node-sdk.md#vonpayrefundslisttarget-page) and [Python](../sdks/python-sdk.md#list-a-payments-refunds). To see what has been refunded, read these totals (also on [`GET /v1/payment_intents/{id}`](payment-intent-object.md) and [`GET /v1/sessions/{id}`](session-object.md)) rather than adding up webhooks. An unknown id, another account's payment, or a payment in the other key mode returns `404`; a duplicate `transaction` returns the same `422 refund_target_is_duplicate` that `POST /v1/refunds` does. ## Status A refund record is created in `requested` — returned to you directly with HTTP `202` on an asynchronous provider — and reaches a terminal `succeeded` or `failed`; `canceled` is a rare terminal state. ``` requested ──▶ succeeded └─▶ failed ``` ## Full vs. partial - **Full** — omit `amount`. Refunds `captured − previously refunded`. - **Partial** — pass an `amount` below the remaining refundable balance. The full-vs-partial outcome is determined by the amount relative to the remaining balance, not by whether `amount` is present. - Multiple partial refunds against the same intent are allowed up to the remaining refundable balance. Each issues a separate refund record and fires its own [`charge.refunded`](../integration/webhook-events.md#chargerefunded) event. ## Constraints | Condition | Result | |-----------|--------| | `amount` exceeds the remaining refundable balance | `422` [`refund_amount_exceeds_remaining`](error-codes.md#refund_amount_exceeds_remaining). The error envelope carries `remaining_refundable`. | | The source intent is not in a refundable state (status is not `succeeded`) | `422` [`refund_intent_not_refundable`](error-codes.md#refund_intent_not_refundable). The error envelope carries `payment_intent` and `current_status`. | | A dispute or chargeback is recorded on the payment's settlement | `422` [`refund_intent_not_refundable`](error-codes.md#refund_intent_not_refundable), with `current_status` reading `disputed` or `chargeback`, before anything reaches the provider. The buyer gets the money back through the dispute, so a refund on top would pay them twice. The intent itself still reads `succeeded`, because a dispute is recorded on the settlement rather than on the intent. A dispute the provider has not yet linked to this payment is not seen by this check, so a refund can still reach the provider and come back as [`refund_declined`](error-codes.md#refund_declined). | | No `Idempotency-Key` header, **and** the payment was taken on a provider connection that keeps no queryable record of a reversal | `400` [`idempotency_key_required`](error-codes.md#idempotency_key_required). Not retryable as sent — add the header. **Only these connections REJECT a keyless refund** — every other one ACCEPTS one, and is *not* protected by it. Send the key regardless. | ## Void-after-capture **To reverse a captured intent, issue a refund.** Call `refunds.create`, not `paymentIntents.void` — voiding applies to an authorization that has not been captured, and once the intent is `succeeded` the money has moved. Every processor reports [`void_after_capture`](capabilities.md#supported_operations) as `not_supported`, so `refunds.create` is the path on all of them; if you branch on that capability, make the refund the fallback ([branching on the matrix](capabilities.md#branching-on-the-matrix)). ## Reconciliation A refund that **completes** fires a [`charge.refunded`](../integration/webhook-events.md#chargerefunded) webhook carrying `refund_id`, `amount` (this refund), `is_partial`, and `original_charge_amount`. A refund that **fails** fires [`refund.failed`](../integration/webhook-events.md#refundfailed) instead — the buyer has not been paid back. It reaches every active subscription whether or not you selected it, but not every processor connection emits it, so keep an unresolved refund open in your own records until you see one or the other. That event carries a `reason_code`, and the values need opposite handling: retrying a `refund_unresolved` blind can pay the buyer twice. **To get the cumulative refunded total for a charge,** read **`amount_refunded_total`** off the latest [`charge.refunded`](../integration/webhook-events.md#chargerefunded) event and compare it against `original_charge_amount`. ⛔ **Do not sum the `amount` field** — its meaning varies by processor connection (this-refund on some, running-total on others), so summing over-counts and marks an order fully refunded early. :::warning Don't decide "fully refunded" from `is_partial` `is_partial` describes one refund, and how it is derived is not identical across payment providers — a second refund that completes the total can still report `is_partial: true`. Use `amount_refunded_total` as above. See [the note on the events page](../integration/webhook-events.md#chargerefunded). ::: ## Related - [Payment Intents](../integration/payment-intents.md) — the intent a refund applies to - [Capabilities](capabilities.md) — `partial_refund`, `void_after_capture` - [Webhook Events](../integration/webhook-events.md#chargerefunded) — `charge.refunded` and [`refund.failed`](../integration/webhook-events.md#refundfailed) --- ## /reference/security Source: https://docs.vonpay.com/reference/security # Security ## Authentication API requests use Bearer token authentication: ### Key Types {#key-types} ``` Authorization: Bearer vp_sk_live_xxx ``` Von Payments issues two kinds of **API key** you authenticate with and two kinds of **signing secret** you never send in a request. Older accounts may also hold a fifth, legacy key — see the note below the list: - **Secret key** (`vp_sk_test_…` / `vp_sk_live_…`) — server-side only, authenticates every API call. A live key presents your provider's production credentials and moves real money. - **Publishable key** (`vp_pk_test_…` / `vp_pk_live_…`) — the browser-side counterpart, safe to ship in a bundle. - **Session signing secret** (`ss_…`) — older accounts only; it is no longer issued, and **nothing in the integration asks you for it**. In particular it does not confirm a return redirect — those signatures are produced with a single platform-wide secret, so an `ss_*` passed to a return verifier fails every time. - **Webhook signing secret** (`whsec_…`) — verifies the webhooks we send you. Per endpoint, and **not** your API key. See [Webhook signing secrets](../integration/webhook-secrets.md). ⚠ **Older accounts may also hold a `vp_key_…` key.** It predates the current prefixes and is still accepted — as a **full secret key**. If you find one in a bundle or a config, treat it as live credentials and rotate it to a `vp_sk_` key; do not assume it is inert because it is not in the list above. Prefixes, which key each route accepts, rotation and expiry: [API keys](api-keys.md#key-types-at-a-glance). Mode is decided by the key prefix on **every request** — one account holds both and flips per request; a test key never moves real money ([Test mode](test-cards.md)). The prefix picks the mode, not the host: the same host serves both, and a browser whose `apiBaseUrl` names a different host than your server gets `401 auth_invalid_key_publishable` on every call ([troubleshooting](../troubleshooting.md#auth_invalid_key_publishable--http-401)). A compromised secret key is revoked, with no grace window, from the dashboard: [Compromise — skip the grace](api-keys.md#compromise--skip-the-grace). ### Key Rotation {#key-rotation} Secret keys can be rotated without downtime. When a key is rotated, the previous key enters a grace window that is **caller-configurable — `1h`, `24h` or `7d`, defaulting to 24h** — during which both keys authenticate. After the grace window closes, the previous key returns `401` with `code: auth_key_expired` — distinct from `auth_invalid_key` so SDKs can detect rotation and refresh instead of failing the payment. - A revoked key (no rotation metadata) returns `auth_invalid_key`; a key deactivated mid-rotation returns `auth_key_expired` Rotate keys via `/dashboard/developers/api-keys`. The UI shows the new plaintext exactly once — store it immediately. ## Confirming the return redirect The buyer returns to your `successUrl` with `?session=vp_cs_…` whatever the outcome, through their own browser — treat everything on that URL as untrusted, and reaching the page proves nothing. Read the outcome from `sessions.get` with your secret key and fulfil on the `charge.succeeded` webhook: [Handle the return](../integration/handle-return.md). Signature checking belongs on webhooks, not on the redirect — a declined payment is signed exactly as validly as an approved one. ## PCI Compliance Card number, expiry and CVC are entered in a secure iframe hosted by the payment processor and never touch Von Payments' servers or yours, on Hosted Checkout and on Embedded Fields in either mode — your PCI scope stays at SAQ-A. :::warning `elements` mode puts billing-details fields in YOUR page The card fields stay in the iframe in both modes, but in `elements` mode the SDK renders **cardholder name, email, the save-card checkbox and (by default) the billing address as ordinary inputs in your own DOM** — the cardholder-name input even carries `autocomplete="cc-name"`, so the browser may autofill it from the buyer's saved card. A connection whose capability map declares the address `native` gets the processor's own address element instead, rendered in its isolated iframe; the [capability matrix](../embedded-fields/binder-profiles.md#capability-matrix) says which yours declares. Any other script on that page can read those values. Your SAQ-A eligibility is unchanged, but your page-script hygiene (a tag manager, an analytics vendor, a compromised dependency) is now in scope for buyer identity data. If you cannot vouch for every script on your checkout page, prefer `embed` mode. ::: ## Data Encryption | Data | Protection | |------|-----------| | Card data | Never stored — entered in processor's iframe | | Buyer name | AES-256-GCM encrypted at rest | | Buyer email | AES-256-GCM encrypted at rest | | API keys | SHA-256 hashed in database | | Checkout session IDs | Cryptographically random (`vp_cs__` + nanoid) | ## Transport Security - Merchant-supplied URLs (such as `successUrl` and `cancelUrl`) must use **HTTPS**; `localhost` is exempt at the validation layer so you can test locally - Live-mode keys **reject** `localhost`/loopback redirect URLs — local redirects are permitted only with test-mode keys - HSTS header with 2-year max-age - TLS 1.2+ only (per the platform's information security policy) ## Security Headers The checkout page serves these headers: | Header | Value | |--------|-------| | `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | | `X-Frame-Options` | `DENY` | | `X-Content-Type-Options` | `nosniff` | | `Referrer-Policy` | `strict-origin-when-cross-origin` | | `Content-Security-Policy` | Restrictive nonce-based policy allowing only required domains | ## Rate Limiting Every endpoint is rate-limited per IP, per API key, or both — buckets, limits and the `429` headers are on [Rate Limits](rate-limits.md). ## API Versioning The API is versioned by date through the `Von-Pay-Version` request header — [API Versioning](versioning.md). ## Webhook Signature Verification Webhooks are signed with HMAC-SHA256, keyed with a per-endpoint `whsec_*` signing secret. The signature ships in the `x-vonpay-signature` request header with both the timestamp and the HMAC inline: ``` x-vonpay-signature: t=1714406400,v1=abc123def456... ``` The exact bytes to sign, the replay window and working verifiers in Node, Python, Go, Ruby and PHP are on [Webhook Signature Verification](../integration/webhook-verification.md). The platform signs with exactly one secret and sends one `v1=` entry, so there is no rotation grace window on our side — [Rotating a signing secret](../integration/webhook-secrets.md#3-rotate). Each endpoint's `whsec_*` is its own secret: not your API key, and not the `ss_*` session secret. ## Reporting Vulnerabilities If you discover a security vulnerability, contact security@vonpay.com. --- ## /reference/session-object Source: https://docs.vonpay.com/reference/session-object # Session Object A checkout session represents a single payment attempt from creation to completion. ## Session ID Format: `vp_cs_{env}_{nanoid}` - `vp_cs_test_k7x9m2n4p3q8r1s5`: sandbox session - `vp_cs_live_k7x9m2n4p3q8r1s5`: production session The ID ends in a 16-character random string. It cannot be guessed. ## Fields ### Core fields | Field | Type | Always Present | Description | |---|---|---|---| | `id` | string | Yes | Session ID | | `status` | string: `pending` \| `processing` \| `succeeded` \| `failed` \| `expired` | Yes | Where the session is in its lifecycle. The status table below gives the trigger for each value. | | `expiredFrom` | string \| null: `pending` \| `processing` \| `failed` | No | How an `expired` session expired, and whether a payment may still exist on it. `null` whenever `status` is not `expired`. `pending` means no payment had been started through Von Payments' servers, so nothing was charged. `failed` means nothing was charged either: a declined `elements` + `chargeAtSubmit` session that was closed with nothing recorded, or a session whose time limit passed while the buyer was at their bank's security check, once the payment provider confirmed that payment was never authorized. `processing` means a payment HAD been started and its outcome was never reported before the time limit passed: **do not read it as "nothing was charged"**. On an `elements` + `chargeAtSubmit` session it can later change to `failed` when the provider confirms no authorization after the bank check times out; that attempt's `payment_intent.failed` / `charge.failed` webhooks announce it. Otherwise do NOT rely on polling the session or on a webhook to settle it, because a payment that lands on an already-expired session is not reported today. Contact support with the session id before charging the buyer again for the same order. `null` on a session that IS `expired` is unknown, and should be treated like `processing`. | | `paymentStatus` | string: `unpaid` \| `held` \| `paid` \| `cancelled` | Yes | **Did the money move?** Deliberately separate from `status`, which says whether the *buyer* finished. `unpaid` means nothing is committed. `held` means **authorised, not captured**: the card is on hold and you must [capture](/integration/payment-intents#capture-an-authorized-intent) or [void](/integration/payment-intents#void-an-authorized-uncaptured-intent) it. **Do not fulfil on `held`.** An authorisation you never capture lapses at the issuer and the sale is lost, and the window is shorter than most integrations assume: around seven days on Mastercard, American Express and Discover, about five on a Visa merchant-initiated payment, as little as two for card-present. Treat the shortest as your deadline unless you know the brand. `paid` means captured, and is the one to fulfil on. `cancelled` means a hold released without ever being captured. Refunds are **not** reported here: a captured-then-refunded session stays `paid`, so read the [refund](/reference/refunds) for that. | | `mode` | string: `payment` \| `setup` | Yes | Payment mode. | | `captureMethod` | string: `automatic` \| `manual` | Yes | Echoes the [`captureMethod`](/integration/create-session#capture-method) the session was created with (`automatic` or `manual`), so you can see which semantics it carries without inferring them from `paymentStatus`. | | `integrationMode` | string: `embed` \| `elements` | Yes | The integration mode the session was created with, as applied: `embed` if you asked for `elements` and your account does not support it. The same value the create response returned. | | `channel` | string \| null: `virtual_terminal` | Yes | Which Von Payments product created the session: `virtual_terminal` for a sale keyed in your dashboard's virtual terminal, `null` for a session your integration created. Set by Von Payments; you cannot send it. | | `chargeAtSubmit` | boolean | Yes | Whether the embedded fields charge the buyer on submit, as applied at create: `false` unless the session is an `elements` session in `payment` mode that asked for it. | | `threeDSecure` | object | Yes | How the session will authenticate the cardholder if the bank asks for it: an object whose `mode` is `in_page`, `redirect` or `not_applicable` (treat it as an open list). Worked out each time you read it, so it follows your account's current setup. It says what will happen, not that a challenge ran. | | `storedCredentialUse` | string \| null: `recurring` \| `installment` \| `unscheduled` | Yes | The [`storedCredentialUse`](/integration/create-session) declaration recorded at create: `recurring`, `installment` or `unscheduled`. `null` means none was declared. Read it back to confirm your declaration landed. | | `setupForFutureUse` | string \| null: `on_session` | Yes | `on_session` when you declared [`setupForFutureUse`](/integration/create-session) at create, otherwise `null`. This is your declaration. What a saved card actually records is on the charge result, where a buyer who ticked "save my card" (`off_session`) wins. | | `merchantId` | string | Yes | Merchant account that owns this session | | `amount` | integer | Yes | Payment amount in minor units | | `currency` | string | Yes | ISO 4217 currency code (a required 3-letter uppercase code) | | `country` | string \| null | No | ISO 3166-1 alpha-2 country code (optional) | | `description` | string \| null | No | Human-readable description of the payment (max 500 characters) | | `locale` | string \| null | Yes | The checkout page language you sent at create, as stored. `null` when none was sent. | | `successUrl` | string \| null | No | Redirect URL on success. Echoes the value supplied at creation (`null` if none) | | `cancelUrl` | string \| null | No | Redirect URL on cancel. Echoes the value supplied at creation (`null` if none) | | `shipping` | string: `none` \| `auto` \| `required` | Yes | Shipping-address collection mode **actually applied**: `none`, `auto` or `required`. Not necessarily what you sent: omit the field on create and this is your account setting (`none` out of the box); send it with a publishable key and a value weaker than your account setting is raised to it. See [Asking the buyer for more than a card](../integration/create-session.md#buyer-fields). | | `billingAddress` | string: `none` \| `auto` \| `required` | Yes | Billing-address collection mode actually applied: `none`, `auto` or `required`. Your account setting when you omitted it. This is the mode, not the buyer's address. | | `phone` | string: `none` \| `auto` \| `required` | Yes | Phone collection mode actually applied: `none`, `auto` or `required`. The number the buyer typed is `buyerPhone`. | | `collectEmail` | boolean | Yes | Whether the checkout shows an email input even when a buyer email was pre-filled, as stored at create. | | `nameCollection` | string: `none` \| `auto` \| `required` | Yes | Whether this session requires the buyer's name: `none`, `auto` or `required`, from your account's buyer-field settings when the session was created. | | `emailCollection` | string: `auto` \| `required` | Yes | Whether this session requires the buyer's email: `auto` or `required`, from your account's buyer-field settings when the session was created. Email is always collected, so it is never `none`. | | `shippingAddress` | object \| null | No | Buyer's shipping address (only present when status is `succeeded`) | | `buyerId` | string (≤ 200) \| null | Yes | Your own reference for this session's buyer, as sent at create. `null` when the session has none. The buyer's email and name are personal data and are never returned here. | | `buyerPhone` | string \| null | Yes | The phone number the **buyer typed** on the checkout page. `null` when none was collected. Not the same as `phone`, the *collection mode* you set at creation, which decides whether the field is shown. | | `transactionId` | string \| null | No | The payment provider's reference for this session's payment (set on completion). **Not the id refunds take**: use `vp_tx_id`. | | `vp_tx_id` | string \| null | Yes | The Von transaction id (`vp_tx_*`): the id [`POST /v1/refunds`](refunds.md) takes as `transaction`, and the same `vp_tx_id` the `charge.*` webhooks carry, so a server that confirms a payment by reading the session can refund it without a webhook. `null` until captured, and while `paymentStatus` is `held`. Can trail `paid` by a few moments: read again rather than treating `null` as "no payment". Also `null` when `vp_tx_count` is 2 or more. | | `vp_tx_count` | integer | Yes | How many separate payments are recorded for this session: `0` before payment and while `held`, `1` normally. **2 or more means the buyer was charged more than once**: `vp_tx_id` is then `null` on purpose. Check the payments by hand before refunding or fulfilling. | | `amount_refunded` | integer \| null | Yes | Total refunded so far on the session's payment, in minor units: refunds done or still in progress (`requested`). Failed and canceled refunds are not counted. On `GET` only; `PATCH` and expire return `null`. Also `null` when `vp_tx_id` is `null` or the totals could not be read at that moment. To list the refunds themselves, call [`GET /v1/refunds?transaction=`](refunds.md#list-refunds) with `vp_tx_id`. | | `remaining_refundable` | integer \| null | Yes | What a refund with no `amount` would return now, in minor units: the captured amount minus `amount_refunded`. A refund still in progress is already subtracted; if it later fails, its amount comes back. `0` whenever `refundable` is `false`. `null` in the same cases as `amount_refunded`. | | `refundable` | boolean \| null | Yes | Whether the payment qualifies for a refund now, by the same rules [`POST /v1/refunds`](refunds.md) applies: `false` when it is not settled yet, disputed, already fully refunded or not refundable through the API. Account conditions, such as refunds being switched on for your payment provider, are still checked by the refund call. `null` in the same cases as `amount_refunded`. | | `paymentIntentId` | string \| null | Yes | The payment intent this session's payment created. **On a `captureMethod: "manual"` session this is the id you [capture](/integration/payment-intents#capture-an-authorized-intent) or [void](/integration/payment-intents#void-an-authorized-uncaptured-intent)** once `paymentStatus` reads `held`. Also set on a paid automatic payment, where the money is already taken: use it to read or refund, never to capture. Decide from `paymentStatus` (only `held` owes a capture), not from this field being present. `null` before payment, for a few moments while a payment is confirmed, and when more than one payment intent is recorded (check by hand rather than capturing either). | | `retry` | object \| null | Yes | Whether a declined `elements` + `chargeAtSubmit` session will take another card, on an account with retry on the same session switched on: `{ allowed, attempts_remaining }`. `allowed: true` means a decline re-opened the session (`status` is `pending`) and the buyer can pay with another card. `allowed: false` with `attempts_remaining` above 0 means re-opened, with the next card form still being prepared: read again in a second. `allowed: false` with `attempts_remaining: 0` means the session ended with a decline (`failed`). `null` when retry on the same session is not switched on for your account, the session is not `elements` + `chargeAtSubmit`, nothing has been declined, or a decline handed the session back (`pending`) without using up an attempt, in which case it can still be paid. Only `allowed: false` with `attempts_remaining: 0` is final; [close the session](api.md#expire-session) before you cancel an order on it. **This is the only place to learn it after a decline on the bank's 3-D Secure page**, where no charge response is waiting. The [failure webhooks](../integration/webhook-events.md#retry-after-decline) carry the same field. | | `last_payment_error` | object \| null | Yes | The most recent declined attempt, when the session `failed`, was re-opened after a decline, or is `expired` with `expiredFrom: "failed"`: `{ code, decline_code }`. `code` is `card_declined` (the bank refused) or `payment_failed` (any other reason); `decline_code` uses the same values as a payment intent's [`decline_code`](payment-intent-object.md), or `null`. Filled in on `GET` only; `PATCH` and expire return `null`. Also `null` if it could not be read at that moment, which never fails the read. | | `metadata` | object of string \| null | No | Merchant-provided key-value pairs (passed through to webhooks) | | `createdAt` | string | Yes | ISO 8601 creation timestamp | | `updatedAt` | string | Yes | ISO 8601 last update timestamp | | `expiresAt` | string | Yes | ISO 8601 expiry timestamp | A buyer on the session is what makes the embedded charge-and-save flow vault a reusable `vp_pmt_*` alongside the charge; a guest session charges once and vaults nothing (`charged: true`, no token). A returning buyer also sees the cards saved in prior completed sessions inside the embed — [buyer identification](../integration/create-session.md#buyer-identification). ### Payment routing (VORA) Processor selection happens server-side. Neither `POST /v1/sessions` nor `GET /v1/sessions/:id` tells you which processor runs the payment — [VORA](../concepts/vora.md). ## Status Lifecycle ``` pending ──> processing ──> succeeded └──> failed ──> succeeded (retry converges) pending ──> expired processing ──> expired ``` | Status | Description | Trigger | |--------|-------------|---------| | `pending` | Session created, waiting for buyer | `POST /v1/sessions` | | `processing` | Buyer has acted; charge in flight | Buyer submits payment | | `succeeded` | Payment completed | Payment processor confirms the capture | | `failed` | Payment declined or errored | Payment processor rejects | | `expired` | Session reached its expiry without completing | Cleanup sweep (see Rules) | ### Rules - A succeeded session cannot become failed, and cannot be charged again: one session pays for one order **once**. The reverse is allowed. `failed` is not terminal; a buyer whose card was declined may pay again on the same session until it expires, and a successful retry converges it to `succeeded`. Do not mark an order dead on `failed`. Keep listening for `charge.succeeded`. - Expired sessions cannot be re-activated; create a new session. - Session TTL defaults to **30 minutes**, but is configurable per session via `expiresIn` (from 5 minutes up to 7 days). `expiresAt` equals the creation time plus that TTL. - A session that passes its `expiresAt` without completing is moved to `expired` by a periodic cleanup sweep (not at the exact instant the TTL elapses). Only sessions that have not moved money are expired this way. --- ## Statement Descriptors Source: https://docs.vonpay.com/reference/statement-descriptors # Statement Descriptors A **statement descriptor** is the short line of text a buyer sees next to a charge on their card statement or banking app. It has **two parts**, and they behave very differently: | Part | What it is | Who controls it | Reliable? | |---|---|---|---| | **Brand prefix** | Your business name — the part a buyer reads as "who charged me". | Your **merchant account**, registered with your payment processor at boarding. | Set once, at the account level — **not** per charge. | | **Dynamic tail** | A short per-transaction identifier appended after the brand (e.g. an order number). | **You**, per charge, through the Payments API. | Yes — this is the reliable lever. | Put together, a statement typically reads like: ``` BRAND*ORD-1234 ``` where `BRAND` is your registered business name and `ORD-1234` is the dynamic tail you sent on that specific charge. To change the brand a buyer sees, update the registered descriptor on your merchant account with your payment processor — many banks show that registered name and ignore any business name sent on an individual charge. The tail is what you set per charge. ## How the descriptor is configured Descriptor behaviour is configured per merchant by Von Payments — ask your Von Payments contact to set it up or change it. A configuration has two pieces: 1. **A fixed identity** — your business `name`, plus optional `city`, `country`, `phone`, `url`, and `postal code`. These describe your business and, where a processor accepts a structured descriptor, travel with the charge. 2. **A dynamic-suffix rule** — how the per-transaction tail is chosen. The rule has a **source** (where the tail comes from) and a **fallback** (what to do when that source is missing on a given charge). ### Where the brand line actually comes from ⚠️ **Your configured `name` is not always what prints as the brand.** It depends on the processor connection your account uses, and the difference matters if you are building a preview of what the buyer will see. | Connection style | What supplies the brand | What your `name` does | |---|---|---| | A structured-descriptor connection | Your configured `name` is sent with the charge as the brand. | It is the brand. | | A prefix-and-suffix connection | The prefix comes from **your own account settings held at the processor**, and those take precedence. | It does **not** print. It only estimates the character budget left for the tail — and that estimate is wrong when the two differ. | On a prefix-and-suffix connection the printed result is `PREFIX* tail`, and the separator (`*` plus a space) counts against the total — so two characters of your budget go to punctuation before any of your text does. **Do not build a buyer-facing preview screen from your configuration alone.** Use it to see what you have configured and to compute the tail your `metadata` will produce; confirm the final printed descriptor at the processor. [Confirming what was sent](#confirming-what-was-sent) covers how. ### Tail sources The **source** decides which value becomes the tail. All except `none` read a field from the charge's `metadata`: | Source | Reads from | Typical use | |---|---|---| | `order_id` | `metadata.order_id` | Show the order number on the statement. | | `subscription_id` | `metadata.subscription_id` | Show the subscription a renewal belongs to. | | `customer_id` | `metadata.customer_id` | Show your internal customer reference. | | `custom` | `metadata.` | Any metadata key you choose (e.g. `product_name`). | | `none` | — | No dynamic tail; the statement shows the brand only. | ### Fallbacks The **fallback** decides what happens when the source field is absent or empty on a charge: | Fallback | Behavior when the source field is missing | |---|---| | `skip` | No tail is added — the statement shows the brand alone. | | `pi_ref` | The **last 8 characters of the payment intent ID** are used as the tail, so the charge is still traceable. | | `literal` | A **fixed text you configured** is used as the tail. | ## Feeding the tail from your code You supply the tail's **value** per charge in the PaymentIntent `metadata` — the same `metadata` object you already send on [`POST /v1/payment_intents`](../integration/payment-intents.md). The **key** is fixed by your configured rule; you provide the value. If your rule's source is `order_id`: ```json { "amount": 4999, "currency": "USD", "metadata": { "order_id": "ORD-1234" } } ``` If your rule uses a `custom` source with the key `product_name`: ```json { "amount": 4999, "currency": "USD", "metadata": { "product_name": "Blue Widget" } } ``` The buyer's statement then reads roughly `BRAND*ORD-1234` or `BRAND*Blue Widget` (subject to the length and character rules below). If you omit the configured key on a charge, the rule's **fallback** applies. :::note The tail can never be sourced from buyer PII A `custom` key that looks like buyer-identifying data — `email`, `phone`, `card`, `ssn`, `tax_id`, `address`, and similar — is **rejected when the rule is set up**. Choose a non-PII key such as `order_id` or `product_name`. ::: ## Length and character rules Card networks give the whole descriptor very little room, so a few rules apply before anything reaches the bank: - **Total length: 22 characters.** The entire descriptor — brand prefix, the separator, and the tail — is capped at **22 characters**. The prefix and separator consume part of that budget; the tail gets whatever remains. - **The tail is hard-truncated.** If the tail is longer than the remaining room, it is **cut** to fit — there is no `…` ellipsis. A tail of `ORDER-2026-000199` can land on the statement as `ORDER-2026-00` if that's all that fits. Keep tails short and put the most identifying characters first. - **Printable ASCII only.** Tail values are reduced to printable ASCII (letters, digits, and common punctuation) before use; emoji, accented letters, and non-Latin characters are stripped out. The configured brand name is likewise limited to **22 printable-ASCII characters** and can't contain `< > \ ' " *`. Because the tail is cut, not summarised, front-load the part that identifies the charge: `1234-ORDER` truncates more usefully than `ORDER-NUMBER-1234`. ## Confirming what was sent Every [`POST /v1/payment_intents`](payment-intent-object.md) response includes a `resolved_descriptor` object so your integration can confirm exactly what was applied — no dashboard or database access needed. - **When a descriptor was applied**, `resolved_descriptor` echoes what was dispatched. Read it to see the exact tail that will reach the bank *after* truncation. `kind` tells you which of two applied shapes you got, and it never names a payment provider. Branch on these three values only: | `kind` | Shape | Meaning | |---|---|---| | `suffix` | `{ kind, suffix }` | A dynamic tail appended to the descriptor prefix registered on your processing account. | | `descriptor` | `{ kind, descriptor: { name, description, city, country, phoneNumber, url, postalCode } }` | A full descriptor block sent with the charge. | | `skipped` | `{ kind, reason }` | Nothing was applied. See the reasons below. | - **When nothing was applied**, it's a skipped record naming the reason. The charge then falls back to your processor's own default statement descriptor: ```json { "resolved_descriptor": { "kind": "skipped", "reason": "no_config" } } ``` ### Why a descriptor was skipped Four reasons reach you: | `reason` | What happened | What to do | |---|---|---| | `no_config` | No descriptor was sent for this charge. | If your account has no descriptor configured, ask your Von Payments contact to set one up. Otherwise see the caution below: this reason also covers a transient lookup failure and a configuration that produced nothing for this charge. | | `kill_switch` | Descriptor passthrough is switched off platform-wide. | Nothing on your side. Charges still succeed; they carry your processor's default descriptor until it is switched back on. | | `binder_unsupported` | The processor connection your account uses does not accept a descriptor from us today. | Nothing on your side — this is a property of the connection, not of your configuration. | | `invalid_for_provider` | Your configured descriptor breaks a length or format limit the processor enforces (for example a name shorter than 5 characters). The charge went through without it, so your account's default descriptor applies. | Ask your Von Payments contact to correct the descriptor; it is applied again from the next charge after that. | On a structured-descriptor connection, an **optional** field (`description`, `city`, `country`, `phoneNumber`, `url`, `postalCode`) that breaks a processor limit is dropped on its own and the rest of the descriptor, including your name, still goes out. The usual case is a per-charge `description` that resolves to a short order number. That is not a skip: `resolved_descriptor` reads `kind: "descriptor"` and simply lacks the field. Compare it with your configuration to spot one. **`no_config` does not always mean "you have not configured one."** It means no descriptor was sent, for one of three reasons that report identically: - the account has no descriptor configured; - the configuration read failed (it never fails the charge, which proceeds without a descriptor); - on a connection that appends a dynamic suffix, the configuration was read but resolved to an empty suffix for this charge, so the connection's default applies. On an account with a descriptor configured, `no_config` is not a sign that the configuration was lost; do not re-create it on the strength of this value. Descriptor passthrough is resolved only on a real processor connection, so verify a descriptor with a live key, or on a sandbox account with its own processor test account. A merchant account's test key cannot take a test payment ([`sandbox_account_required`](error-codes.md#sandbox_account_required)). The SDK types and the contract also list a fourth value, `circuit_open`. It is declared and not emitted — a configuration read failure reports `no_config` — so do not write a branch that waits for it. ## Related - [Payment Intent object](payment-intent-object.md) — the `metadata` field and the full charge response shape - [Payment Intents guide](../integration/payment-intents.md) — creating a charge and attaching `metadata` - [Create a session](../integration/create-session.md) — note that a session's `description` is **not** the statement descriptor - [Error codes](error-codes.md) — provider length/character limits on descriptor and `metadata` values --- ## /reference/test-cards Source: https://docs.vonpay.com/reference/test-cards # Test mode A test-mode key (`vp_sk_test_*`, `vp_pk_test_*`) never moves real money. Test payments run on a sandbox account: a test request runs against its processor's own test environment, and that environment decides every outcome. A test key on a live merchant account, or on a sandbox with no processor attached, is refused with `422` [`sandbox_account_required`](error-codes.md#sandbox_account_required). | Your key | A request goes to | What decides the outcome | |---|---|---| | **Test** (`vp_sk_test_*`, `vp_pk_test_*`) | Your sandbox account's processor test environment | The [order total](#processor-test-account), for declines | | **Live** (`vp_sk_live_*`, `vp_pk_live_*`) | The processor itself, moving real money | The processor. A live key on a sandbox account is refused with `auth_key_type_forbidden`; it is never simulated. | [`GET /v1/capabilities`](capabilities.md) reports the connection your account actually runs on. Read it if you are unsure what you are pointed at. ## Test cards {#test-cards} Sandboxes run 3-D Secure on every card payment, so only these cards work. Common test numbers such as `4242 4242 4242 4242` and `4111 1111 1111 1111` are **declined**. Use expiry `03/30` and CVC `100` (`1000` for American Express). If the dashboard shows different test cards for your sandbox, use those. | Card | Result | |---|---| | `4111 1111 1110 1203` (Visa) / `5200 0000 0000 1203` (Mastercard) | Verified with no challenge: approves | | `4111 1111 1118 1072` (Visa) / `5240 0000 0000 1072` (Mastercard) | Challenge: the test page lets you pass or fail it | A card payment can pause while the buyer completes the bank check, so always send a return URL. See [3-D Secure](../embedded-fields/3ds.md). ## Testing a decline {#processor-test-account} With one of the cards above, the processor's test environment decides a decline 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 this one: | Order total | `amount` | Result | `decline_code` | [`action`](payment-intent-object.md#decline-action) | |---|---|---|---|---| | 2,000.12 | `200012` | Declined by the card's bank | `do_not_honor` | `hard` | `decline_code` is the field on the payment intent; the `charge.failed` webhook carries the same value as `failure_code`. Because the total decides the outcome, the same totals work on hosted checkout and on Embedded Fields. ## Rules in every test mode - **A test key never reaches a live processor account.** The credentials resolved for a test key are that processor's sandbox credentials, and there is no fallback to live ones: a missing sandbox configuration throws rather than reaching the live account. - **Mode is a property of the key, not the host.** Test and live keys are served by the same host. See [Which environment am I talking to?](../guides/sandbox.md#which-environment). - **Test and live objects never mix.** A `vp_pmt_test_*` token used with a live key is rejected with `400 payment_method_mode_mismatch`. (Not `payment_method_inactive`, which is `422` and means the token was revoked.) ## Webhooks in test mode Test mode fires the same events as live, from the same place: your processor's notification arrives and we emit. Nothing about the event set or its payloads changes with the key's mode. Every event and its payload is on [Webhook events](../integration/webhook-events.md). ## Related - [Sandbox & Test Mode](../guides/sandbox.md) — keys, environments, webhook setup for local development - [Webhook events](../integration/webhook-events.md) — every event and its payload --- ## /reference/tokens Source: https://docs.vonpay.com/reference/tokens # Tokens `POST /v1/tokens` creates a reusable payment-method token. Token IDs use the `vp_pmt_test_*` / `vp_pmt_live_*` prefix. This is the **server-side** token endpoint. For the browser/embedded flow — where the Embedded Fields SDK (`vora.js`) mints a token from card fields the buyer types — see [Tokenization](../embedded-fields/tokenization.md). **Required key:** secret key (`vp_sk_*`). ## Create a token ```bash curl https://checkout.vonpay.com/v1/tokens \ -H "Authorization: Bearer vp_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -d '{ "buyer_id": "buyer_abc", "provider_reference": "pm_token_abc123" }' ``` ```javascript const token = await client.tokens.create({ buyerId: "buyer_abc", providerReference: "pm_token_abc123", }); ``` ### Request — `CreateTokenRequest` All fields are optional, and the schema is strict (unknown fields are rejected). | Field | Type | Required | Description | |---|---|---|---| | `buyer_id` | string | — | Buyer to associate the token with (enables reuse / MIT). | | `buyer` | object | — | Nested buyer profile (snake_case): `external_id`, `email`, `name`, `phone`, `company`, `address`, `metadata`. Upserts the same buyer record as [`POST /v1/buyers`](buyers.md) and associates the vaulted token with it — `buyer.external_id` feeds the same buyer association as the flat `buyer_id`, which remains supported. Must carry an identity (`buyer.external_id` or `buyer.email`) directly or via `buyer_id`, else `400 validation_error`. Sending `buyer_id` AND `buyer.external_id` with **equal** values is fine; **different** values return `400 validation_error`. Echoed back on the response (present only when sent; `buyer.metadata` echoed post-scrub). | | `provider_reference` | string (≤ 255) | Yes | A gateway-specific reference for the payment method — depending on the underlying gateway, an iframe-vault payment-method handle or a server-side setup reference. **Required** in both modes: without it the request returns `400 validation_error`. Max 255 chars. | | `card_last4` | string | — | Accepted for compatibility. Ignored when the payment provider supplies the saved card's last four digits, which is the usual case. | | `card_brand` | string: `visa` \| `mastercard` \| `amex` \| `discover` \| `unionpay` \| `jcb` \| `diners` \| `unknown` | — | Accepted for compatibility. Ignored when the payment provider supplies the saved card's network. | | `exp_month` | integer | — | 1–12. Accepted for compatibility. Ignored when the payment provider supplies the saved card's expiry. | | `exp_year` | integer | — | Four-digit year (≥ 2025). Accepted for compatibility. Ignored when the payment provider supplies the saved card's expiry. | | `metadata` | object | — | Record with string keys and arbitrary JSON values (size-capped, scrubbed). | | `setup_for_future_use` | string: `off_session` \| `on_session` | — | Permission to **charge** the card again — `off_session` or `on_session`. Omitted, the token is single-use and a merchant-initiated charge against it is refused with [`payment_method_consent_missing`](error-codes.md#payment_method_consent_missing). Recorded once at vault time; nothing adds it later. | | `allow_redisplay` | string: `always` \| `limited` \| `unspecified` | — | Permission to **show** the card back to the buyer in a later checkout — `always`, `limited`, or `unspecified`. A **different** permission from `setup_for_future_use` above: agreeing to a subscription is not agreeing to see that card offered on an unrelated purchase months later. Only `always` permits display. **Send `unspecified` when you did not ask** — that is a real answer, and omitting the field is not. Like consent, it can only be captured on the call that first vaults the card and can never be added afterwards. Echoed back on the response when sent. | | `billing_address` | object | — | Buyer billing address, stored encrypted with the saved card and never returned; correct it later with [`PATCH /v1/tokens/{id}`](#update-billing-address). No verification result is produced at vault time, because no payment is made. A later `POST /v1/payment_intents` charge on this card that sends no `billing_address` uses the stored one, on payment connections that support address verification; an address sent on the charge takes priority. One payment connection cannot take an address on the charge at all and refuses the whole charge with `422 capability_not_supported` when `billing_address` is present; see [Address verification](../guides/address-verification.md). See [Address verification](../guides/address-verification.md). | | `stored_credential_use` | string: `recurring` \| `installment` \| `unscheduled` | — | Declares what the stored card is for — `recurring`, `installment`, or `unscheduled`. The server-to-server counterpart of `storedCredentialUse` on [`POST /v1/sessions`](../integration/create-session.md) — **note the different casing on each surface**. Use the same value you will later send as `mit.reason`. Reaching the card networks additionally requires `currency` and `buyer_id` on this request plus account-level enablement; when it does not, the token is still vaulted and the response carries a `warnings` entry rather than an error. See [Recurring & saved cards](../guides/recurring-and-saved-cards.md). | | `currency` | string (exactly 3) | — | Three-character currency for the zero-amount card verification. Vaulting a card creates no payment, so on its own there is nothing to carry a `stored_credential_use` declaration to the card networks; supplying a currency lets the vault run as a zero-amount verification that does. Deliberately un-defaulted — there is no correct fallback currency. Omit it and the card is vaulted with no verification. | **Test and live keys behave the same:** both require a `provider_reference` (the payment method must already exist at the gateway, or in your sandbox account's processor test environment). Without it, the request returns `400 validation_error`. A test key with no test environment behind it is refused with [`422 sandbox_account_required`](error-codes.md#sandbox_account_required). ### Response — `PaymentMethodToken` ```json { "id": "vp_pmt_test_QAqnXEJF_TCum1jg", "status": "active", "card": { "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2030 } } ``` | Field | Type | Description | |---|---|---| | `id` | string | Token ID (`vp_pmt_test_*` / `vp_pmt_live_*`). | | `status` | string: `active` \| `revoked` | Token lifecycle state. | | `setup_for_future_use` | string: `off_session` \| `on_session` | Echoes the reuse permission you sent on the request, so you can confirm it was recorded against the token. Omitted — not `null` — when you did not send one. | | `card.brand` | string \| null | Card network (e.g. `visa`), read from the payment provider's record of the saved card. `null` if it could not be read; the card is still saved. | | `card.last4` | string \| null | Last four digits, read from the payment provider. `null` if they could not be read. | | `card.exp_month` | integer \| null | 1–12, read from the payment provider. `null` if it could not be read. | | `card.exp_year` | integer \| null | Four-digit year, read from the payment provider. `null` if it could not be read. | | `idempotent` | boolean | `true` when this response is a replay of an earlier request that used the same `Idempotency-Key` — no new token was vaulted, you are being handed the existing one. It only appears when you sent that header; without one, a retry vaults a second token. See [Idempotency](api.md#idempotency). | | `allow_redisplay` | string: `always` \| `limited` \| `unspecified` | Echoes the display permission you sent. Present only when you sent it. | | `buyer` | object | Echoes the nested buyer you sent, confirming it was accepted. `buyer.metadata` comes back **after** privacy scrubbing, so it reflects what was stored rather than exactly what you sent. Present only when you sent one. | | `warnings` | array of object | Present only when something about the request did not take effect. **A warning never means the vault failed** — the token in the same response is real and usable. Each entry carries `code`, `message`, and `field`. | #### Warning codes | Code | Meaning | |---|---| | `stored_credential_use_not_applied` | The card was saved, but your `stored_credential_use` declaration did not reach the card networks, so no stored-credential declaration was made for this card — a later merchant-initiated charge on this token will not be anchored to one. Reaching the networks requires `currency` and `buyer_id` on the request plus account-level enablement; contact support to confirm enablement before relying on the declaration. | ## List a buyer's saved cards {#list-a-buyers-saved-cards} ``` GET /v1/payment_methods?buyer_id= ``` Returns the cards you have on file for one of your customers, newest first. **Secret key only** — these are handles that can be charged, so a publishable key is refused. `buyer_id` is **your own customer reference** — the same value you send as `buyerId` when creating a session and as `buyer_id` when creating a payment intent. You do not need to resolve an id of ours first. :::tip Use this when you no longer hold a saved-card reference A card saved during a payment that finished **asynchronously** — anything that went through 3-D Secure, or a wallet payment recovered after a delay — is vaulted *after* your original call returned, so its token was never in that response. This is how you find it afterwards. ::: | Parameter | | | |---|---|---| | `buyer_id` | **required** | Your own customer reference (max 200 chars). | | `limit` | optional | 1–100, default 25. Values above 100 are capped. | | `starting_after` | optional | The `next_cursor` from a previous response. A malformed value is rejected rather than silently returning the first page again. | The response is `{ data, next_cursor, has_more }`, newest first. `next_cursor` is `null` on the last page. Each entry carries: | Field | Notes | |---|---| | `id` | `vp_pmt_*` — the only handle returned (never a provider-side one); pass back as `payment_method.id` when charging. | | `status` | Always `active`. **A revoked card is omitted, not listed as revoked.** | | `card` | `brand`, `last4`, `exp_month`, `exp_year` — display identity only. Each is `null` if the payment provider's details could not be read when the card was saved. | | `wallet_type` | Set when the card was saved through a wallet. | | `setup_for_future_use` | Whether you may **charge** it again. | | `allow_redisplay` | Whether it may be **shown back** to the buyer. | Both permissions are returned as data, not applied as a filter — a card without `setup_for_future_use: "off_session"` is listed and a merchant-initiated charge against it is refused ([the two permissions](../embedded-fields/charge-at-submit.md#consent-permissions)). A publishable key cannot list saved cards: read them on your server and send your frontend only what it needs to render. ## Update a saved card's billing address {#update-billing-address} ``` PATCH /v1/tokens/{id} ``` Replaces the billing address stored with a saved card, for a customer who has moved or a typo made when the card was saved. **Secret key only.** Each saved card holds its own address, so updating one never changes another card belonging to the same customer. ```json { "billing_address": { "address_line1": "1600 Pennsylvania Ave NW", "city": "Washington", "state": "DC", "postal_code": "20500", "country": "US" } } ``` - **`billing_address` is the only field you can send.** Card details come from the processor, and the two reuse permissions are recorded once when the card is saved, so any other field returns `400 validation_unknown_field`. - **Send `"billing_address": null` to clear the stored address.** Leaving the field out returns `400 validation_missing_field`: a request that changes nothing is treated as a mistake. - **The address is never returned**, by this endpoint or any other. The response reports `has_billing_address`, read back from what was stored. - **How the stored address is used.** A later `POST /v1/payment_intents` charge on this card that sends no `billing_address` uses the stored one, on payment connections that support address verification (the charge response then carries an `avs_result_code`). An address sent on the charge takes priority, so if you already send one on every charge nothing changes. One payment connection cannot take an address on the charge at all and refuses the whole charge with `422 capability_not_supported` when `billing_address` is present; see [Address verification](../guides/address-verification.md). Charges started from your checkout page use the address given on that page. - **A card saved without an address picks one up** from its first approved `POST /v1/payment_intents` charge that sends a `billing_address`, unless that charge's address check came back as no match. On a card saved for a buyer, the charge must name that same buyer. Use this endpoint to correct or clear it. - **Keep it current.** The stored address is checked by the card's bank on later charges, and an out-of-date one fails the check, which some banks treat as a reason to decline. If you know it is wrong but not yet the new one, clear it with `null`: a card with no stored address simply gets no address check. ```json { "id": "vp_pmt_live_3xK9vQ2mNp7wRtY4", "status": "active", "card": { "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2030 }, "has_billing_address": true } ``` In the server SDKs (2.6.0 or later) setting and clearing are separate methods, so an undefined variable can never clear an address by accident: ```typescript await vonpay.tokens.updateBillingAddress("vp_pmt_live_3xK9vQ2mNp7wRtY4", { addressLine1: "1600 Pennsylvania Ave NW", city: "Washington", state: "DC", postalCode: "20500", country: "US", }); // → { id, status, card, hasBillingAddress: true } await vonpay.tokens.clearBillingAddress("vp_pmt_live_3xK9vQ2mNp7wRtY4"); ``` The SDKs check the address before sending: a missing or blank `addressLine1`, `postalCode` or `country` throws (`TypeError` in Node, `ValueError` in Python) and nothing is sent. Both SDKs also refuse unknown keys, so a typo such as `address1` fails instead of being dropped. To remove an address on purpose, use `clearBillingAddress()`. ```python vonpay.tokens.update_billing_address("vp_pmt_live_3xK9vQ2mNp7wRtY4", { "address_line1": "1600 Pennsylvania Ave NW", "city": "Washington", "state": "DC", "postal_code": "20500", "country": "US", }) vonpay.tokens.clear_billing_address("vp_pmt_live_3xK9vQ2mNp7wRtY4") ``` | Status | Code | Cause | |--------|------|-------| | `400` | [`validation_unknown_field`](error-codes.md#validation_unknown_field) | A field other than `billing_address` was sent. | | `400` | [`validation_missing_field`](error-codes.md#validation_missing_field) | `billing_address` was left out. | | `400` | `payment_method_mode_mismatch` | A test card with a live key, or the reverse. | | `403` | [`auth_key_type_forbidden`](error-codes.md#auth_key_type_forbidden) | A publishable key was used. | | `404` | [`payment_method_not_found`](error-codes.md#payment_method_not_found) | No such saved card on your account. | | `413` | [`validation_error`](error-codes.md#validation_error) | The body is larger than 1 KiB. | | `415` | [`unsupported_media_type`](error-codes.md#unsupported_media_type) | The request did not declare `Content-Type: application/json`. | | `422` | [`payment_method_inactive`](../embedded-fields/tokenization.md#common-token-related-errors) | The card is revoked. A revoked card is not a missing one, so this is not a `404`. | ## Revoke a stored payment method {#revoke} ``` DELETE /v1/tokens/{id} ``` Revokes a stored card so it can never be charged again. **Secret key only.** Once revoked, a later charge against that token fails with [`payment_method_inactive`](../embedded-fields/tokenization.md#common-token-related-errors), and the card stops appearing in [the saved-cards list](#list-a-buyers-saved-cards). ⚠️ **Revoking is final.** There is no un-revoke call — the buyer has to present the card again, and a card re-saved that way is a new token with new permissions. ## Status | Status | Meaning | |--------|---------| | `active` | The token can be used to create a payment intent. | | `revoked` | The token has been revoked and can no longer be used. | A token does not carry a separate `expired` status. Card expiry (`exp_month` / `exp_year`) is stored on the token's `card` object, but the token itself stays `active` until it is revoked. ## Errors | Status | Code | Cause | |--------|------|-------| | `400` | [`validation_error`](error-codes.md#validation_error) | The request had no `provider_reference` (or another invalid request field). | | `501` | [`endpoint_not_implemented`](error-codes.md#endpoint_not_implemented) | The merchant's gateway does not expose tokenization through this endpoint — some gateways accept only browser-minted handles and bypass `/v1/tokens` entirely. | ## Using a token Pass a token's `id` as `payment_method.id` when creating a payment intent (`{ "payment_method": { "id": "vp_pmt_..._" } }`). See [Payment Intents](../integration/payment-intents.md). ## Related - [Tokenization](../embedded-fields/tokenization.md) — minting tokens browser-side with the Embedded Fields SDK - [Payment Intents](../integration/payment-intents.md) — charging a token - [Test mode](test-cards.md) — what a test key reaches; the cross-mode rule --- ## /reference/versioning Source: https://docs.vonpay.com/reference/versioning # API Versioning The API is versioned by date. ## Version Header Set the `Von-Pay-Version` request header to pin your integration to a specific API version: ``` Von-Pay-Version: 2026-04-14 ``` The current (and only) supported version is `2026-04-14`. The API validates the header against an allowlist of supported versions, so an unrecognized value is treated the same as no header at all (see below). ## Default Behavior If the `Von-Pay-Version` header is omitted — or set to a value that isn't a supported version — the API falls back to the **current version**, `2026-04-14`. This is a single, global default, not a per-account or per-key setting; there is no account-specific "version current when your account was created." Every response echoes two headers so you can confirm what was applied: - `Von-Pay-Version` — the version the request was processed against. This is the value you sent **only if** it's a supported version; otherwise it's the current version. - `Von-Pay-Latest-Version` — always the latest available version, so you can detect when a newer version exists. ## SDK Pinning The server SDKs that talk to the REST API — Node.js and Python — accept an `apiVersion` option that sets the `Von-Pay-Version` header automatically on every request. **Node.js:** ```typescript const vonpay = new VonPayCheckout({ apiKey: "vp_sk_live_xxx", apiVersion: "2026-04-14", }); ``` **Python:** ```python client = VonPayCheckout("vp_sk_test_...", api_version="2026-04-14") ``` The browser SDK (`vora.js`) and the hosted SDK (`vora-hosted.js`) do not send a version header — browser-side behavior tracks the current version automatically — and the CLI and MCP server inherit whatever the underlying Node SDK is configured with. Version pinning is therefore a server-side concern: set it where you make API calls. ## Compatibility Policy **Non-breaking changes** do not require a version bump. These include: - Adding new optional request parameters - Adding new fields to response objects - Adding new event types - Adding new error codes **Breaking changes** result in a new dated version. These include: - Removing or renaming fields - Changing field types - Changing default behavior - Removing endpoints ## Deprecation Policy ### How deprecation is signalled A request that uses a superseded field shape is processed normally and answered with its usual success status plus [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) deprecation headers, so you can find the legacy usage in production. The one surface that emits them is `POST /v1/sessions` when a request passes the stringified `metadata.mirror` value instead of the top-level `mirror` field — it still succeeds with `201 Created`, the string is [never parsed](../connected-platforms/shopify.md#legacy) so no order is mirrored, and the response carries: ``` Deprecation: true Sunset: Wed, 27 Aug 2026 00:00:00 GMT Link: ; rel="deprecation"; type="text/html" ``` - `Deprecation` — the literal value `true` (an RFC 8594 boolean signal that the request used a deprecated shape). It is **not** an HTTP-date. - `Sunset` — the earliest HTTP-date at which the legacy shape may stop being accepted. The `metadata.mirror` date has passed and the shape is still accepted. - `Link` — a documentation URL describing the replacement, with `rel="deprecation"`. A deprecated shape is not met with `410 Gone`, and no blanket `Deprecation` header appears on other endpoints. A breaking change ships in a new dated version and is signalled the same way, on the affected request shape, before the prior version is retired — pin `apiVersion` so a future default does not change your integration's behaviour until you bump it. --- ## SDK telemetry Source: https://docs.vonpay.com/sdk-telemetry # SDK telemetry The Node and Python server SDKs can report their own failures back to Von Payments so SDK bugs surface faster. It is **on by default with a test key** (`vp_sk_test_*`) and **off with a live key**; an explicit setting always wins: ```typescript const vonpay = new VonPayCheckout({ apiKey, telemetry: { enabled: false } }); ``` ```python client = VonPayCheckout(api_key, telemetry={"enabled": False}) ``` When it is on, the SDK prints one console notice at construction, linking here. ## What is sent One small JSON event when a call in a fixed set of operations fails — session create/get, payment intent create/capture/void, refund create, token create, capabilities, and webhook signature verification — on a non-retryable response, a retry that ran out or a network error. Buyer-profile, webhook-subscription and webhook-event calls never produce a telemetry event (your `errorReporter` still fires for them). It goes to `POST /v1/sdk-telemetry` with your secret key: | Field | Value | |---|---| | `sdk_name`, `sdk_version`, `runtime` | `checkout-node` / `checkout-python`, the SDK version, the runtime (e.g. `node-22.4.0`) | | `operation` | Which SDK method failed, from a closed list | | `error_code` | The API error code, or a generic class for a non-API failure | | `request_id_hash` | The `X-Request-Id` of the failed call as a SHA-256 hash — never the id itself | | `occurred_at` | Timestamp | | `context.retry_count`, `context.http_status` | When known | Nothing else: no request or response bodies, no amounts, no buyer data, no headers. Before sending, the SDK scans every string in the event for the shape of an API key, a session secret, a webhook secret or an email address and **drops the whole event** on a match; an event over 2 KB is not sent. Each POST is fire-and-forget with a 5-second timeout, never throws into your code, and pauses for 60 seconds after a `429`. Your own `errorReporter` callback ([Node](sdks/node-sdk.md#error-reporting), [Python](sdks/python-sdk.md)) is independent of this channel and runs whether or not telemetry is on. ## Serverless runtimes {#serverless} On Vercel or AWS Lambda the SDK warns at construction: the POST is fire-and-forget, so a function that freezes immediately after responding can drop it. Nothing about your payment calls is affected — only the telemetry event may be lost. If you want every event delivered from a serverless runtime, disable telemetry and use `errorReporter` with your own observability pipeline. ## Behind a firewall {#firewall} The event goes to `https://checkout.vonpay.com/v1/sdk-telemetry` — the SDK's default API host, regardless of any custom `baseUrl` you configured for your own calls. If the SDK warns about a telemetry network error or timeout while your API calls succeed, an egress rule is blocking that path; allow `checkout.vonpay.com:443`, or disable telemetry. The warning is printed once per SDK client (a serverless function that constructs a client per request sees it repeat) and the SDK keeps working either way. --- ## /sdks/cli Source: https://docs.vonpay.com/sdks/cli # CLI Command-line interface for Von Payments, powered by the `@vonpay/checkout-cli` package. The `vonpay` command is the umbrella CLI. `checkout` is a product subcommand — all checkout-related commands live under `vonpay checkout`. ## Install ```bash npm install -g @vonpay/checkout-cli ``` ## Authentication The CLI resolves your API key in this order: 1. **Environment variable** `VON_PAY_SECRET_KEY` (takes precedence) 2. **Stored key** saved by `vonpay checkout login` (persisted to `~/.vonpay/config.json`) ## Commands ### `vonpay checkout login` Interactively store your API key. The CLI prompts for your secret key and saves it to `~/.vonpay/config.json`. ```bash vonpay checkout login # ? Enter your Von Payments secret key: vp_sk_test_... # Key saved to ~/.vonpay/config.json ``` ### `vonpay checkout logout` Remove the stored API key from `~/.vonpay/config.json`. ```bash vonpay checkout logout # ✓ Stored API key removed from ~/.vonpay/config.json ``` If `VON_PAY_SECRET_KEY` is still set in your environment, the CLI warns you: the env var takes precedence over the stored config, so you remain authenticated in that shell until you `unset VON_PAY_SECRET_KEY`. ### `vonpay checkout init` Write a `.env` file in the current directory using the stored API key. ```bash vonpay checkout init # Created .env with VON_PAY_SECRET_KEY ``` ### `vonpay checkout sessions create` Create a checkout session from the command line. ```bash vonpay checkout sessions create --amount 1499 --currency USD ``` | Flag | Required | Description | |------|----------|-------------| | `--amount` | Yes | Amount in smallest currency unit (e.g. 1499 = $14.99) | | `--currency` | Yes | Three-letter currency code | | `--country` | No | Two-letter country code (e.g. `US`) | | `--description` | No | Session description | | `--success-url` | No | Redirect URL on success | | `--cancel-url` | No | Redirect URL on cancel | | `--idempotency-key` | No | Idempotency key for safe retries | | `--dry-run` | No | Validate without creating a session | | `--json` | No | Output raw JSON response | ```bash # Dry-run validation vonpay checkout sessions create --amount 1499 --currency USD --dry-run # JSON output for scripting vonpay checkout sessions create --amount 1499 --currency USD --country US --json ``` ### `vonpay checkout sessions get` Retrieve a session by ID. ```bash vonpay checkout sessions get vp_cs_test_abc123 ``` | Flag | Description | |------|-------------| | `--json` | Output raw JSON response | ### `vonpay checkout payment-intents create` Create a payment intent directly from the command line (the discrete auth/capture lifecycle, separate from sessions). ```bash vonpay checkout payment-intents create --amount 1499 --currency USD ``` | Flag | Required | Description | |------|----------|-------------| | `--amount` | Yes | Amount in minor units (e.g. 1499 = $14.99) | | `--currency` | Yes | ISO 4217 currency code (e.g. `USD`) | | `--capture-method` | No | `automatic` (default) or `manual` (parks at `authorized`) | | `--metadata` | No | Metadata `key=value` (repeat the flag for multiple pairs) | | `--mit-initiator` | No | MIT initiator (`merchant` or `customer`) | | `--mit-reason` | No | MIT reason (`recurring`, `unscheduled`, or `installment`) | | `--mit-original-transaction-id` | No | Cardholder-initiated anchor payment intent ID (`vpi_*`) | | `--payment-method` | No | Stored card to charge (`vp_pmt_*`). Required for saved-card and recurring charges | | `--session-id` | No | Checkout session this charge belongs to (`vp_cs_*`). Refused if that session already charged | | `--buyer-id` | No | Your customer reference. Send it on every saved-card charge | | `--buyer-email` | No | Buyer email for this charge | | `--idempotency-key` | No | Key for this charge. Generated and printed when omitted | | `--confirm-live` | No | Required to proceed when the loaded key is `vp_sk_live_*` | | `--json` | No | Output raw JSON | The three `--mit-*` flags are all-or-nothing: supply all three to attach a merchant-initiated-transaction block, or none. Every charge carries an idempotency key. Without `--idempotency-key`, the command generates one and prints it to stderr before sending, so `--json` output is unchanged. To retry the **same** charge, re-run with `--idempotency-key `. Re-running without it sends a new key, which is a new charge. ### `vonpay checkout payment-intents capture` Capture an authorized payment intent. Pass the payment intent ID (`vpi_*`) as the argument. ```bash vonpay checkout payment-intents capture vpi_test_abc123 ``` | Flag | Description | |------|-------------| | `--amount-to-capture` | Partial capture amount in minor units (omit for a full capture) | | `--idempotency-key` | Idempotency key for safe retries | | `--confirm-live` | Required to proceed when the loaded key is `vp_sk_live_*` | | `--json` | Output raw JSON | ### `vonpay checkout payment-intents void` Void an authorized (uncaptured) payment intent. Pass the payment intent ID (`vpi_*`) as the argument. ```bash vonpay checkout payment-intents void vpi_test_abc123 ``` | Flag | Description | |------|-------------| | `--idempotency-key` | Idempotency key for safe retries | | `--confirm-live` | Required to proceed when the loaded key is `vp_sk_live_*` | | `--json` | Output raw JSON | ### `vonpay checkout refunds create` Create a refund against a captured payment intent, or against a transaction for a payment that has no payment intent. ```bash vonpay checkout refunds create --payment-intent vpi_test_abc123 vonpay checkout refunds create --transaction vp_tx_test_abc123 ``` | Flag | Required | Description | |------|----------|-------------| | `--payment-intent` | One of the two | Payment intent ID to refund (`vpi_*`) | | `--transaction` | One of the two | Transaction ID to refund (`vp_tx_*`): for a payment with no payment intent, or the transaction a [`refund_target_is_duplicate`](../reference/error-codes.md#refund_target_is_duplicate) error names | | `--amount` | No | Refund amount in minor units (omit for the full remaining balance) | | `--currency` | No | ISO 4217 currency code | | `--reason` | No | Free-form reason (mirrored back on the record) | | `--metadata` | No | Metadata `key=value` (repeat the flag for multiple pairs) | | `--idempotency-key` | No | Idempotency key for safe retries | | `--confirm-live` | No | Required to proceed when the loaded key is `vp_sk_live_*` | | `--json` | No | Output raw JSON | Pass exactly one of `--payment-intent` and `--transaction`; both, or neither, is refused before anything is sent. Both ids are visible to the shopper's browser, so refund only an id you looked up in your own order records. ### `vonpay checkout tokens create` Create a payment-method token for saved-card / MIT flows. ```bash vonpay checkout tokens create --buyer-id buyer_abc123 ``` | Flag | Description | |------|-------------| | `--buyer-id` | Buyer ID to attach the token to | | `--provider-reference` | Provider tokenization handle (required) | | `--metadata` | Metadata `key=value` (repeat the flag for multiple pairs) | | `--idempotency-key` | Idempotency key for safe retries | | `--json` | Output raw JSON | The server requires `--provider-reference` with a test key and with a live key: without it the request returns `400 validation_error`. ### `vonpay checkout capabilities` Show what this merchant account supports — auth/capture separation, partial capture, partial refund, void-after-capture policy, MIT, network tokens, 3-D Secure 2, ACH, payouts, settlement currencies, and rate limits. ```bash vonpay checkout capabilities # JSON output vonpay checkout capabilities --json ``` | Flag | Description | |------|-------------| | `--json` | Output raw JSON response | ### `vonpay checkout trigger` Send a signed test webhook event to a URL — including `localhost` — to verify your webhook handler during development. The CLI signs the payload with the **same algorithm and header format** as the live delivery engine: a single `x-vonpay-signature` header of the form `t=,v1=`, where `v1` is an HMAC-SHA256 over `${t}.${body}`. One difference matters: for this local trigger the signing **secret is your API key**, whereas production webhooks are signed with your endpoint's `whsec_*` secret. So a passing test confirms your signature-verification scheme is wired correctly — verify against your API key for the local trigger, and against the endpoint's `whsec_*` secret in production. See [Webhooks → Test your handler](../integration/webhooks.md#test-your-handler) for the full walkthrough. ```bash vonpay checkout trigger charge.succeeded --url http://localhost:3000/webhooks/vonpay ``` Supported events: every `charge.*` and `payment_intent.*` event, `refund.failed`, `mirror.order.created`, `session.succeeded` and `session.failed`; any other name is rejected. `session.succeeded` and `session.failed` are delivered to a subscription that selects them, but carry no saved card or `vp_tx_id`, so fulfil on `charge.succeeded`. See the full [webhook event catalog](../integration/webhook-events.md) for event payload shapes. | Flag | Required | Description | |------|----------|-------------| | `--url` | Yes | The endpoint to deliver the test event to. Must be this machine (`localhost`, `127.0.0.1` or another loopback address) unless you pass `--url-tunnel` | | `--url-tunnel` | No | Allow a non-local `--url`, such as an ngrok or Cloudflare Tunnel address | | `--session-id` | No | Use a specific session ID instead of a generated one | | `--amount` | No | Amount in minor units (defaults to `1499`) | | `--currency` | No | Currency code (defaults to `USD`) | | `--confirm-live` | No | Required when the loaded key is `vp_sk_live_*`: the event is signed with that live key | ### `vonpay checkout doctor` Collect a diagnostic bundle: your environment, a live health probe, and — when a key is available — a round-trip against the API. Use it when something is failing and you cannot tell whether the problem is your setup, your key, or the service. ```bash vonpay checkout doctor # Machine-parseable, for your own tooling vonpay checkout doctor --json # Structured for an AI assistant to read and act on vonpay checkout doctor --for-llm ``` | Flag | Description | |------|-------------| | `--json` | Emit the bundle as JSON instead of the readable table | | `--for-llm` | Emit structured markdown an assistant can act on without further prompting | **It is safe to paste anywhere.** Keys are never printed — only the prefix, enough to tell test from live. Environment variables are reported by **name only**; no value is ever included. That is deliberate, so the output can go straight into a public support thread or to an AI assistant without a redaction pass first. ### `vonpay checkout listen` Watch your webhook delivery attempts in real time — a live tap on the delivery stream for the authenticated merchant. Optionally re-forward each event to a local URL so you can drive your handler during development. This command requires a **secret key** (`vp_sk_*`); publishable keys are rejected by the server. ```bash vonpay checkout listen # Stream and re-forward each event to a local handler vonpay checkout listen --forward-to http://localhost:3000/webhooks/vonpay ``` | Flag | Description | |------|-------------| | `--forward-to` | Local URL to receive a shadow copy of each delivered event (loopback only by default) | | `--forward-to-tunnel` | Allow non-loopback `--forward-to` URLs (for ngrok / Cloudflare Tunnel workflows) | | `--events` | Only stream attempts for the given event type. Repeat the flag for multiple types | | `--json` | Emit one JSON object per line on stdout instead of the colored transcript | | `--api-key` | Override the API key resolved from env / config | | `--confirm-live` | Acknowledge that the stream may surface production webhook attempts (required for `vp_sk_live_*` keys) | With a live key (`vp_sk_live_*`), `listen` refuses to start unless you pass `--confirm-live`, since production webhook attempts would stream to your terminal. The stream is served by an operator-controlled endpoint. When that endpoint is disabled, the server responds with `503 Service Unavailable` and a `Retry-After` header — re-run after a short wait. ### `vonpay checkout health` Check the API health status. ```bash vonpay checkout health # JSON output vonpay checkout health --json ``` --- ## /sdks Source: https://docs.vonpay.com/sdks # SDKs & Tools Von Payments ships client libraries and developer tools for the environments integrators most commonly work in. The server SDKs (Node + Python) provide full discrete-lifecycle coverage: `paymentIntents.create` / `capture` / `void`, `refunds.create`, `tokens.create`, and `capabilities.get`. Server SDKs ship with the full `ErrorCode` catalog, programmatic typed error helpers (`retryable` / `nextAction` / `llmHint` on every error), opt-in `errorReporter` callback for piping into your APM or error-tracking platform. The CLI ships `vonpay checkout doctor` for one-command diagnostic bundles; the MCP server adds a `vonpay_checkout_diagnose_error` tool for AI agents. Current package versions live in the [versions table](#versions) below. ## Server-side SDKs | SDK | Install | Reference | |---|---|---| | **Node / TypeScript** | `npm install @vonpay/checkout-node` | [Node SDK](./node-sdk) | | **Python** | `pip install vonpay-checkout` | [Python SDK](./python-sdk) | Both SDKs expose `sessions.create` / `sessions.get` / `sessions.validate`, the `buyers` resource for stored shopper profiles, webhook signature verification, typed `VonPayError` with the full `ErrorCode` union, and exponential-backoff retries on 429/5xx. ## Browser SDKs Two browser scripts, two different products. Pick by buyer experience. | SDK | Load | Buyer experience | Reference | |---|---|---|---| | **vora-hosted.js** | `` | **Redirects** to hosted checkout | [vora-hosted.js](./vora-hosted) | | **vora.js** (Embedded Fields) | `` | **Embedded** — buyer stays on your domain | [Embedded Fields quickstart](../embedded-fields/quickstart.md) | Both are publishable-key-scoped and reject secret keys at runtime. The names are easy to mix up — if you want the buyer to stay on your domain, you want `vora.js`, not `vora-hosted.js`. Neither is on npm today (the workspace package `@vonpay/vora-js` is internal-only); use the CDN URLs above. **Don't add `@stripe/stripe-js` or any other processor SDK.** Vonpay picks the processor server-side via VORA; your integration code loads `vora-hosted.js` (redirect) or `vora.js` (embedded) and never a processor SDK directly. See the [Embedded Fields quickstart](../embedded-fields/quickstart.md#step-1--install-the-browser-sdk) for details. ## Language-neutral | Surface | Entry point | Reference | |---|---|---| | **REST API** | `https://checkout.vonpay.com/v1/sessions` | [REST API](./rest-api) | For languages or runtimes without a first-party SDK, the REST API is the canonical contract. Covered end-to-end by the [OpenAPI spec](https://checkout.vonpay.com/openapi.yaml). ## Developer tooling | Tool | Install | Reference | |---|---|---| | **CLI** | `npm install -g @vonpay/checkout-cli` | [CLI](./cli) | | **MCP server** | `npx -y @vonpay/checkout-mcp` | [MCP server](./mcp) | The CLI (`vonpay checkout login`, `vonpay checkout sessions create`, `vonpay checkout trigger`, etc.) covers local-development and scripting use-cases. The MCP server exposes the same surface to AI agents via the [Model Context Protocol](https://modelcontextprotocol.io) — see [AI Agents](../agents/overview.md) for config. ## Versions **Read the current version from the registry, not from this page.** A version transcribed into documentation is stale the moment the next release ships, and nothing fails when it goes wrong — so this table tells you *where to look* rather than repeating a number that will drift. | Package | Where the current version lives | |---|---| | `@vonpay/checkout-node` | [npm](https://www.npmjs.com/package/@vonpay/checkout-node) | | `vonpay-checkout` (PyPI) | [PyPI](https://pypi.org/project/vonpay-checkout/) | | `@vonpay/vora-js` (CDN only) | [`integrity.json`](https://js.vonpay.com/integrity.json) → `channels.v1.current` | | `@vonpay/checkout-cli` | [npm](https://www.npmjs.com/package/@vonpay/checkout-cli) | | `@vonpay/checkout-mcp` | [npm](https://www.npmjs.com/package/@vonpay/checkout-mcp) | **Pinning.** Pin the **major** you tested against, and take minor and patch releases freely — they are additive under semver. A major bump means something breaking changed, and it ships with an upgrade section on that SDK's page ([Node](./node-sdk.md#v2-migration), [Python](./python-sdk.md#v2-migration)). Read which major is current from the registry links above, not from this paragraph. Pin an exact version only when you need byte-reproducible builds. Below `1.0.0` those rules do not hold — a minor release is allowed to break you. The CLI and the MCP server move on their own cadence and have been pre-1.0 for most of their life, so check their registry entries above and pin an **exact** version while either is still below 1.0.0. `vora.js` is distributed by CDN and has its own rules: see [Script-tag integration (SRI)](../embedded-fields/sri.md). ### Sessions need 0.15.0 or later {#sdk-session-minimum} :::warning Below 0.15.0 the session call fails and no session is created Earlier SDKs send `collectShipping`, which `POST /v1/sessions` now refuses. Any integration that references the field at all is affected. ::: Earlier server SDK versions still send `collectShipping`, which `POST /v1/sessions` now **refuses** — so the call fails with `400` [`validation_unknown_field`](../reference/error-codes.md#validation_unknown_field) and no session is created. This bites wider than it looks: both SDKs send `collectShipping: false` rather than omitting it, so the ordinary be-explicit habit fails exactly like `true`. **Any integration that references the field at all is affected.** 0.15.0 translates it instead of forwarding it — `true` becomes `shipping: "auto"`, `false` is dropped, an explicit `shipping` wins over the translated boolean (a client-side rule — the server then applies its own, see the link below), and you get one warning per process. It also adds the `shipping` and `phone` modes, so there is an SDK route to the current behaviour. See [Asking the buyer for more than a card](../integration/create-session.md#buyer-fields). ## Support matrix - **Node:** ≥ 20. An ES module; from 3.3.0, CommonJS code can `require()` it on Node 20.19+ or 22.12+ ([details](node-sdk.md#install)) - **Python:** ≥ 3.9 (httpx 0.27+) ## Where the packages come from The server and tooling packages are published to their language registries — [`@vonpay/checkout-node`](https://www.npmjs.com/package/@vonpay/checkout-node), [`@vonpay/checkout-cli`](https://www.npmjs.com/package/@vonpay/checkout-cli) and [`@vonpay/checkout-mcp`](https://www.npmjs.com/package/@vonpay/checkout-mcp) on npm, [`vonpay-checkout`](https://pypi.org/project/vonpay-checkout/) on PyPI. The browser SDKs are CDN-distributed rather than published to npm: load them from `https://js.vonpay.com/v1/`. Runnable integrations for Express, Flask and Next.js are at [github.com/Von-Payments/vonpay-samples](https://github.com/Von-Payments/vonpay-samples). --- ## /sdks/mcp Source: https://docs.vonpay.com/sdks/mcp # MCP Server The `@vonpay/checkout-mcp` package lets AI assistants interact with the Von Payments API using the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/). MCP is an open standard that gives AI assistants the ability to call external tools. Instead of writing API calls by hand, an AI agent can create checkout sessions, check payment status, and preview test payment outcomes through natural language. ## Setup ### Claude Desktop Add the following to your Claude Desktop MCP config (`claude_desktop_config.json`): ```json { "mcpServers": { "vonpay": { "command": "npx", "args": ["-y", "@vonpay/checkout-mcp"], "env": { "VON_PAY_SECRET_KEY": "vp_sk_test_..." } } } } ``` ### Cursor Add the same configuration to your Cursor MCP settings (`.cursor/mcp.json`): ```json { "mcpServers": { "vonpay": { "command": "npx", "args": ["-y", "@vonpay/checkout-mcp"], "env": { "VON_PAY_SECRET_KEY": "vp_sk_test_..." } } } } ``` ## Available Tools :::danger Five of these tools move money: on a live key a person approves each call in your MCP host `create_payment_intent`, `capture_payment_intent`, `void_payment_intent`, `create_refund` and `create_token` (with a reusable scope) act on real money or mint a chargeable credential. With a live key (`vp_sk_live_*`) each call needs `confirmLive: true` **and** the person's approval in the MCP host's own confirmation prompt. Sandbox keys (`vp_sk_test_*`) are never gated. ::: From version 3.0.0, a gated call on a live key passes these checks, in this order, before anything is sent: 1. **The host must support MCP elicitation**, the protocol's way for a server to ask the person a question. A host that does not cannot run these five tools on a live key: each call is refused, with a message saying why. Stay on 2.x if you need the older behaviour on such a host. 2. **On the four payment tools, `idempotencyKey` must be on the call** (from 4.0.0). `create_payment_intent`, `capture_payment_intent`, `void_payment_intent` and `create_refund` refuse a live-key call without one before `confirmLive` is checked or the person is asked. Approval is never remembered, so without a key an agent that asks twice would get two separately approvable payments. Use one key per intended action and send the same key on every attempt at it. 3. **`confirmLive: true` must be on the call.** Without it the tool returns an error result (`isError: true`) telling the agent to get its operator's approval, not to set the flag itself. Only `true` counts, never `"yes"` or `1`. 4. **The person approves in the host's own prompt.** It shows the action, the amount and currency when the call names them, and the payment intent, transaction or card id. Nothing else from the call appears in it, so notes, reasons and metadata never reach the prompt. Money moves only if the person ticks **approve** and accepts. Declining, dismissing, an error, or no answer within 5 minutes refuses the call and nothing is sent. The AI model sets `confirmLive`, so the host prompt is the real safeguard. Your MCP host draws that prompt, not Von Payments, so it is only as trustworthy as the host: one that approves prompts automatically, or shows them to nobody, defeats it. Approval is per call and never carries over from an earlier call in the conversation. `confirmLive` is on every gated tool's schema at all times, including with a sandbox key, so an agent that learned a tool against a test key sees it when pointed at a live one. Each of the five is marked below with what is specific to it; `create_token` is gated only when `setupForFutureUse` is set, since single-use vaulting mints nothing that can be charged again. ### `vonpay_checkout_create_session` Create a checkout session. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `amount` | integer | Yes | Amount in minor units (e.g. `1499` = $14.99) | | `currency` | string | Yes | Three-letter ISO 4217 currency code | | `country` | string | No | Two-letter ISO 3166-1 alpha-2 country code | | `description` | string | No | Order description (max 500 characters) | | `successUrl` | string | No | Redirect URL on success (http/https only) | | `cancelUrl` | string | No | Redirect URL on cancel (http/https only) | | `metadata` | object | No | Key-value metadata: up to 50 keys, keys up to 64 characters, values up to 500 characters | ### `vonpay_checkout_get_session` Retrieve a session by ID. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `sessionId` | string | Yes | The session ID to look up (`vp_cs_test_*` or `vp_cs_live_*`) | The result includes `vp_tx_id` (the id `create_refund` takes as `transaction`) and `vp_tx_count`. When `vp_tx_count` is 2 or more the buyer was charged more than once and `vp_tx_id` is `null`: the tool tells the agent to stop and tell a person, not to refund. ### `vonpay_checkout_simulate_payment` Return a synthetic, shape-only preview of what a `succeeded`, `failed`, or `expired` outcome looks like. This tool does **not** call the API, does **not** look up the session, and does **not** change any session state; use it to preview the shape of a real webhook or session payload while building an integration. The `sessionId` you pass is echoed back in the synthetic response rather than looked up, and both test (`vp_cs_test_*`) and live (`vp_cs_live_*`) session IDs are accepted. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `sessionId` | string | Yes | The session ID to use in the synthetic response (not looked up) | | `outcome` | string | Yes | One of: `succeeded`, `failed`, `expired` | ### `vonpay_checkout_health` Check the API health status. No parameters. ### `vonpay_checkout_list_test_cards` Explains how to produce each test-mode outcome. No parameters, and no API call. It returns the order total that declines in test mode (with the `decline_code` and `action` it produces) and what a test payment needs. Same table as [Test mode](../reference/test-cards.md). ### `vonpay_checkout_get_payment_intent` Read a payment intent's stored state: status, amount, capture method, timestamps, the saved-card token, the 3-D Secure result, and `nextAction` while the buyer is still at their bank. Moves no money, so it is never gated. Only `succeeded` means the money was captured: `authorized` is a hold. Your metadata and the buyer's email are left out of the result, so they never enter the agent's conversation. | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `paymentIntentId` | string | Yes | The payment intent to read (`vpi_test_*` or `vpi_live_*`) | ### `vonpay_checkout_create_payment_intent` Create a payment intent for server-driven flows, recurring billing, MIT (merchant-initiated transactions), and saved-card charges, as a programmatic alternative to hosted checkout sessions (wraps `POST /v1/payment_intents`). Returns a `PaymentIntent` whose `status` discriminates the next action: `requires_action` (3DS: surface the `nextAction` URL to the buyer), `authorized` (call `vonpay_checkout_capture_payment_intent` next), `captured` (post-capture, pre-settle), or `succeeded` (terminal). :::warning Charges immediately unless you ask for manual capture With the default `captureMethod: "automatic"` this charges the buyer straight away (→ `succeeded`), and an `mit` block charges a saved credential with **no buyer present**. **On a live key: needs `confirmLive: true` and the person's approval**; see [above](#available-tools). ::: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `amount` | integer | Yes | Amount in minor units (e.g. `1499` = $14.99) | | `currency` | string | Yes | Three-letter ISO 4217 currency code | | `captureMethod` | string | No | One of `automatic` (default; charges immediately on auth success) or `manual` (parks at `authorized` until you call capture) | | `metadata` | object | No | Key-value metadata: up to 50 keys, keys up to 64 characters, values up to 500 characters | | `mit` | object | No | Merchant-initiated transaction block for recurring / saved-card / retry charges. Omit for cardholder-initiated transactions. Capability gate: confirm `supportedOperations.mit === true` first | | `idempotencyKey` | string | Live key | Same key returns the same intent rather than charging twice. Required on a live key. On a sandbox key, omitting it sends a generated key that protects only that one call | | `confirmLive` | boolean | Live key | `true` only once a human approved this call; the host prompt follows. See [above](#available-tools) | ### `vonpay_checkout_capture_payment_intent` Capture funds on an authorized payment intent (wraps `POST /v1/payment_intents/{id}/capture`). Call this after `vonpay_checkout_create_payment_intent` returned `status: authorized` (created with `captureMethod: "manual"`), typically at order fulfillment / ship time. The intent transitions to `succeeded` on success. :::warning Transfers the funds This moves money from the buyer's account to the merchant. **On a live key: needs `confirmLive: true` and the person's approval**; see [above](#available-tools). ::: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `paymentIntentId` | string | Yes | The payment intent to capture (`vpi_test_*` or `vpi_live_*`) | | `amountToCapture` | integer | No | Partial-capture amount in minor units. **Omit to capture the full authorized amount.** Partial-capture support is binder-dependent: check `supportedOperations.partialCapture` first | | `idempotencyKey` | string | Live key | Idempotency key for safe retries. Required on a live key | | `confirmLive` | boolean | Live key | `true` only once a human approved this call; the host prompt follows. See [above](#available-tools) | ### `vonpay_checkout_void_payment_intent` Void an authorized (uncaptured) payment intent (wraps `POST /v1/payment_intents/{id}/void`). Use this when the buyer changed their mind before fulfillment, or the order was cancelled while the intent was still in `authorized` state. Voiding releases the auth hold; no funds change hands. :::warning Releasing the hold cannot be undone A void releases the authorization **irreversibly**: the order cannot be captured afterwards, and there is no recovery short of asking the buyer to pay again. **On a live key: needs `confirmLive: true` and the person's approval**; see [above](#available-tools). You cannot void a captured intent: `supportedOperations.voidAfterCapture` reads `not_supported` on every processor today, so once the intent is `succeeded` use `vonpay_checkout_create_refund` instead. ::: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `paymentIntentId` | string | Yes | The payment intent to void (`vpi_test_*` or `vpi_live_*`) | | `idempotencyKey` | string | Live key | Idempotency key for safe retries. Required on a live key | | `confirmLive` | boolean | Live key | `true` only once a human approved this call; the host prompt follows. See [above](#available-tools) | ### `vonpay_checkout_create_refund` Refund a captured payment intent, or a settled transaction (wraps `POST /v1/refunds`). Use this when the payment intent is in `succeeded` state and you need to return funds to the buyer (order cancelled post-fulfillment, returned merchandise, billing dispute, etc.). Refund IDs use the `vpr_test_*` / `vpr_live_*` prefix. The returned `Refund` `status` is `requested`, `succeeded`, `failed`, or `canceled`; a refund the processor has accepted but not yet completed comes back as **`requested`**, which is in progress, not a failure. Do not re-issue it; it settles asynchronously via the [`charge.refunded`](../integration/webhook-events.md#chargerefunded) webhook, or reports [`refund.failed`](../integration/webhook-events.md#refundfailed) if it does not complete. See [Refunds](../reference/refunds.md#status). :::warning A settled refund cannot be reversed This returns funds from the merchant to the buyer, and once it settles neither party can undo it. **On a live key: needs `confirmLive: true` and the person's approval**; see [above](#available-tools). ::: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `paymentIntent` | string | One of the two | The payment intent to refund (`vpi_test_*` or `vpi_live_*`) | | `transaction` | string | One of the two | The transaction to refund (`vp_tx_test_*` or `vp_tx_live_*`): for a payment with no payment intent, or the transaction a [`refund_target_is_duplicate`](../reference/error-codes.md#refund_target_is_duplicate) error names | | `amount` | integer | No | Refund amount in minor units. **Omit to refund the full remaining balance** (server computes `authorized - previously refunded`). Partial-refund support is binder-dependent: check `supportedOperations.partialRefund` | | `currency` | string | No | Three-letter ISO 4217 currency code | | `reason` | string | No | Free-form reason mirrored back on the refund record, for audit trails (max 500 characters) | | `metadata` | object | No | Key-value metadata: up to 50 keys, keys up to 64 characters, values up to 500 characters | | `idempotencyKey` | string | Live key | Idempotency key for safe retries. Required on a live key | | `sessionId` | string | Live key, with `transaction` | The session this payment belongs to (`vp_cs_*`). Used only to check the transaction against the session before the person is asked; never sent with the refund. Refused with `paymentIntent` | | `confirmLive` | boolean | Live key | `true` only once a human approved this call; the host prompt follows. See [above](#available-tools) | From 5.0.0, on a live key the approval prompt also shows details looked up from Von Payments, not supplied by the agent: the original payment's amount, status and creation time for `paymentIntent`, or the session's total, payment status, creation time and id for `transaction`. A refund larger than the looked-up amount, or in a different currency, adds a warning line. A refund by `transaction` is refused before the person is asked when its session cannot be found, when the session's `vp_tx_count` is not exactly 1, or when the transaction is not that session's payment. Sandbox keys skip these checks. Pass exactly one of `paymentIntent` and `transaction`; both, or neither, is refused before anything is sent. The result names the target it refunded, with the other one `null`. Both ids are visible to the shopper's browser, so the tool tells the agent never to refund an id taken from a customer message or any text that did not come from your own order records. ### `vonpay_checkout_create_token` Vault a card for later reuse (wraps `POST /v1/tokens`). Returns a `vp_pmt_(test|live)_*` payment-method token. :::warning A reusable scope mints a chargeable credential With `setupForFutureUse: "on_session"` or `"off_session"` this vaults a credential that can be charged later with no buyer present, and **with either scope, on a live key, the call needs `confirmLive: true` and the person's approval**; see [above](#available-tools). Single-use vaulting (the scope omitted) is low-risk and is not gated. ::: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `setupForFutureUse` | string | No | Reuse scope, captured at vault time: `"on_session"` (in-session reuse, e.g. upsells) or `"off_session"` (recurring / MIT, after explicit buyer consent). Omitted, a later merchant-initiated charge against the card returns `payment_method_consent_missing` (422). | | `providerReference` | string | No | Iframe-minted vault handle from the browser-side card input (≤512 chars). Optional in the tool's schema, but the API requires it with a test key and with a live key, and refuses the call without it (`400 validation_error`). | | `buyerId` | string | No | Buyer to attach the token to (≤128 chars). The server may also infer it from key context. | | `metadata` | object | No | Key-value metadata: up to 50 keys, values up to 500 characters. | | `idempotencyKey` | string | No | Idempotency key to make the vault call safely retryable. | | `confirmLive` | boolean | Live key + reusable scope | `true` only once a human approved this call; the host prompt follows. See [above](#available-tools) | Reusability is governed by the `setupForFutureUse` field on the vault row, not by a second prefix; the `vp_pmt_*` prefix is stable. Scope is set **once** at vault time; re-vault is required to upgrade it (e.g. `null` → `"off_session"`) because the consent record must match what the buyer saw. See [Tokenization](../embedded-fields/tokenization.md) for the full reusability model. ### `vonpay_checkout_diagnose_error` Diagnose a Von Payments error code and return structured self-heal guidance an agent can act on without a follow-up prompt. **Pure data: no API call, no state change.** | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `code` | string | Yes | The error code to diagnose (lowercase identifier, e.g. `auth_invalid_key`, ≤64 chars). Unknown codes return a `contact_support` fallback. | | `status` | integer | No | The HTTP status that came with the error (100–599), to help correlate. | | `requestId` | string | No | The `X-Request-Id` from the error response (≤128 chars), for support escalation. | Returns a JSON object: `{ code, known, retryable, nextAction, llmHint, docs, agentInstructions[] }` (plus `status` / `requestId` when you supplied them). `nextAction` is one of `fix_input` · `rotate_key` · `wait_and_retry` · `contact_support` · `ignore` · `reconcile` (`reconcile`: the outcome is unknown, so read the payment intent back and never retry; this is the MCP tool's own action vocabulary, distinct from the server error envelope's `selfHeal.nextAction`); `agentInstructions` is a short branch-specific playbook for that action. See [Error Codes](../reference/error-codes.md) for the canonical per-code reference. ## Example Agent Workflows ### Create and preview a test payment > "Create a $14.99 checkout session in USD, then show me what a successful payment payload looks like." The agent will: 1. Call `vonpay_checkout_create_session` with amount `1499`, currency `USD` 2. Call `vonpay_checkout_simulate_payment` with outcome `succeeded` to preview the success payload shape (this is a synthetic preview; it does not change the real session) 3. Call `vonpay_checkout_get_session` to read the live session's current status ### Preview error handling > "Create a session and show me what a failed payment payload looks like, including the error details." The agent will: 1. Create a session 2. Call `vonpay_checkout_simulate_payment` with outcome `failed` to preview the failure payload shape (synthetic; no real payment is attempted) 3. Use the previewed shape to build the failure-handling path in the integration ### Check system health > "Is the Von Payments API up?" The agent calls `vonpay_checkout_health` and reports the status. --- ## /sdks/node-sdk Source: https://docs.vonpay.com/sdks/node-sdk # @vonpay/checkout-node — Node.js SDK Typed TypeScript/JavaScript client for the Von Payments Checkout API. Zero runtime dependencies, Node 20+. ## Install ```bash npm install @vonpay/checkout-node ``` The package is an ES module. From 3.3.0, CommonJS code can load it with `require("@vonpay/checkout-node")` on Node 20.19+ or 22.12+, and gets the same module `import` returns. On older Node, use `await import("@vonpay/checkout-node")`. A TypeScript project that compiles to CommonJS needs `"module": "nodenext"` (TypeScript 5.8+), `"module": "node20"` (TypeScript 5.9+) or `"moduleResolution": "bundler"`; with `"module": "node16"` the type check reports TS1479 even though the code runs. Pin the major you tested against: minor and patch releases are additive under semver, though a minor bump may add options or change defaults. A major bump is breaking and comes with an upgrade section: see [Upgrading to 3.0.0](#v3-migration). ## Initialize ```typescript // Simple — pass API key as a string const vonpay = new VonPayCheckout("vp_sk_live_xxx"); // With options — pass a config object const vonpay = new VonPayCheckout({ apiKey: "vp_sk_live_xxx", apiVersion: "2026-04-14", baseUrl: "https://checkout.vonpay.com", // default maxRetries: 2, // default timeout: 30_000, // ms, default errorReporter: (err, ctx) => { // optional — see Error reporting below Sentry.captureException(err, { extra: ctx }); }, }); ``` The constructor validates the key prefix. A key must start with one of `vp_sk_test_`, `vp_sk_live_`, `vp_pk_test_`, or `vp_pk_live_`; passing a key that matches none of these throws immediately. (Most server-side calls require a secret `vp_sk_*` key; see `sessions.get` below.) --- ## vonpay.sessions.create(params, options?) Create a checkout session and get a checkout URL. ```typescript const session = await vonpay.sessions.create({ amount: 1499, // in cents — required and >= 1 for mode "payment" currency: "USD", // required, 3-letter code (uppercased) successUrl: "https://mystore.com/order/123/confirm", // optional (HTTPS) cancelUrl: "https://mystore.com/cart", // optional country: "US", // optional, ISO 3166-1 alpha-2 mode: "payment", // optional, default "payment" description: "Order #123", // optional locale: "en", // optional expiresIn: 1800, // optional, seconds (300–604800, max 7 days) buyerId: "cust_abc", // optional — your STABLE per-user ID (not per-visit) buyerName: "Jane Doe", // optional buyerEmail: "jane@example.com", // optional lineItems: [ // optional { name: "Widget", quantity: 1, unitAmount: 1499 }, ], metadata: { orderId: "order_123" }, // optional }, { idempotencyKey: "order_123_create", // optional request option }); // session.id => "vp_cs_live_k7x9m2n4p3" // session.checkoutUrl => "https://acme.vonpay.com/checkout?session=..." // session.expiresAt => "2026-03-31T15:30:00.000Z" ``` From 3.5.0, `buyerContact` sends the buyer's `phone`, `shippingAddress` and `billingAddress` when you already have them, for example a sale your staff keys in. Every part is optional; addresses use the `ShippingAddress` shape (send `state: ""` where the country has none). Secret key only, never returned by `sessions.get()`, and personal data: send only what the buyer gave you and never log it. The session read also types `nameCollection`, `emailCollection` and `channel`. `amount` is in minor units (cents): `1499` is `$14.99`. It is required and must be `>= 1` for the default `mode: "payment"`. `currency` is required and normalised to a 3-letter uppercase code. `successUrl` is **optional**; when present it must be a valid HTTPS URL. `idempotencyKey` is passed as the second-argument request option and sent as the `Idempotency-Key` header. The live checkout URL is hosted on your merchant subdomain (for example `https://acme.vonpay.com/checkout?session=...`). `expiresAt` is an ISO-8601 timestamp string. See [Create a Session](../integration/create-session.md) for the full parameter reference. ### Embedded charge-and-save If the buyer completes payment through the embedded checkout (Embedded Fields) rather than the hosted page, the buyer fields above are what wire the session to vaulting: - A buyer reference on the session, a `buyerId` **or** a `buyerEmail` (with `buyerName` to enrich the record), is **required** to vault a reusable `vp_pmt_*` token via the embed. A guest session with no buyer reference at all charges once and saves nothing. - Under charge-and-save the embed **charges on submit**, so the server must **not** also call `paymentIntents.create` for that same session, or the buyer is charged twice. - The embed's client-side result is a UX signal only. Confirm settlement from the [`charge.succeeded`](../integration/webhook-events.md#chargesucceeded) webhook before fulfilling. See [Charge and save](../embedded-fields/tokenization.md#charge-and-save) for the full embedded flow and the `{ token } | { charged: true } | { error }` result shape. --- ## vonpay.sessions.get(sessionId) Retrieve the full status of a session. Requires a secret key (`vp_sk_*`); a publishable key is rejected with HTTP `403` (`auth_key_type_forbidden`). ```typescript const status = await vonpay.sessions.get("vp_cs_live_k7x9m2n4p3"); // status.id => "vp_cs_live_k7x9m2n4p3" // status.status => "succeeded" // status.transactionId => "txn_abc123" // status.amount => 1499 // status.currency => "USD" ``` Returns a full `SessionStatus` object including payment details and metadata. `status.status` is typed `SessionState` in the SDK, whose members are `pending`, `processing`, `succeeded`, `failed`, and `expired`. `processing` is a transient state during authorization, not a result: treat it as non-terminal and keep polling or wait for the webhook rather than branching on it as an outcome. On an expired session, `expiredFrom: "processing"` can later change to `"failed"` once the provider confirms an abandoned bank check authorized nothing, so a `processing` you read once is not final. The rules are on [The session object](../reference/session-object.md). From 3.6.0 the read also types these fields, in wire spelling like `vp_tx_id`: - `retry`: `{ allowed, attempts_remaining }`, whether the session accepts another card after a decline (an `elements` session charging at submit, on an account with retry enabled). It is the only place to learn this when the decline came after the bank's check. `null` when it does not apply. - `last_payment_error`: `{ code, decline_code }` for the most recent declined attempt. `code` is `card_declined` or `payment_failed` (open enum); `decline_code` uses the payment intent's `failure_code` values, or `null`. - `amount_refunded`, `remaining_refundable` and `refundable`: the refund totals, `null` together when they could not be read or the session has no single payment. `null` is not zero. Each reads `undefined` from a server that does not send it, never a default. --- ## vonpay.sessions.update(sessionId, params, options?) Change the total of an open, unpaid `elements` session, for example after the buyer picks a shipping option in the Apple Pay or Google Pay sheet. Requires a secret key. Added in 2.11.0. The rules are on [Change an open session's total](../reference/api.md#update-session-total). ```typescript try { const session = await vonpay.sessions.update("vp_cs_live_k7x9m2n4p3", { amount: 5498 }); // session.amount => 5498 } catch (err) { if (err instanceof SessionNotModifiableError) { // Nothing was changed. err.reason is not_open | integration_mode | // setup_session | store_order | in_page_bank_check; err.session has its state. } throw err; } ``` `params` takes `amount` (required, minor units), `currency` (optional; omit it to keep the session's currency) and `lineItems` (optional; replaces the displayed line items). It resolves with the session carrying the new `amount`. Setting a total is idempotent on its own, so `idempotencyKey` is optional. --- ## vonpay.sessions.expire(sessionId, options?) Close an open, unpaid session so no payment can start on it afterwards. Call it before taking payment for the same order another way. Requires a secret key. Resolves with the closed session; any other state throws `SessionNotExpirableError` with the session's state, and nothing changes. The rules, including the one exception on `embed` sessions, are on [Close a session so you can charge another way](../reference/api.md#expire-session). ```typescript try { await vonpay.sessions.expire("vp_cs_live_k7x9m2n4p3"); // Closed: now take payment another way. } catch (err) { if (err instanceof SessionNotExpirableError && err.reason === "recent_activity") { // A declined session that changed recently: call expire() again after // err.retryAfterSeconds and it closes. The SDK never repeats the call itself. } else if (err instanceof SessionNotExpirableError) { // Nothing changed, and a payment may exist on it. Do not charge another way. } throw err; } ``` From 3.5.0 the error carries `reason`: `"recent_activity"` (call again after `retryAfterSeconds`), `"payment_may_be_in_flight"`, or `"unknown"` for a value the SDK does not recognise, which you handle like `"payment_may_be_in_flight"`. It is `undefined` when the API sent none. `rawReason` keeps the value as sent. A closed session's `expiredFrom` can be `"failed"` (a declined session closed this way), and like `"pending"` nothing can start on it afterwards. --- ## vonpay.paymentIntents.retrieve(paymentIntentId) Read a payment intent's stored state (maps to `GET /v1/payment_intents/{id}`). Added in `2.7.0`. Requires a secret key; a publishable key is rejected `403`. ```typescript const intent = await vonpay.paymentIntents.retrieve("vpi_live_9f2nd4kq7x1m"); // intent.status => "succeeded" // intent.amount => 1499 ``` **This is how you confirm a payment on a 3-D Secure return page.** Pass the id you stored when you created the intent, never one read out of the return URL, which anyone can forge. Fulfil only on `status === "succeeded"`: `authorized` is a hold, and `captured` is still pending at the processor. From 3.6.0 the read also types: - `avsResultCode` and `cvvResultCode`: the address and security-code results for the payment. `null` when nothing was checked or no result came back. `"unavailable"` and `"not_supported"` (address) or `"not_provided"` and `"unavailable"` (security code) mean the check did not run, not that it failed. This server read is where a payment taken in the browser gets its result, because the browser never receives it. Keep the values on your server. - `amountRefunded`, `remainingRefundable` and `refundable`: the refund totals. All three are `null` together when the totals could not be read at that moment, and `null` is not zero: read again or call [`refunds.list()`](#vonpayrefundslisttarget-page). `refundable` is `false` until the charge settles. - `nextAction` while the payment is `requires_action`, with `issuedAt` on the same object (beside `redirectToUrl`, not inside it) saying when the challenge link was issued. If your server lost the create response, send the buyer to the link from here; check `issuedAt` first, since the payment provider decides how long a link stays usable. See [3-D Secure](../embedded-fields/3ds.md). Decline details and the card summary exist on the create response and are not re-read here. ```typescript const pi = await vonpay.paymentIntents.retrieve(storedIntentId); if (pi.cvvResultCode === "no_match") { // a real mismatch } ``` --- ## vonpay.refunds.list(target, page?) The refunds of one payment, plus its refund totals (maps to `GET /v1/refunds`). Added in 3.6.0. Requires a secret key. It never moves money and is retried like any other read. ```typescript const page = await vonpay.refunds.list( { transaction: session.vp_tx_id! }, // or { paymentIntent: "vpi_..." }, exactly one { limit: 25, startingAfter: cursor }, ); page.data; // newest first, every status page.amountRefunded; // succeeded + still in progress, across all pages page.remainingRefundable; // what a refund with no amount would refund now page.refundable; // false until the charge settles, and when disputed or fully refunded page.nextCursor; // pass back as startingAfter while hasMore; null on the last page ``` Name the payment with exactly one of `paymentIntent` or `transaction`: both or neither is a compile error, and a `TypeError` before any request. Refunds made through either id are listed whichever one you ask with. Check each refund's own `status`, since a `requested` refund is still in progress and can still fail. Each listed refund carries `idempotencyKey` (the key you sent, or `null`) and `createdAt`. There is no auto-paging helper: loop on `nextCursor`. A duplicate-record `transaction` throws `422 refund_target_is_duplicate` with `refundInstead`, as on `refunds.create()`. The fields are on [List a payment's refunds](../reference/refunds.md#list-refunds). A `refunds.create()` answer now types `idempotent`: `true` when it replays an earlier request with the same `idempotencyKey`, so no new refund was issued. --- ## vonpay.sessions.validate(params) Dry-run validation of session parameters without creating a session (maps to `POST /v1/sessions?dry_run=true`). Returns validation results. ```typescript const result = await vonpay.sessions.validate({ amount: 1499, currency: "USD", successUrl: "https://mystore.com/confirm", }); // result.valid => true // result.warnings => ["cancelUrl is recommended for production"] ``` --- ## vonpay.webhooks.verifySignature(payload, signature, secret) Verify an incoming webhook's HMAC-SHA256 signature. Uses `crypto.timingSafeEqual` to prevent timing attacks. Returns boolean; never throws. Prefer `constructEvent` for typed event parsing. ```typescript const isValid = vonpay.webhooks.verifySignature( req.body, // raw request body (Buffer or string) req.headers["x-vonpay-signature"] as string, // signature header (t=…,v1=…) process.env.VON_PAY_WEBHOOK_SECRET, // whsec_* — per-endpoint secret ); ``` --- ## vonpay.webhooks.constructEvent(payload, signature, secret) Verify the signature, enforce the asymmetric replay window (5 min past / 30 sec future), and parse the webhook payload into a typed event. Takes 3 arguments. On a malformed header, a stale timestamp, or an HMAC mismatch it throws a `VonPayError` with code `webhook_invalid_signature` and HTTP status `401`. ```typescript const endpointSecret = process.env.VON_PAY_WEBHOOK_SECRET; // whsec_* app.post("/webhooks/vonpay", express.raw({ type: "application/json" }), (req, res) => { try { const event = vonpay.webhooks.constructEvent( req.body, // raw body (Buffer) req.headers["x-vonpay-signature"] as string, // signature header (t=…,v1=…) endpointSecret, // whsec_* — per-endpoint secret ); switch (event.type) { case "charge.succeeded": console.log(`Paid: ${event.data.payment_intent_id}`); break; case "charge.failed": console.log(`Failed: ${event.data.failure_reason}`); break; case "charge.refunded": // null means the connection could not derive the total: say so, never "0". console.log(`Refunded so far: ${event.data.amount_refunded_total ?? "unknown, review"}`); break; default: // Unknown event types: log and fall through. constructEvent still // returns a well-formed envelope for an event type added server-side // after this SDK version, so new types may arrive without an SDK bump. console.log(`Unhandled event: ${event.type}`); } // Your handler returns the HTTP 200 ack — constructEvent only parses the // event; it does not send an HTTP response. res.status(200).json({ received: true }); } catch (err) { res.status(400).json({ error: err.message }); } }); ``` The webhook secret is per-endpoint (`whsec_*`), minted when you register the endpoint at `/dashboard/developers/webhooks`. The signature header is named `x-vonpay-signature` with the format `t=,v1=`. See [Webhook Signing Secrets](../integration/webhook-secrets.md) for the create / rotate / revoke lifecycle. --- ## Confirming a return redirect When the buyer is redirected back to your `successUrl`, read the outcome from the API: the redirect itself only tells you they came back. ```typescript const client = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY); const params = Object.fromEntries( new URL(req.url, `https://${req.headers.host}`).searchParams, ); const outcome = await client.sessions.confirmReturn(params); if (outcome.paid) { // show the confirmation page } else if (outcome.reason === "still_pending") { // IN FLIGHT, not declined — show "confirming your payment", never a failure } else { // not succeeded } ``` `sessions.confirmReturn(params)` verifies the return **and** reads the server-side status in one call, returning `{ paid, signatureValid, status, reason }`. Branch on `paid`: it is decided by the authenticated session read, never by the redirect URL. ⚠️ **Do not pass the signing secret.** The `secret` parameter is optional and returns are signed platform-wide, so a per-merchant `ss_*` can never match. Leaving it out is the normal path: `signatureValid` then reads `null`, which means *"not checked"*, a healthy value, not a failure. `false` would mean *checked and it failed*. ⚠️ **`paid: true` is not "safe to fulfil".** The status keeps reading `succeeded` on every replay of the same URL, so record which session IDs you have already fulfilled; and on a `captureMethod: "manual"` session `succeeded` means authorised and held, not paid. Fulfil the order from the `charge.succeeded` webhook rather than here: a buyer who pays and closes the tab never loads this page. See [Handle the Return](../integration/handle-return.md). :::note `VonPayCheckout.verifyReturnSignature` is not how you confirm a payment The SDK still exports this static method, but a signature check answers *"was this message altered?"*, not *"did the buyer pay?"* A declined payment carries an equally valid signature, so it must never gate a confirmation page or an order. Use `confirmReturn` above. Signature verification belongs on webhooks, where each endpoint has its own `whsec_*` secret; see [`vonpay.webhooks.verifySignature`](#vonpaywebhooksverifysignaturepayload-signature-secret). ::: --- ## vonpay.webhookSubscriptions.list(params?) List your registered webhook endpoints (maps to `GET /v1/webhook_subscriptions`). Newest first, ordered by creation time. ```typescript const page = await vonpay.webhookSubscriptions.list({ limit: 20 }); // page.object => "list" // page.hasMore => true // page.data[0] => { id, url, enabledEvents, status, lastSuccessAt, … } ``` `limit` is 1–100 and defaults to 10. To page, pass the **last item's `id`** as `startingAfter`: ```typescript const first = await vonpay.webhookSubscriptions.list({ limit: 20 }); const next = first.hasMore ? await vonpay.webhookSubscriptions.list({ limit: 20, startingAfter: first.data[first.data.length - 1].id, }) : null; ``` :::warning A cursor you do not own is a `400`, not an empty page If `startingAfter` does not reference one of your own subscriptions, the request fails with `400 validation_error`. Pagination deliberately does **not** silently restart from the top: restarting would let an id be probed across merchants, and a paging loop that mistakes it for "no more pages" would silently re-read page one forever. ::: Each entry carries `status`, typed as `"active" | "paused" | "disabled"`, plus `lastDeliveryAt`, `lastSuccessAt` and `lastErrorAt`. Those three are the ones worth surfacing in your own dashboard: a subscription can read `active` while every recent delivery failed. --- ## vonpay.webhookSubscriptions.retrieve(webhookSubscriptionId) Look up a single endpoint by id (maps to `GET /v1/webhook_subscriptions/{id}`). ```typescript const sub = await vonpay.webhookSubscriptions.retrieve("vp_whsub_live_k7x9m2n4p3"); // sub.url => "https://mystore.com/webhooks/vonpay" // sub.enabledEvents => ["charge.succeeded", "charge.refunded"] // sub.status => "active" ``` **Ownership failures are a `404`, never a `403`.** An id belonging to another merchant and an id that has been deleted both return the same **`404` `webhook_subscription_not_found`** as an id that never existed: one body for all three, so ids cannot be probed across merchants. The one `403` this call can return is unrelated to ownership: like the rest of this resource it is secret-key only, so a publishable key is rejected with `403 auth_key_type_forbidden` before the lookup happens. --- ## vonpay.webhookSubscriptions.sendTestEvent(id, params) Send one signed test event to an endpoint through the real delivery path and get the outcome in the same call (maps to `POST /v1/webhook_subscriptions/{id}/send_test_event`). Added in 3.6.0. Requires a secret key. Use it to prove your endpoint verifies **and** processes notifications. ```typescript const outcome = await vonpay.webhookSubscriptions.sendTestEvent(sub.id, { eventType: "charge.succeeded", sessionId: "vp_cs_test_k7x9m2n4p3", // optional }); // outcome.delivered => true when the endpoint answered 2xx // outcome.responseStatus => the endpoint's HTTP status, or null if nothing answered // outcome.deliveryAttemptId => audit handle for the attempt // outcome.signaturePreview => first 12 characters of the signature header sent // outcome.error => why it was not delivered; null when delivered ``` `delivered: false` with a `responseStatus` is a successful test of an endpoint that answered non-2xx. If the endpoint could not be reached or timed out, the call throws `502 webhook_test_delivery_failed` with `nextAction: "fix_input"`, and the SDK does not repeat it: check the URL and send again. A `429` or `503` is retried as usual. The delivered event carries `test_event: true`, the only marker that it is synthetic. Your handler must check it before touching an order: with `sessionId`, the payload carries that session's real ids and will match a real order. See [Webhook Event Types](#webhook-event-types). --- :::note Answering "did my endpoint actually receive that event?" Read `lastDeliveryAt`, `lastSuccessAt` and `lastErrorAt` from [`webhookSubscriptions.list()`](#vonpaywebhooksubscriptionslistparams) above. Those describe deliveries to **your** endpoint. `webhookEvents.retrieve(id)` is a different thing: it reads our stored record of an event we received from the processor. Its `id` is the one shown in the dashboard's **Events** view, not the `vp_evt_*` id on a payload delivered to you. ::: --- ## vonpay.health() Check API health and latency (maps to `GET /api/health`). ```typescript const health = await vonpay.health(); // health.status => "ok" // the only value this call can return, but not // // for the reason you might expect. "healthy" and // // "degraded" belong to the DEEP check (?deep=true), // // which this call does not request — "healthy" is a // // 200, not a 503 — and health() has no parameter // // to request the deep check. "error" IS a 503, and the // // SDK throws on any non-2xx. Wrap in try/catch, not a switch. // health.version => "" // the endpoint sends no version field; the client // // defaults it. Do not monitor deploys with this. // health.latencyMs => 42 ``` --- ## vonpay.buyers — stored shopper profiles New in **2.0.0**. A buyer profile is a stored shopper record you can attach payments to and look up later. ```typescript const buyer = await vonpay.buyers.upsert({ externalId: "cust_4471", email: "ada@example.com" }); await vonpay.buyers.retrieve(buyer.id); await vonpay.buyers.find({ email: "ada@example.com" }); // null when not found await vonpay.buyers.update(buyer.id, { phone: "+15551234567" }); ``` Five methods: `upsert`, `retrieve`, `find`, `update`, `eraseEmail`. `find` returns `null` rather than throwing when there is no match. ### Erasing an email is its own method, on purpose {#erase-email} ⛔ **This is the one part of the buyers API where the SDK deliberately refuses to do what the endpoint allows.** `PATCH /v1/buyers/{id}` treats `email: null` as a permanent erase. Several common JSON libraries serialise an absent optional field as `null` by default, so a routine "update this shopper's phone number" built from a half-populated struct can wipe their address, return `200`, and look exactly like a success. So `update()` **cannot express `null`** (it is a compile error) and `eraseEmail()` is the only path that sends it: ```typescript await vonpay.buyers.eraseEmail(buyer.id); // the only way to erase ``` ⛔ **`eraseEmail` is not a complete deletion and must never be described to a shopper as one.** It clears the address on the profile. Copies elsewhere keep their own retention rules and this call does not touch them. ⚠ **And a success does not confirm the provider's copy is gone.** We ask your payment provider to drop theirs, but that request is dispatched after we answer you and reports no failure back. For a buyer you only ever identified by email, the retraction cannot be performed at all: the key it needs was derived from the address just erased. **[Buyers](../reference/buyers.md#erase-email) is the full model**, which copies survive, why that list is examples rather than an inventory, and what to do when you are answering a formal erasure request. Read it before you build on this. ## Upgrading to 3.6.0 {#v3-6-upgrade} Additive, apart from one type correction. **Saved-card details can be `null`.** `Token.card.brand`, `.last4`, `.expMonth` and `.expYear` are now typed `string | null` / `number | null`, matching what the API already sends when the payment provider's details for a saved card cannot be read (the card is still saved and usable). At runtime, a field the response omits now reads `null` instead of `undefined`, and a response with no `card` now gives a `card` whose four fields are `null` instead of no `card` at all. A response that carries every field is unchanged. TypeScript may now ask for a null check where you read them: ```typescript const label = token.card.last4 ?? "unknown"; ``` If you switch exhaustively on `resolvedDescriptor.reason`, add `"invalid_for_provider"`: the charge was sent without your configured descriptor because it breaks a provider limit. See [Statement descriptors](../reference/statement-descriptors.md). ## Upgrading to 3.0.0 {#v3-migration} Two breaking changes. Nothing was removed or renamed. **Pass `idempotencyKey` to `void()` to keep automatic retries.** A void now follows the same rule as capture and refunds: after a timeout or a `500`/`502`/`503`/`504` it is retried only when you passed a key. Without one, the first error is thrown with `retryWithheld: true`. A void releases the hold for good, and an unclear failure does not mean it was not applied. ```typescript await vonpay.paymentIntents.void(paymentIntentId, { idempotencyKey: `order_${orderId}_void` }); ``` **A redirect from the API is now an error.** The API never redirects, so a `3xx` means something else answered: a proxy, a mistyped `baseUrl`, or a hijacked connection. The SDK no longer follows it and throws [`unexpected_redirect`](../reference/error-codes.md#unexpected_redirect) with `retryable: false` and a new `nextAction` value, `check_configuration`. Do not retry: check that `baseUrl` is `https://checkout.vonpay.com` with no path, then any proxy and the network. If you switch exhaustively on `ErrorCode` or `nextAction`, add both values. 3.0.0 also exports `formatMinorUnits(amount, currency)` (display only: do not parse or store its output), `minorUnitDigits(currency)` and `isLiveKey(key)`. ## Upgrading to 2.0.0 {#v2-migration} One breaking change, and it is a type change rather than a behaviour change. **`rejectReason` dropped five values it could never receive.** `not_authorized`, `already_captured`, `already_voided`, `already_refunded` and `amount_exceeds_remaining` are gone; the server never sent any of them, so every comparison against one was already dead code. Those comparisons now fail to compile. Delete them. ⭐ **The value worth adding a branch for is `concurrent_update`**: another capture or void is in flight for the same payment right now. Before 2.0.0 the SDK replaced any value it did not recognise with nothing at all, so a branch checking for a race never fired and **a payment still in flight was read as terminal**. That is how a live payment gets cancelled or refunded. Treat it as "unknown outcome, read again", never as an end state. The full list and what each value means is on [Error codes](../reference/error-codes.md#invalid_transition). ## Error reporting The SDK accepts an optional `errorReporter` callback in the constructor so integrators can pipe SDK failures into their own observability stack (Sentry, Datadog, custom logger). Your reporter is invoked synchronously, fire-and-forget; if it throws, the SDK swallows the throw with a `console.warn` and continues. A separate [SDK-telemetry](../sdk-telemetry.md) channel can POST anonymised error events back to Von Payments to improve the SDK. It is enabled by default for test keys (`vp_sk_test_*`) and off for live keys unless you explicitly enable it; it never carries request bodies or PII. The `errorReporter` callback above is independent of this and runs whether or not telemetry is enabled. ### When it fires - API request failures: a non-retryable response (any status not in the retry set: 4xx including `401`/`403`/`404`/`422`, plus non-retryable 5xx like `501`); retry-exhaustion on a retryable status (`429`, `500`, `502`, `503`, `504`); and network/timeout errors after retry exhaustion - `webhooks.constructEvent` verification failures (signature mismatch, stale timestamp, malformed header) It does **not** fire on: - `verifySignature` / `verifyReturnSignature`: these return boolean, never throw - The constructor's invalid-key-prefix throw: that's a dev-time error before the reporter is wired ### Callback shape ```typescript const reporter: ErrorReporter = (err, ctx) => { // err is VonPayError | Error // ctx is ErrorReporterContext: // method: string // e.g. "sessions.create", "sessions.get", // // "sessions.validate", "webhooks.constructEvent", // // or a "GET /api/health"-style fallback // sdkVersion: string // url?: string // origin + path, no query string (no PII via params) // status?: number // HTTP status if from API response // requestId?: string // X-Request-Id for correlation // code?: string // server error code (auth_invalid_key, etc.) // attempt?: number // 0-indexed retry attempt }; ``` `ErrorReporterContext.method` is typed as a plain `string`: the SDK passes a method label such as `"sessions.create"` or `"webhooks.constructEvent"` where one is available, and otherwise falls back to a `" "` string (for example `"GET /api/health"`). ### Sentry example ```typescript const vonpay = new VonPayCheckout({ apiKey: process.env.VON_PAY_SECRET_KEY!, errorReporter: (err, ctx) => { Sentry.captureException(err, { tags: { sdk: "vonpay-node", method: ctx.method, code: ctx.code }, contexts: { vonpay: ctx }, }); }, }); ``` ### Datadog example ```typescript const vonpay = new VonPayCheckout({ apiKey: process.env.VON_PAY_SECRET_KEY!, errorReporter: (err, ctx) => { logger.error("vonpay sdk error", { err, ...ctx }); }, }); ``` `errorReporter` is opt-in and additive: if you don't configure it, errors still propagate via `throw` and nothing else changes. --- ## Charges without an idempotency key {#keyless-charge-warning} From the **28 October 2026** cut-off the API can refuse a charge that carries no `Idempotency-Key` with `400` [`idempotency_key_required`](../reference/error-codes.md#idempotency_key_required), before anything is charged ([Idempotency](../integration/idempotency.md#idempotency-required)). From 3.2.0 the SDK prints one `[vonpay]` warning per process the first time `paymentIntents.create()` is called without `idempotencyKey`. The charge is still sent. An empty string counts as no key. `VONPAY_QUIET=1` silences the warning. Pass the same key on every retry of the same charge: ```typescript await vonpay.paymentIntents.create(params, { idempotencyKey: `order_${orderId}_create` }); ``` ## Auto-Retry The SDK automatically retries on `429` (rate-limited) and `5xx` (server error) responses with exponential backoff. It reads the `Retry-After` header when present, capped at 60 seconds. Configure with `maxRetries` in the constructor (default: 2). ### Money-moving calls without an idempotency key are not retried Four calls move money: `paymentIntents.create`, `paymentIntents.capture`, `paymentIntents.void` (from 3.0.0) and `refunds.create`. When one of them ends **ambiguously** (a `500`, `502`, `503`, `504`, or a network timeout), the SDK cannot tell whether the charge was applied before the connection broke. If you sent **no idempotency key**, retrying could take the buyer's money a second time, so the SDK stops and raises the first error instead. **Send an idempotency key and the retries come back.** The same key rides every attempt, so the repeat is collapsed server-side and is safe. One exception, from 3.3.0: when the `5xx` reply to a money call (`paymentIntents.create`, `.capture`, `.void`, `refunds.create`) cannot be read, because it was cut off or replaced by a proxy's HTML error page, the SDK stops after the first attempt even with a key. The error carries `retryWithheld: true`; read the payment intent back before repeating anything. ```typescript await vonpay.paymentIntents.create( { amount: 1499, currency: "USD", paymentMethod: { id: "vp_pmt_live_..." }, returnUrl: `https://mystore.com/checkout/return?order=${orderId}` }, { idempotencyKey: `order_${orderId}_create` }, // ← retries stay enabled ); ``` Two boundaries, so this doesn't read as broader than it is: - **`429` is still retried without a key.** A rate-limited request is rejected before it reaches the handler, so nothing was charged and repeating it is safe. - **Non-money writes are unchanged.** `sessions.create`, `tokens.create` and the webhook-subscription writes retry exactly as before. On the HTTP path the raised `VonPayError` carries `retryWithheld: true` so you can tell this case apart from an ordinary failure: ```typescript catch (err) { if (err instanceof VonPayError && err.retryWithheld) { // Ambiguous outcome, no key sent — the charge MAY have gone through. // Do not blindly re-issue; look up the payment intent before retrying. } } ``` :::warning A timeout is withheld too, but arrives without the flag The same protection applies when the request times out or the network drops: that outcome is always ambiguous. But a network failure raises the underlying error rather than a `VonPayError`, so **`retryWithheld` is not present on it**. Do not treat "no `retryWithheld`" as "safe to retry". After any failed money-moving call sent without an idempotency key, confirm the actual state before re-issuing. ::: The cleanest fix for all of this is to send an idempotency key on every money-moving call; then none of the above applies. See [Idempotency](../reference/api.md#idempotency). ### `payment_outcome_unknown` is never retried In 2.11.0 and later, the SDK never repeats a call that answers `502` [`payment_outcome_unknown`](../reference/error-codes.md#payment_outcome_unknown), with or without an idempotency key, on a capture or a void alike: the provider accepted the operation and only the record of it failed, so a repeat could capture twice. The error carries `nextAction: "reconcile"` and `captureOutcome` or `voidOutcome` set to `"unknown"`. Read the payment intent back with `paymentIntents.retrieve()` before doing anything else. --- ## Error Handling All methods throw `VonPayError` on non-2xx responses. Errors include structured fields for programmatic handling. ```typescript try { await vonpay.sessions.create({ ... }); } catch (err) { if (err instanceof VonPayError) { console.error(err.message); // "Invalid API key" console.error(err.status); // 401 console.error(err.code); // "auth_invalid_key" console.error(err.fix); // "Check that your API key is correctly formatted and active" console.error(err.docs); // "https://docs.vonpay.com/reference/security#key-types" console.error(err.requestId); // "req_abc123" console.error(err.rateLimit); // { limit: 100, remaining: 0, reset: 1710000000, retryAfter: 30 } } } ``` `rateLimit` is `{ limit, remaining, reset, retryAfter? }`; `retryAfter` (seconds) is populated from the `Retry-After` header when the server sends one. ### ErrorCode union type The `code` field is a string-literal union, enabling exhaustive `switch` statements: ```typescript function handleError(code: ErrorCode) { switch (code) { case "auth_missing_bearer": case "auth_invalid_key": case "auth_key_expired": case "auth_key_type_forbidden": case "auth_merchant_inactive": case "auth_service_unavailable": // authentication errors break; case "validation_error": case "validation_missing_field": case "validation_invalid_amount": // validation errors break; case "rate_limit_exceeded": case "rate_limit_exceeded_per_key": // back off break; case "session_not_found": case "session_expired": case "session_wrong_state": case "session_integrity_error": // session errors break; // ... exhaustive handling } } ``` `auth_invalid_key` maps to HTTP `401`, `auth_key_type_forbidden` to `403`, and both `rate_limit_exceeded` / `rate_limit_exceeded_per_key` to `429`. --- ## Webhook Event Types `WebhookEvent` is a discriminated union on the `type` field. The envelope carries `id`, `type`, `created`, `livemode`, `merchant_id`, and a typed `data` payload that varies by event type, plus `test_event?: true` on a **Send test event** delivery only: `if (event.test_event === true)` return `200` and do nothing else. See [Webhook Event Reference](../integration/webhook-events.md) for the full per-event `data` shapes. | Event | Typed `data` fields | |-------|---------------------| | `charge.succeeded` | `session_id`, `payment_intent_id`, `transaction_id`, `vp_tx_id`, `amount`, `currency`, `card` | | `charge.failed` | `session_id`, `payment_intent_id`, `transaction_id`, `vp_tx_id`, `amount`, `currency`, `failure_reason`, `failure_code`, `rule_code`, `network_decline_code`, `card`, `retry` | | `charge.refunded` | `session_id`, `payment_intent_id`, `transaction_id`, `vp_tx_id`, `refund_id`, `amount`, `refund_amount`, `amount_refunded_total`, `currency`, `reason`, `is_partial`, `original_charge_amount`, `card` | | `refund.failed` | `session_id`, `payment_intent_id`, `transaction_id`, `vp_tx_id`, `refund_id`, `amount`, `refund_amount`, `amount_refunded_total`, `currency`, `reason`, `reason_code`, `is_partial`, `original_charge_amount`, `retry_available`, `card` | | `payment_intent.succeeded` | `session_id`, `payment_intent_id`, `transaction_id`, `amount`, `currency` | | `payment_intent.failed` | `session_id`, `payment_intent_id`, `transaction_id`, `amount`, `currency`, `failure_reason`, `failure_code`, `rule_code`, `network_decline_code`, `retry` | | `payment_intent.cancelled` | `session_id`, `payment_intent_id`, `transaction_id`, `amount`, `currency`, `cancellation_reason` | **Reconcile and refund on `vp_tx_id`**, the Von transaction id, never `transaction_id`, which is not one id space. The `charge.*` types name it as of `2.7.1`, and `refund.failed` as of `2.7.2`. The wire carries more, unchanged on `event.data`: `avs_result_code` / `cvv_result_code` on `charge.succeeded`, and `decline_code` / `action` / `decline_message` on the failure family. The [event reference](../integration/webhook-events.md) documents each. Every top-level `data` key is nullable: the SDK types each as `T | null`, so always null-check before use. `card` is `{ brand, last4 }` (its inner fields are non-null once `card` itself is present) and is populated only when card enrichment is active for the merchant; `null` otherwise. `failure_code` is the normalized decline code (e.g. `card_declined`); `network_decline_code` is the raw ISO-8583 code (e.g. `05`). The table lists the most-used events, not every member of the union. From 3.6.0, `charge.failed`, `payment_intent.failed` and `session.failed` type `retry`, the same `{ allowed, attempts_remaining }` object as on `sessions.get()`. A failure event is sent for **every** declined attempt, so one is not final on its own: `allowed: true` means the session was re-opened for another card (keep the order open); `allowed: false` with `attempts_remaining: 0` is final for this checkout; `null` means retry does not apply. An event without the field is unknown, never final. See [A failed charge is not always the end of the checkout](../integration/webhook-events.md#retry-after-decline). ```typescript function handle(event: WebhookEvent) { switch (event.type) { case "charge.succeeded": // event.data.transaction_id is typed here break; case "charge.failed": // event.data.failure_reason is typed here break; case "charge.refunded": // event.data.amount_refunded_total is typed here break; } } ``` --- ## TypeScript All types are exported: ```typescript import type { VonPayCheckoutConfig, CreateSessionParams, CheckoutSession, SessionStatus, LineItem, HealthStatus, VonPayError, ErrorCode, WebhookEvent, } from "@vonpay/checkout-node"; ``` --- ## Sample apps Clone-and-run reference integrations using this SDK live in [`Von-Payments/vonpay-samples`](https://github.com/Von-Payments/vonpay-samples): - [`checkout-nextjs`](https://github.com/Von-Payments/vonpay-samples/tree/main/checkout-nextjs): Next.js 15 / React 19 hosted checkout with signed return verification + webhook handler - [`checkout-express`](https://github.com/Von-Payments/vonpay-samples/tree/main/checkout-express): Node + Express 5 server-only hosted checkout - [`checkout-paybylink-nextjs`](https://github.com/Von-Payments/vonpay-samples/tree/main/checkout-paybylink-nextjs): Pay-by-link operator + customer flow - [`platform-integrator-nextjs`](https://github.com/Von-Payments/vonpay-samples/tree/main/platform-integrator-nextjs): Multi-tenant platform pattern: per-tenant credentials, multi-tenant webhook routing, idempotency keys (CRM, subscription-billing, and ISV connector shape; see also [Integrate VORA as a Payment Gateway](../platforms/index.md)) Each ships with `.env.example`, a per-sample README, and pinned to a known-working SDK version. --- ## /sdks/python-sdk Source: https://docs.vonpay.com/sdks/python-sdk # Python SDK Typed Python client for the Von Payments Checkout API, published as `vonpay-checkout` on PyPI. **Requirements:** Python 3.9+, httpx ## Install ```bash pip install vonpay-checkout ``` Pin the major you tested against — minor and patch releases are additive under semver, though a minor bump may add options or change defaults. A major bump is breaking and comes with an upgrade section: see [Upgrading to 2.0.0](#v2-migration). ## Initialize ```python from vonpay.checkout import VonPayCheckout, VonPayError client = VonPayCheckout("vp_sk_test_...", api_version="2026-04-14") ``` The `api_version` parameter pins the API version for all requests made by this client instance. See [API Versioning](../reference/versioning.md) for details. ## Sessions ### Create a session ```python session = client.sessions.create( amount=1499, currency="USD", country="US", ) print(session.id) # "vp_cs_test_abc123" print(session.checkout_url) # "https://checkout.vonpay.com/checkout?session=..." ``` Returns a `CheckoutSession` object. ### Get a session ```python status = client.sessions.get("vp_cs_test_abc123") print(status.status) # "succeeded" print(status.amount) # 1499 ``` Returns a `SessionStatus` object. Requires a secret key. From 3.6.0 the session read also carries: - `retry`: a `SessionRetry` with `allowed` and `attempts_remaining`, whether the session accepts another card after a decline (an `elements` session charging at submit, on an account with retry enabled). It is the only place to learn this when the decline came after the bank's check. `None` when it does not apply. - `last_payment_error`: a `SessionLastPaymentError` with `code` (`"card_declined"` or `"payment_failed"`, open set) and `decline_code` (the payment intent's `failure_code` values, or `None`). - `amount_refunded`, `remaining_refundable` and `refundable`: the refund totals, `None` together when they could not be read or the session has no single payment. `None` is not zero. On an expired session, `expired_from` of `"processing"` can later change to `"failed"` once the provider confirms an abandoned bank check authorized nothing, so a `"processing"` you read once is not final. The rules are on [The session object](../reference/session-object.md). ### Change an open session's total ```python from vonpay.checkout import SessionNotModifiableError try: session = client.sessions.update("vp_cs_test_abc123", amount=5498) except SessionNotModifiableError as e: # Nothing was changed. e.reason is not_open, integration_mode, # setup_session, store_order or in_page_bank_check; e.session has its state. raise ``` Only `elements` sessions can change their total. `currency` and `line_items` are optional keyword arguments. Added in 2.11.0; the rules are on [Change an open session's total](../reference/api.md#update-session-total). ### Close a session ```python import time from vonpay.checkout import SessionNotExpirableError try: client.sessions.expire("vp_cs_test_abc123") except SessionNotExpirableError as err: if err.reason != "recent_activity": # Nothing changed, and a payment may exist on it. Do not charge another way. raise # A declined session that changed recently: ask again after the wait. # The SDK never repeats the call itself. time.sleep(err.retry_after_seconds or 1) client.sessions.expire("vp_cs_test_abc123") ``` From 3.5.0 the error carries `reason`: `"recent_activity"` (call again after `retry_after_seconds`), `"payment_may_be_in_flight"`, or `"unknown"` for a value the SDK does not recognise, handled like `"payment_may_be_in_flight"`. `raw_reason` keeps the value as sent. A closed session's `expired_from` can be `"failed"`. `sessions.create()` takes `buyer_contact` (phone, shipping and billing address) from 3.5.0, with the same rules as `buyerContact` on [Create a session](../integration/create-session.md): secret key only, never returned on the read, personal data you must not log. Closes an open, unpaid session so no payment can start on it afterwards. See [Close a session so you can charge another way](../reference/api.md#expire-session). ### Validate (dry run) ```python result = client.sessions.validate( amount=1499, currency="USD", ) # Validates parameters without creating a session ``` ## Payment intents ### Read a payment intent ```python pi = client.payment_intents.retrieve(stored_intent_id) print(pi.status) # "succeeded" if pi.cvv_result_code == "no_match": ... # a real mismatch ``` Returns a `PaymentIntentRead`. Requires a secret key. Pass the id you stored when you created the intent, never one read out of a return URL. From 3.6.0 it also carries: - `avs_result_code` and `cvv_result_code`: the address and security-code results. `None` when nothing was checked or no result came back. `"unavailable"` and `"not_supported"` (address) or `"not_provided"` and `"unavailable"` (security code) mean the check did not run, not that it failed. This server read is where a payment taken in the browser gets its result, because the browser never receives it. Keep the values on your server. - `amount_refunded`, `remaining_refundable` and `refundable`: the refund totals, `None` together when they could not be read. `None` is not zero: read again or call `refunds.list()`. `refundable` is False until the charge settles. - `next_action` while the payment is `requires_action`: the challenge link is `pi.next_action["redirect_to_url"]["url"]`, and `["issued_at"]` beside it says when it was issued. If your server lost the create response, send the buyer to the link from here; check `issued_at` first, since the payment provider decides how long a link stays usable. See [3-D Secure](../embedded-fields/3ds.md). ## Refunds ### List a payment's refunds ```python page = client.refunds.list( transaction=session.vp_tx_id, # or payment_intent="vpi_...", exactly one limit=25, starting_after=cursor, ) page.data # newest first, every status page.amount_refunded # succeeded + still in progress, across all pages page.remaining_refundable # what a refund with no amount would refund now page.refundable # False until the charge settles, and when disputed or fully refunded page.next_cursor # pass back as starting_after while has_more; None on the last page ``` Added in 3.6.0; maps to `GET /v1/refunds`. Requires a secret key. Pass exactly one of `payment_intent` or `transaction`; both or neither raises `ValueError` before any request. Check each refund's own `status`, since a `requested` refund is still in progress and can still fail. Each listed `Refund` carries `idempotency_key` and `created_at`. It never moves money and is retried like any other read. The fields are on [List a payment's refunds](../reference/refunds.md#list-refunds). A `refunds.create()` answer now carries `idempotent`: True when it replays an earlier request with the same `idempotency_key`, so no new refund was issued. ## Webhooks ### Verify signature Verify the HMAC-SHA256 signature on an incoming webhook request. The webhook secret is per-endpoint (`whsec_*`), minted when you register the endpoint at `/dashboard/developers/webhooks`. ```python is_valid = client.webhooks.verify_signature( payload=request_body, signature_header=request.headers["x-vonpay-signature"], secret=os.environ["VON_PAY_WEBHOOK_SECRET"], # whsec_* ) ``` ### Construct event Parse and verify a webhook payload into a typed event object. Enforces the asymmetric replay window (5 min past / 30 sec future). ```python event = client.webhooks.construct_event( payload=request_body, signature_header=request.headers["x-vonpay-signature"], secret=os.environ["VON_PAY_WEBHOOK_SECRET"], # whsec_* ) print(event.type) # "charge.succeeded" print(event.data["vp_tx_id"]) # "vp_tx_test_9f2nd..." — the Von transaction id; reconcile and refund on this ``` `event.data` is a plain dict, handed through as sent. On a **Send test event** delivery the event carries `test_event: True`: check it first, before any lookup or write, and return `2xx` without doing anything else. `charge.failed`, `payment_intent.failed` and `session.failed` carry `event.data.get("retry")`: `{"allowed": bool, "attempts_remaining": int}` or `None`. A failure event is sent for **every** declined attempt, so one is not final on its own. `allowed` True means the session was re-opened for another card (keep the order open); `allowed` False with `attempts_remaining` 0 is final for this checkout. A missing key means unknown, never final. See [A failed charge is not always the end of the checkout](../integration/webhook-events.md#retry-after-decline). ## Webhook management Read-only access to your registered webhook endpoints and stored event records. These methods all require a secret key (`vp_sk_*`) — publishable keys are rejected with `403`. The wire format is camelCase end-to-end and parsed into typed dataclasses. ### List webhook subscriptions ```python subs = client.webhook_subscriptions.list( limit=20, # optional starting_after="b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13", # optional cursor ) for sub in subs.data: print(sub.id, sub.url, sub.status) print(subs.has_more) # True if more pages remain ``` Results are newest-first. To page, pass the last item's `id` as `starting_after`. A cursor that doesn't reference a subscription owned by your merchant raises a `400` `validation_error` (pagination is not silently restarted from the top). Returns a `WebhookSubscriptionsList` object with `object`, `data` (a list of `WebhookSubscription`), `has_more`, and `url`. Requires a secret key. ### Retrieve a webhook subscription ```python sub = client.webhook_subscriptions.retrieve("b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13") print(sub.url) # the registered endpoint URL print(sub.enabled_events) # ["charge.succeeded", ...] print(sub.status) # "active" | "paused" | "disabled" ``` Returns a `WebhookSubscription` object. A cross-merchant or soft-deleted id returns `404` (opaque — never `403`). The signing secret is never present on a read response. Requires a secret key. ### Send a test event Sends one signed test event to the endpoint through the real delivery path and returns the outcome in the same call. Added in 3.6.0. Requires a secret key. ```python outcome = client.webhook_subscriptions.send_test_event( sub.id, event_type="charge.succeeded", session_id="vp_cs_test_abc123", # optional ) print(outcome.delivered) # True when the endpoint answered 2xx print(outcome.response_status) # the endpoint's HTTP status, or None if nothing answered print(outcome.error) # why it was not delivered; None when delivered ``` Returns a `SendTestEventOutcome` with `delivered`, `response_status`, `delivery_attempt_id`, `signature_preview` and `error`. `delivered` False with a `response_status` is a successful test of an endpoint that answered non-2xx. If the endpoint could not be reached or timed out, the call raises `502 webhook_test_delivery_failed` and the SDK does not repeat it: check the URL and send again. A `429` or `503` is retried as usual. The delivered event carries `test_event: True`, the only marker that it is synthetic. Your handler must check it before touching an order: with `session_id`, the payload carries that session's real ids and will match a real order. ### Retrieve a stored webhook event ```python record = client.webhook_events.retrieve("vp_wh_5x9k2m4n") print(record.type) # "charge.succeeded" print(record.processed) # True | False print(record.retry_count) # int ``` Returns a `WebhookEventRecord` — our stored record of an event received from the processor, keyed by the `id` in the dashboard's **Events** view (not a delivered payload's `vp_evt_*` id, which `webhooks.construct_event` parses). Requires a secret key. ## Confirming a return redirect When the buyer is redirected back to your `success_url`, read the outcome from the API — the redirect itself only tells you they came back. ```python client = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"]) outcome = client.sessions.confirm_return(dict(request.args)) if outcome.paid: ... # show the confirmation page elif outcome.reason == "still_pending": ... # IN FLIGHT, not declined — "confirming your payment", never a failure else: ... # not succeeded ``` `sessions.confirm_return(params)` verifies the return **and** reads the server-side status in one call, returning a `ReturnOutcome` with `paid`, `signature_valid`, `status` and `reason`. Branch on `paid` — it is decided by the authenticated session read, never by the redirect URL. ⚠️ **Do not pass the signing secret.** The `secret` argument is optional and returns are signed platform-wide, so a per-merchant `ss_*` can never match. Leaving it out is the normal path: `signature_valid` then reads `None`, which means *"not checked"* — a healthy value, not a failure. `False` would mean *checked and it failed*. ⚠️ **`paid` being true is not "safe to fulfil".** The status keeps reading `succeeded` on every replay of the same URL, so record which session IDs you have already fulfilled; and on a `captureMethod: "manual"` session `succeeded` means authorised and held, not paid. Fulfil the order from the `charge.succeeded` webhook rather than here — a buyer who pays and closes the tab never loads this page. See [Handle the Return](../integration/handle-return.md). :::note `verify_return_signature` is not how you confirm a payment The SDK still exposes this static method, but a signature check answers *"was this message altered?"* — not *"did the buyer pay?"* A declined payment carries an equally valid signature, so it must never gate a confirmation page or an order. Use `confirm_return` above. Signature verification belongs on webhooks, where each endpoint has its own `whsec_*` secret — see [Webhooks](#webhooks). ::: ## Health Check ```python health = client.health() print(health.status) # "ok" — the only value this call can return print(health.latency_ms) # float — measured dependency latency (ms) print(health.version) # always "" — the endpoint sends no version field ``` Returns a `HealthStatus` dataclass with `status`, `latency_ms`, and `version`. Branch on neither. `status` is always `"ok"` — this call sends no query parameters, so it gets the liveness ping; `"healthy"`/`"degraded"` need the deep check, which `health()` cannot request (call `?deep=true` directly), and `"error"` is a `503`, which raises. `version` is always `""`: the endpoint sends no such field. The signal is whether the call **raised**. ## Error Handling All API errors raise `VonPayError` with structured fields for programmatic handling. ```python from vonpay.checkout import VonPayCheckout, VonPayError try: session = client.sessions.create(amount=-1, currency="USD") except VonPayError as e: print(e.status) # 400 print(e.code) # "validation_invalid_amount" print(e.fix) # "Amount must be a positive integer in minor units (cents). 1499 = $14.99" print(e.docs) # "https://docs.vonpay.com/integration/create-session#amount-format" ``` ## Buyer profiles New in **2.0.0**. A buyer profile is a stored shopper record you can attach payments to and look up later. ```python buyer = client.buyers.upsert(external_id="cust_4471", email="ada@example.com") client.buyers.retrieve(buyer.id) client.buyers.find(email="ada@example.com") # None when not found client.buyers.update(buyer.id, phone="+15551234567") ``` Five methods: `upsert`, `retrieve`, `find`, `update`, `erase_email`. `find` returns `None` rather than raising when there is no match. ### Erasing an email is its own method, on purpose {#erase-email} ⛔ **This is the one part of the buyers API where the SDK deliberately refuses to do what the endpoint allows.** `PATCH /v1/buyers/{id}` treats `email: null` as a permanent erase — and a routine update built from a half-populated object can send that null without anyone intending it. So the SDK separates "not supplied" from "explicitly None": `update()` **raises** on `email=None`, and `erase_email()` is the only path that sends it. ```python client.buyers.erase_email(buyer.id) # the only way to erase ``` ⛔ **`erase_email` is not a complete deletion and must never be described to a shopper as one.** It clears the address on the profile. Copies elsewhere keep their own retention rules and this call does not touch them. ⚠ **And a success does not confirm the provider's copy is gone.** We ask your payment provider to drop theirs, but that request is dispatched after we answer you and reports no failure back. For a buyer you only ever identified by email, the retraction cannot be performed at all — the key it needs was derived from the address just erased. **[Buyers](../reference/buyers.md#erase-email) is the full model** — which copies survive, why that list is examples rather than an inventory, and what to do when you are answering a formal erasure request. Read it before you build on this. ## Upgrading to 3.6.0 {#v3-6-upgrade} **Check your code before upgrading: saved-card details can now be `None` at runtime.** When the payment provider's details for a saved card cannot be read, the card is still saved and usable and the API returns each detail as `null`. Before 3.6.0 the SDK filled those in with `""` or `0`; now `token.card.brand`, `.last4`, `.exp_month` and `.exp_year` are `None`, as is a missing `card`. Code that uses them directly, such as `token.card.last4.upper()` or `token.card.exp_year + 1`, raises on such a card. Guard each read: ```python label = token.card.last4 or "unknown" ``` Cards whose details were read are unaffected. If you match exhaustively on `resolved_descriptor.reason`, add `"invalid_for_provider"`: the charge was sent without your configured descriptor because it breaks a provider limit. See [Statement descriptors](../reference/statement-descriptors.md). ## Upgrading to 3.0.0 {#v3-migration} Two breaking changes. Nothing was removed or renamed. **Pass `idempotency_key` to `void()` to keep automatic retries.** A void now follows the same rule as capture and refunds: after a timeout or a `500`/`502`/`503`/`504` it is retried only when you passed a key. Without one, the first error is raised with `retry_withheld=True`. A void releases the hold for good, and an unclear failure does not mean it was not applied. ```python vonpay.payment_intents.void(payment_intent_id, idempotency_key=f"order_{order_id}_void") ``` **A redirect from the API is now an error.** The API never redirects, so a `3xx` means something else answered: a proxy, a mistyped `base_url`, or a hijacked connection. The SDK no longer follows it and raises [`unexpected_redirect`](../reference/error-codes.md#unexpected_redirect) with `retryable` false and a new `next_action` value, `check_configuration`. Do not retry: check that `base_url` is `https://checkout.vonpay.com` with no path, then any proxy and the network. If you match exhaustively on error codes or next actions, add both values. ## Upgrading to 2.0.0 {#v2-migration} One breaking change, and it is a type change rather than a behaviour change. **`rejectReason` dropped five values it could never receive.** `not_authorized`, `already_captured`, `already_voided`, `already_refunded` and `amount_exceeds_remaining` are gone; the server never sent any of them, so every comparison against one was already dead code. Those comparisons now fail to compile. Delete them. ⭐ **The value worth adding a branch for is `concurrent_update`** — another capture or void is in flight for the same payment right now. Before 2.0.0 the SDK replaced any value it did not recognise with nothing at all, so a branch checking for a race never fired and **a payment still in flight was read as terminal**. That is how a live payment gets cancelled or refunded. Treat it as "unknown outcome, read again", never as an end state. The full list and what each value means is on [Error codes](../reference/error-codes.md#invalid_transition). ## Error reporting The SDK accepts an optional `error_reporter` callback so integrators can pipe SDK failures into their own observability stack (Sentry, Datadog, custom logger). Your reporter is invoked synchronously, fire-and-forget; if it raises, the SDK swallows it (with a `logging.warning` on the `vonpay.checkout` logger) and continues. ### When it fires - API request failures: non-retryable 4xx, retry-exhaustion on 5xx, network/timeout errors after retry exhaustion - `webhooks.construct_event` verification failures (signature mismatch, stale timestamp, malformed header) It does **not** fire on: - `verify_signature` / `verify_return_signature` — these return `bool`, never raise - The constructor's invalid-key-prefix `ValueError` — that's a dev-time error before the reporter is wired ### Callback shape ```python from vonpay.checkout import ErrorReporter, ErrorReporterContext, VonPayError def reporter(err: Exception, ctx: ErrorReporterContext) -> None: # err is VonPayError or another Exception subclass # ctx fields: # method: str — "sessions.create" / "webhooks.construct_event" / etc. # sdk_version: str # url: str | None — origin + path, no query string (no PII via params) # status: int | None — HTTP status if from API response # request_id: str | None — X-Request-Id for correlation # code: str | None — server error code # attempt: int | None — 0-indexed retry attempt ... ``` ### Sentry example ```python import sentry_sdk from vonpay.checkout import VonPayCheckout def report_to_sentry(err, ctx): sentry_sdk.capture_exception( err, tags={"sdk": "vonpay-python", "method": ctx.method, "code": ctx.code}, contexts={"vonpay": ctx.__dict__}, ) client = VonPayCheckout( api_key=os.environ["VON_PAY_SECRET_KEY"], error_reporter=report_to_sentry, ) ``` ### Logging example ```python import logging from vonpay.checkout import VonPayCheckout log = logging.getLogger("myapp") client = VonPayCheckout( api_key=os.environ["VON_PAY_SECRET_KEY"], error_reporter=lambda err, ctx: log.error( "vonpay sdk error", extra={"err": str(err), "ctx": ctx.__dict__} ), ) ``` `error_reporter` is opt-in and additive — if you don't configure it, errors still propagate via `raise` and nothing else changes. --- ## Charges without an idempotency key {#keyless-charge-warning} From the **28 October 2026** cut-off the API can refuse a charge that carries no `Idempotency-Key` with `400` [`idempotency_key_required`](../reference/error-codes.md#idempotency_key_required), before anything is charged ([Idempotency](../integration/idempotency.md#idempotency-required)). From 3.2.0 the SDK emits one `FutureWarning` per process the first time `payment_intents.create()` is called without `idempotency_key`. The charge is still sent. An empty string counts as no key. `VONPAY_QUIET=1` silences the warning. **If you run with warnings as errors** (`python -W error`, `PYTHONWARNINGS=error` or pytest's `filterwarnings = error`), a keyless `payment_intents.create()` raises `FutureWarning` locally, before anything is sent. Nothing is charged, but the call fails. Pass `idempotency_key` on every charge before you upgrade. ```python vonpay.payment_intents.create(..., idempotency_key=f"order_{order_id}_create") ``` ## Auto-Retry The SDK automatically retries on `429` (rate limited) and `5xx` (server error) responses using exponential backoff. Default is 2 retries; configure with the `max_retries` constructor argument. ⛔ **Money-moving calls are the exception.** On a charge, capture, void (from 3.0.0) or refund the SDK retries an ambiguous response **only when you passed an `idempotency_key`.** Without one it stops and raises rather than repeating the call, because a `5xx` or a timeout there means the provider was reached and the answer was lost — repeating it can charge the buyer twice. `429` still retries without a key (nothing reached the handler), and non-money writes are unchanged. From 3.3.0 there is one case a key does not cover: when the `5xx` reply to a money call (`payment_intents.create`, `capture`, `void`, `refunds.create`) cannot be read, because it was cut off or replaced by a proxy's HTML error page, the SDK stops after the first attempt with or without a key. The error carries `retry_withheld=True`; read the payment intent back before repeating anything. In 2.11.0 and later, a `502` [`payment_outcome_unknown`](../reference/error-codes.md#payment_outcome_unknown) is **never** retried, key or no key: the provider accepted the capture or void and only the record of it failed. The raised error carries `capture_outcome` or `void_outcome` set to `"unknown"`. Read the payment intent back before doing anything else. --- ## Sample app Clone-and-run reference integration using this SDK lives in [`Von-Payments/vonpay-samples`](https://github.com/Von-Payments/vonpay-samples): - [`checkout-flask`](https://github.com/Von-Payments/vonpay-samples/tree/main/checkout-flask) — Flask hosted checkout with signed return verification + webhook handler Ships with `.env.example`, a per-sample README, and pinned to a known-working SDK version. --- ## /sdks/rest-api Source: https://docs.vonpay.com/sdks/rest-api # REST API For developers not using Node.js. Call the API directly with any HTTP client. ## Base URL ``` https://checkout.vonpay.com ``` ## Authentication All merchant-facing endpoints require a Bearer token: ``` Authorization: Bearer vp_sk_live_xxx ``` ## Endpoints ### Create Session ```bash curl -X POST https://checkout.vonpay.com/v1/sessions \ -H "Authorization: Bearer vp_sk_live_xxx" \ -H "Content-Type: application/json" \ -H "Von-Pay-Version: 2026-04-14" \ -H "Idempotency-Key: unique_key_123" \ -d '{ "amount": 1499, "currency": "USD", "country": "US", "successUrl": "https://mystore.com/confirm", "lineItems": [{"name": "Widget", "quantity": 1, "unitAmount": 1499}] }' ``` **Response (201):** ```json { "id": "vp_cs_live_k7x9m2n4p3", "checkoutUrl": "https://checkout.vonpay.com/checkout?session=vp_cs_live_k7x9m2n4p3", "expiresAt": "2026-03-31T15:30:00.000Z" } ``` ### Get Session Status ```bash curl https://checkout.vonpay.com/v1/sessions/vp_cs_live_k7x9m2n4p3 \ -H "Authorization: Bearer vp_sk_live_xxx" \ -H "Von-Pay-Version: 2026-04-14" ``` ### Health Check ```bash curl https://checkout.vonpay.com/api/health ``` No authentication required. > **The full server-side resource set** — `POST /v1/payment_intents` (+ `/capture`, `/void`), `POST /v1/refunds`, `POST /v1/tokens`, and `GET /v1/capabilities` — is documented per-resource in [API Reference](../reference/api.md), [Payment Intents](../integration/payment-intents.md), [Refunds](../reference/refunds.md), [Tokens](../reference/tokens.md), and [Capabilities](../reference/capabilities.md), and in full in the [OpenAPI spec](https://checkout.vonpay.com/openapi.yaml). They're all plain HTTP with the same Bearer-token auth shown above. ## Webhook Subscriptions Programmatically manage your webhook endpoints. **Secret key only** (`vp_sk_*`) — publishable keys receive `403`. Reads are limited to 100/min per key; writes (create / update / delete / rotate / test) to 30/min per key. | Method | Path | Description | |--------|------|-------------| | `GET` | `/v1/webhook_subscriptions` | List subscriptions (cursor pagination) | | `POST` | `/v1/webhook_subscriptions` | Create a subscription | | `GET` | `/v1/webhook_subscriptions/{id}` | Retrieve a subscription | | `PATCH` | `/v1/webhook_subscriptions/{id}` | Update a subscription | | `DELETE` | `/v1/webhook_subscriptions/{id}` | Delete a subscription | | `POST` | `/v1/webhook_subscriptions/{id}/rotate_signing_secret` | Rotate the signing secret | | `POST` | `/v1/webhook_subscriptions/{id}/send_test_event` | Send a signed test event | | `GET` | `/v1/webhook_events/{id}` | Retrieve a stored processor event, by its Events-view id | This management API is **camelCase** end to end (`enabledEvents`, `signingSecret`, `lastDeliveryAt`); the delivered event **payload** is snake_case (see below). Cross-merchant or cross-mode access returns an opaque `404` (never `403`). ### Create a subscription ```bash curl -X POST https://checkout.vonpay.com/v1/webhook_subscriptions \ -H "Authorization: Bearer vp_sk_live_xxx" \ -H "Content-Type: application/json" \ -d '{ "url": "https://mystore.com/webhooks/vonpay", "enabledEvents": ["charge.succeeded", "charge.refunded"], "description": "Order fulfillment" }' ``` **Response (201)** — includes the signing secret, returned **only once**: ```json { "id": "b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13", "object": "webhook_subscription", "url": "https://mystore.com/webhooks/vonpay", "enabledEvents": ["charge.succeeded", "charge.refunded"], "status": "active", "description": "Order fulfillment", "signingSecret": "whsec_3f9a2b...", "apiVersion": "2026-04-14", "lastDeliveryAt": null, "lastSuccessAt": null, "lastErrorAt": null, "createdAt": "2026-06-18T12:00:00.000Z" } ``` :::warning Save the signing secret now `signingSecret` (the `whsec_…` value) is returned **only** on create and on `rotate_signing_secret` — never on reads. Store it immediately; if you lose it, rotate to get a new one. You need it to [verify the `x-vonpay-signature` header](../integration/webhook-verification.md) on every delivery. ::: `enabledEvents` must name events from the catalog. A list containing **any** unrecognised name (`session.expired`, which does not exist, or a misspelling) is refused with [`400 validation_error`](../reference/error-codes.md#validation_error), even when the other names are valid, and nothing is stored. The same applies to `PATCH`. ### Selectable events The most commonly used values for `enabledEvents`. **[Webhook Events](../integration/webhook-events.md) is the complete list** — including connected-platform order mirroring, which is not shown here: | Event | Fires when | |-------|-----------| | `charge.succeeded` | A charge is captured | | `charge.failed` | A charge attempt fails | | `charge.refunded` | A charge is refunded (full or partial) | | `refund.failed` | A refund did not complete — no money went back to the buyer. Delivered to every active subscription whether or not you selected it, but not every processor connection emits it, so never make it your only detector for a failed refund; reconcile an unresolved refund from your own ledger. See [Refunds](../reference/refunds.md#status) | | `payment_intent.succeeded` | A payment intent reaches `succeeded` | | `payment_intent.failed` | A payment intent fails | | `payment_intent.cancelled` | A payment intent is voided / cancelled | Two other families are modeled in the SDK's `WebhookEvent` type so your handler can `switch` on `type` forward-compatibly. `dispute.*` (`created` / `won` / `lost`) is **delivered to every active subscription automatically** (as is `refund.failed`, above): you do not list it in `enabledEvents`, and your endpoint receives it even if it only registered for `charge.*`. `session.succeeded` and `session.failed` are subscribable like the rest; see [Session events](../integration/webhook-events.md#session-events). Always handle an unrecognized `type` gracefully: return `200` and log. ### Update, rotate, delete ```bash # Pause without deleting (stops deliveries; keeps the config + secret) curl -X PATCH https://checkout.vonpay.com/v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13 \ -H "Authorization: Bearer vp_sk_live_xxx" -H "Content-Type: application/json" \ -d '{"status": "paused"}' # Rotate the signing secret (returns a new whsec_ once) curl -X POST https://checkout.vonpay.com/v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13/rotate_signing_secret \ -H "Authorization: Bearer vp_sk_live_xxx" # Delete curl -X DELETE https://checkout.vonpay.com/v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13 \ -H "Authorization: Bearer vp_sk_live_xxx" ``` `PATCH` accepts any of `url`, `enabledEvents`, `description`, and `status`: | `status` | Behavior | |----------|----------| | `active` | Receives deliveries normally. | | `paused` | Stops deliveries; config + signing secret retained. Resume by patching back to `active`. | | `disabled` | **Two states share this value, and only one is recoverable.** *Your endpoint returned `410 Gone`* on a delivery: we stop sending and set this status, but the subscription still exists — fix the endpoint and patch `status` back to `active`. *You called `DELETE`*: the record is soft-deleted, every subsequent call returns `404`, and the signing secret is never reissued, so [create a fresh endpoint](../integration/webhook-secrets.md#4-revoke) instead. If a patch to `active` returns `404`, it was deleted rather than auto-disabled. You cannot set `disabled` yourself — `PATCH` accepts `active` and `paused` only. | ### Send a test event ```bash curl -X POST https://checkout.vonpay.com/v1/webhook_subscriptions/b6f2c1a0-3e4d-4c8b-9a1f-2d5e7c9b0a13/send_test_event \ -H "Authorization: Bearer vp_sk_live_xxx" -H "Content-Type: application/json" \ -d '{"eventType": "charge.succeeded"}' ``` The response is **synchronous** — it reports exactly what your endpoint returned to the signed test delivery: ```json { "delivered": true, "response_status": 200, "delivery_attempt_id": "vp_wda_test_...", "signature_preview": "t=1749000000", "error": null } ``` `delivered: false` with a non-null `response_status` means your endpoint **was reached** and returned a non-2xx — useful for confirming your error handling, not a failure of the test itself. Two failures look alike and need opposite handling. A `400 validation_error` means the test was refused for the event type you named (the message is the generic "verify the URL and that every event type is a recognized event"); retrying the same request will not change it, so check the name against the catalog or choose another event type. A `502` [`webhook_test_delivery_failed`](../reference/error-codes.md#webhook_test_delivery_failed) means the test could not be delivered this time; retry shortly. ### Refund event payload `charge.refunded` delivers the following under `data` (snake_case): | Field | Type | Notes | |-------|------|-------| | `vp_tx_id` | string \| null | The Von transaction id of the refunded charge (`vp_tx_*`), the same value its `charge.succeeded` carried and your dashboard shows. Match the refund to the charge on this. | | `transaction_id` | string \| null | The card processor's reference for the refunded charge, **not** the `vp_tx_*` settlement id. It correlates events with each other; see the [webhook event reference](../integration/webhook-events.md#chargesucceeded). | | `refund_id` | string \| null | Identifies which refund in a multi-partial sequence (`vpr_*`) | | `amount` | number | ⚠️ **Meaning varies by processor connection — do not sum it across refunds.** On some it is *this* refund; on others it is the *running total* refunded so far. It is **not** the original charge total. | | `refund_amount` | number \| null | **This refund only**, unambiguously, on every connection — use this one when you need a per-refund figure. `null` means the connection could not derive it. | | `currency` | string | ISO-4217, uppercase | | `reason` | string \| null | One of `customer_request`, `duplicate`, `fraudulent`, `other` | | `is_partial` | boolean \| null | True when `amount < original_charge_amount` | | `original_charge_amount` | number \| null | Full charge total before refund — compute remaining refundable balance from this | | `session_id`, `payment_intent_id` | string \| null | Source ids; null on flows where they don't apply | The subscription's `apiVersion` (pinned at create time) governs the delivered payload shape; the event envelope itself carries no per-event version field. See [Webhook events](../integration/webhook-events.md) for every event's payload and [Signature verification](../integration/webhook-verification.md) for the `x-vonpay-signature` contract. ## Rate Limits | Endpoint | Limit | |----------|-------| | `POST /v1/sessions` | 30/min per API key; per IP, 10/min with a publishable key or 300/min with a secret key | | `GET /v1/sessions/:id` | 30/min per IP | | `POST /api/checkout/init`, `/api/checkout/complete` | 20/min per IP | | `POST /api/webhooks/*` (inbound provider) | 100/min per IP | | `GET /v1/webhook_subscriptions`, `/v1/webhook_subscriptions/:id`, `/v1/webhook_events/:id` | 100/min per API key | | `POST`/`PATCH`/`DELETE /v1/webhook_subscriptions/*` (create / update / delete / rotate / test) | 30/min per API key | See [Rate Limits](../reference/rate-limits.md) for the full bucket list. Rate-limited responses return `429` with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. ## Error Format All errors return JSON with a flat envelope. Every error also carries a `selfHeal` object — machine-readable retry guidance for SDKs and agents: ```json { "error": "Human-readable error message", "code": "validation_invalid_amount", "fix": "Amount must be a positive integer in minor units (cents). 1499 = $14.99", "docs": "https://docs.vonpay.com/integration/create-session#amount-format", "selfHeal": { "retryable": false, "nextAction": "fix_request", "llmHint": "Machine-readable guidance for SDKs and agents." } } ``` Every response includes an `X-Request-Id` header for debugging. See [Error Codes](../reference/error-codes.md) for the full envelope and the `selfHeal` contract. ## OpenAPI Spec The full API specification is available at [`checkout.vonpay.com/openapi.yaml`](https://checkout.vonpay.com/openapi.yaml). Import it into Postman, Insomnia, or any OpenAPI-compatible tool. --- ## Vora.js changelog Source: https://docs.vonpay.com/sdks/vora-changelog # Vora.js changelog What changed in each release of the Von Payments browser SDK (`vora.js`). - **`https://js.vonpay.com/v1/vora.js` updates automatically.** New releases roll out in stages, so this page is where to see what changed. - **A pinned URL (`https://js.vonpay.com/v1.37.0/vora.js`) never changes.** Pin a version if you need to control when you move. - This file is served at `https://js.vonpay.com/v1/CHANGELOG.md` and on docs.vonpay.com. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Releases before 1.30.0 are not listed here. ## 1.38.1 — 2026-10-05 1.38.1 is the first release of the 1.38.0 changes below. 1.38.0 was never rolled out on `https://js.vonpay.com/v1/vora.js` (it was only available at its pinned URL), so 1.38.1 includes everything listed under 1.38.0 plus these two fixes: when the auto-updating URL moves from 1.37.0 to 1.38.1, you get both. If you pinned `v1.38.0`, move to `v1.38.1`. ### Fixed - When the bank declines a card on its 3-D Secure page and the checkout re-opens the session for another card, the buyer's next card is now charged. Previously the first submit after the buyer came back was answered with the earlier decline, the new card was never sent to the bank, and `collection.submit()` returned `frame_payment_declined` with `retryable: false`. Nothing changes for a payment with no earlier decline. - The deprecated `card.tokenize()` now reports a hold (a session created with `captureMethod: "manual"`) the way `collection.submit()` does: `chargeStatus: "succeeded"`, `charged: false` and `paymentIntentId`, with no token. Previously a card saved with a hold could come back as a plain saved card. Capture or void the hold with your secret key, and before you do, read the payment (`GET /v1/payment_intents/{id}`) and check its `metadata.session_id` is this session. ## 1.38.0 — 2026-10-05 ### Added - The embedded card form (`embed`) now places a hold when the session was created with `captureMethod: "manual"`, instead of taking the payment. The buyer's money is authorised, not taken; capture or void it later with `POST /v1/payment_intents/{id}/capture` or `/void`. Any other `captureMethod` takes the payment, as before. - `collection.submit()` reports a hold as `chargeStatus: "succeeded"` with `charged: false` and `paymentIntentId` (the hold to capture). That id comes through the shopper's browser: before you capture, void or fulfil, read the payment with your secret key (`GET /v1/payment_intents/{id}`) and check its `metadata.session_id` is this session. If the result of the hold did not arrive in time it reports `chargeStatus: "pending"` with `charged: false`: do not charge again; read the session with your secret key (`GET /v1/sessions/{id}`): `paymentStatus: "held"` and its `paymentIntentId` name the hold. A hold is never reported as `charged: true`. - On a hold, `collection.submit()` never returns a card token, even when the buyer saved their card: the card is saved on our side, and you list it with your secret key (`GET /v1/payment_methods?buyer_id=…`). (A token's normal next step is a charge, and charging it on top of a hold would bill the buyer twice.) If the buyer asked to save the card and it was not saved, `setupForFutureUse` is `false`: the card cannot be charged again. If the checkout reports that the payment was captured rather than held, `submit()` returns `charged: true`. - One payment per embedded checkout. After the embedded card form places a payment (charged, held, or pending), a second `collection.submit()` on the same checkout is refused before anything is sent, with `frame_session_not_ready` and `retryable: false`: it would be a second payment on the buyer's card. Start a new session for another payment. Sessions created with `integrationMode: "elements"` are not affected. ### Changed - A declined payment's `retryable` is now always set. It is `true` only when the checkout re-opened the session for another card (the card form has already been reset — let the buyer submit again). Otherwise it is `false`: the checkout session is finished, so create a new session for the buyer to pay again. On `false` the wallet buttons in the same collection are taken down and a resubmit is refused with nothing sent. Previously a decline with no retry instruction left `retryable` unset and the wallet button up, and a lost response that turned out not to be charged said "ask the buyer to try again" on a session that could not take it. - When saving a card is declined and the checkout allows another attempt on the same session, the card form is reset and `submit()` returns `frame_payment_declined` with `retryable: true`; the buyer can enter another card without a new session. Otherwise the decline stays `retryable: false`. ### Fixed - On the embedded card form, `collection.submit()` no longer waits forever when the form refuses the card (for example, an invalid card number). It now rejects at once with `frame_field_validation_failed` and nothing is sent. If the form goes silent for any other reason, `submit()` stops waiting after the 3-D Secure challenge timeout (5 minutes at least) and checks the payment with the server: a payment that went through is reported as a success, a hold as a hold, one still in progress as `chargeStatus: "pending"`, and one that did not complete as `frame_3ds_challenge_timeout` (nothing was charged; start a new session). - The embedded card form now reports the card's real last 4 digits and brand. Previously a card paid without saving could come back as `last4: "0000"` and `brand: "unknown"`. - When the card fields save a card instead of charging it and the issuer refuses the card, `submit()` now returns `frame_payment_declined` with `retryable: false`. It previously returned `frame_tokenization_failed` saying the response was malformed. Any other unexpected status on a card save returns `frame_tokenization_failed` naming that status. ## 1.37.0 — 2026-10-02 ### Added - `OptionsOptionalElementType`, exported as a type: the element types whose options argument `collection.create()` lets you leave out. ### Changed - No Apple Pay / Google Pay buttons for CLF, IQD, ISK, LYD, MGA, UYI and UYW. For these currencies the standards disagree on how many decimal places an amount has, so a wallet sheet could show the buyer a total 10x or 100x off what is charged. The express checkout element draws no buttons and its `change` event reports `frame_wallet_unavailable` with a message naming the currency — the same code as "no wallet on this device", so show the card form. Card payments in these currencies are unchanged. If `collection.fetchUpdates()` moves the total to one of these currencies, the buttons are taken down (closing an open sheet) with the same reason, and a wallet payment that still arrives is refused with nothing sent. - `setupForFutureUse` on a successful `tokenize()` can now be `null`. It means you asked to save the card for later and the request was refused, because the session did not identify the buyer (`buyerId`, `buyerEmail` or `buyer` at session create). The payment stands, but the card is saved for this payment only and cannot be charged again. Omitted still means "not asked for". A card charged when the buyer submits reports the same refusal the same way. - `collection.submit()` keeps `setupForFutureUse` as `true` / `false`: a refused save arrives as `false` (present, never omitted, never `true`), meaning the card cannot be charged again. To save cards for later, identify the buyer on the session. ### Fixed - Apple Pay / Google Pay sheets could show amounts in BHD, JOD, KWD, OMR and TND 10x too large (1.234 KWD appeared as 12.34). They now use three decimal places everywhere the sheet shows money: the total, shipping-option prices and the total after a shipping change. A trailing zero is dropped (1.230 shows as 1.23); the amount is never rounded. If a wallet does not accept a three-decimal amount, its sheet does not open and nothing is charged. What is charged was always correct — only the displayed amount was wrong. - `collection.create(type)` with no options argument no longer crashes with a `TypeError`. The default placeholder and style apply, exactly as for `{}`. `address` still needs `{ mode: "billing" }`; leaving it out now fails with `frame_field_validation_failed` naming `mode`. - A card saved on the session reported the save you asked for rather than the one that was stored, so a card saved for one payment only could appear reusable. It now reports what was stored. ## 1.36.0 — 2026-09-28 ### Added - `collection.fetchUpdates()`: after you change the session's amount on your server (a coupon, a shipping choice), call it and wait for it before the buyer pays. It resolves with the new `{ amount, currency }`. The card form and the express checkout button then pay exactly that total, and a payment is refused — nothing charged, `frame_wallet_total_mismatch` — if the total changes again without another `fetchUpdates()`. Opt-in; nothing changes until you call it. - Apple Pay / Google Pay declines now carry `retryable` and `attemptsRemaining` on `frame_payment_declined`, the same as card declines. Card and wallet attempts share one count. Takes effect once the checkout enables retries for your account. - New error `frame_sandbox_account_required`: a test key was used on a live account, or on a sandbox account with no payment provider. Retrying cannot fix it — use a sandbox account's test keys. Previously this surfaced as `frame_session_not_ready`. ### Changed - An express checkout payment is refused, with nothing charged, if the total changes while the Apple Pay / Google Pay sheet is open or while your `onConfirm` runs. - A collection takes one payment at a time: a wallet payment while a card `submit()` is in progress (or the reverse) is refused with `frame_session_not_ready` before anything is sent. - Express checkout shipping option `id` values must be printable ASCII (and at most 64 characters). Other values are refused at mount with `frame_field_validation_failed` instead of failing after the buyer approves. ### Removed - The built-in test simulator, which the checkout retired on 2026-09-24. `/v1.36.0/adapters/sandbox.js` does not exist (older pinned versions still serve theirs), and `/v1/sandbox-iframe.html` and `/v1/sandbox-iframe.js` are no longer served. Nothing in 1.36.0 loads them. Test payments use a sandbox account connected to a payment provider, with that provider's test cards. No change to how card fields are styled. ## 1.35.0 — 2026-09-24 ### Added - Express checkout (`elements.create("express-checkout", …)`) can now collect buyer details and handle shipping inside the Apple Pay / Google Pay sheet. All opt-in: - `collect`: the buyer's email, name, phone, billing and/or shipping address. - `shippingOptions` with `onShippingAddressChange` / `onShippingOptionChange`: shipping choices and an updated total in the sheet. - `onConfirm`: runs after the buyer approves and before any money moves. Answer `{ ok: false }` (or throw, or take longer than 15 seconds) and nothing is charged. - New error codes `frame_wallet_total_mismatch` and `frame_wallet_confirm_failed` (nothing charged in either case). - Contact fields and shipping options need wallet buttons the SDK draws itself (custom elements-mode sessions); elsewhere they are refused at mount with `frame_unsupported_element`. `onConfirm` works everywhere. ### Fixed - Card payments routed through Stripe that need a 3-D Secure check now reach the buyer's bank. The SDK had refused Stripe's challenge page, so the payment expired unpaid. - A double-tap or a re-tap on a wallet button is reported once, and a re-tap during 3-D Secure reports `pending` instead of a decline. Dedupe on `paymentIntentId` on your server as well. - A slow wallet charge could close the sheet as failed and then succeed. The sheet now closes without claiming an outcome, and the SDK checks the result with the checkout. ## 1.34.0 — 2026-09-23 ### Added - A declined card can be retried on the same form. `frame_payment_declined` now carries `retryable` and `attemptsRemaining`, and when a retry is allowed the card form resets itself for another card. Takes effect once the checkout enables retries for your account; until then a decline behaves as before. - `SessionsResource`, `FieldsResource`, `ElementsResource`, `WalletResource` and `ElementCollectResult` are exported as types. ### Fixed - If the card form could not be rebuilt after a reset, the decline is reported as `retryable: false` and asks the buyer to reload. ## 1.33.1 — 2026-09-16 ### Fixed - The error for an unusable 3-D Secure challenge URL now lists every reason the URL can be refused, instead of naming one cause it had not checked. - `ChallengeAction` is exported as a type. ## 1.33.0 — 2026-09-16 ### Added - Browser telemetry records when `redirectToChallenge()` sends a buyer to their bank, including on live keys, so a 3-D Secure drop-off can be diagnosed. ## 1.32.0 — 2026-09-16 ### Added - `vora.redirectToChallenge()`: when your server creates a payment intent and it comes back `requires_action`, pass the challenge URL to this method to send the buyer to their bank. After the bank, the buyer lands on the `returnUrl` you gave when creating the payment intent (server SDK 2.5.0 or later). An untrusted or unusable URL throws `frame_3ds_required` and navigates nobody. ## 1.31.0 — 2026-09-15 ### Changed - The card form appears sooner: the SDK opens the connection to the card-field host as soon as it is known, before loading the rest of the form. No change to what is sent or charged. ## 1.30.0 — 2026-09-12 ### Fixed - A charge that is authorized and held (`captureMethod: "manual"`) now reaches `submit()` as `chargeStatus: "succeeded"` with `charged: false`. Before, `charged` was missing, which could read as paid. Do not fulfil until your server captures. - A held Apple Pay / Google Pay payment is reported as a success with a `paymentIntentId` (use it to capture or void). Before, it was reported as a failure. - `pending` and `requires_action` results now carry `charged: false`. --- ## /sdks/vora-hosted Source: https://docs.vonpay.com/sdks/vora-hosted # vora-hosted.js — Hosted Checkout Drop-in A lightweight drop-in script for creating checkout sessions and redirecting buyers directly from the browser. No backend required. This is **not an npm package**. It is a script served from the Von Payments CDN that you include via a ` ``` This makes the `VonPay` object available globally. :::note Renamed from `vonpay.js` This script was previously served at `https://checkout.vonpay.com/vonpay.js`. That legacy URL keeps serving its original script through a deprecation window, so existing SRI-pinned integrations keep working unchanged — no rush to migrate. New integrations should use `https://js.vonpay.com/v1/vora-hosted.js`. Only the script URL changed; the global object stays `VonPay` and every method below is identical. ::: ## Key requirements `vora-hosted.js` **requires a publishable key** (`vp_pk_test_*` or `vp_pk_live_*`). It rejects secret keys (`vp_sk_*`) and legacy keys (`vp_key_*`) with a thrown error, because either would expose full API credentials in the browser. Create a publishable key at `/dashboard/developers/api-keys`. ## VonPay.configure(options) Call once before any other method: ```javascript VonPay.configure({ apiKey: "vp_pk_live_xxx", // publishable key only baseUrl: "https://checkout.vonpay.com", // optional, this is the default }); ``` The `baseUrl` is the API host that creates sessions and serves the hosted checkout page — it stays `checkout.vonpay.com` and is unrelated to the script URL. ## VonPay.checkout(options) Creates a session and immediately redirects the buyer to the checkout page. ```javascript VonPay.checkout({ amount: 1499, currency: "USD", successUrl: "https://mystore.com/confirm", cancelUrl: "https://mystore.com/cart", lineItems: [ { name: "Premium Widget", quantity: 1, unitAmount: 1499 }, ], metadata: { orderId: "order_123" }, }); ``` ### Options | Option | Type | Required | Description | |--------|------|----------|-------------| | `amount` | number | Yes | Amount in minor units (cents) | | `currency` | string | Yes | ISO 4217 (`USD`, `EUR`) | | `country` | string | No | ISO 3166-1 alpha-2 (e.g. `"US"`), defaults to `"US"` | | `successUrl` | string | No | Redirect after success | | `cancelUrl` | string | No | Redirect on cancel | | `lineItems` | array | No | Order items | | `metadata` | object | No | Key-value pairs | > The hosted drop-in forwards only the options above. To set server-only fields such as `mode`, `description`, `locale`, or `expiresIn`, create the session on your server with [`POST /v1/sessions`](../integration/create-session.md) and redirect to the returned `checkoutUrl` instead. **Naming a buyer happens on your server, not here.** `buyerId`, `buyerEmail`, `buyerName` and the nested `buyer` object are secret-key only on `POST /v1/sessions`, and this script is publishable-key only by design: `configure()` refuses a secret key outright. So create the session on your server with [`POST /v1/sessions`](../integration/create-session.md#buyer-identification) and redirect the buyer to the `checkoutUrl` it returns. That is also what makes saved cards work, since a card vaults under the buyer on the session. ## VonPay.button(selector, options) Attach checkout to a button click: ```html ``` The button is automatically disabled and dimmed while the session is being created. On error, it's re-enabled and the `onError` callback fires. ## Full Example ```html My Store

Premium Widget — $14.99

``` ## Security Note `vora-hosted.js` requires a publishable key (`vp_pk_*`). A publishable key cannot act on your merchant API: it cannot retrieve full session details, issue refunds, create payment intents, or take any other server-authorized action — those return `auth_key_type_forbidden` (HTTP 403). That is what makes it safe to ship in browser code. In this integration the key is used exactly once — to create the session. The browser then navigates to the hosted checkout page, which runs on our origin and never presents your key at all. Its reach is wider in **Embedded Fields**, though, so do not carry this page's mental model over: there a publishable key is the required credential for the browser-facing `/v1/public/*` routes, and some of those move money. See [API Key Types](../reference/api-keys.md#secret-vs-publishable--when-to-use-which) for every route it unlocks and what each one does. Secret keys (`vp_sk_*`) must never appear in frontend code; `vora-hosted.js` will refuse to run with one. ## Browser Support Works in all modern browsers (Chrome, Firefox, Safari, Edge). No dependencies, ~2KB. --- ## /troubleshooting Source: https://docs.vonpay.com/troubleshooting # Troubleshooting When the SDK throws or an API call returns a non-2xx, the response carries a structured `code`. **Branch on the `code`, not just the HTTP status** — `session_expired` (410, create a new session) and `session_already_completed` (409, the buyer already paid or is being charged right now; a new session re-charges them) need opposite fixes. This page is the diagnosis for the codes you will hit most often: likely causes ranked, the check that settles it, and when to escalate. The complete catalogue is on [Error codes](reference/error-codes.md). **Next action** on each entry is the value the API returns in `selfHeal.nextAction`. ## Start here ### Quick reference {#quick-reference} | Code | HTTP | Next action | Retryable | |---|---|---|---| | [`auth_invalid_key`](#auth_invalid_key--http-401) | 401 | `rotate_key` | no | | [`auth_invalid_key_publishable`](#auth_invalid_key_publishable--http-401) | 401 | `fix_request` | no | | [`auth_key_expired`](#auth_key_expired--http-401) | 401 | `rotate_key` | no | | [`auth_merchant_inactive`](#auth_merchant_inactive--http-401) | 401 | `contact_support` | no | | [`webhook_invalid_signature`](#webhook_invalid_signature--http-401) | 401 | `fix_request` | no | | [`merchant_not_configured`](#merchant_not_configured--http-422) | 422 | `complete_onboarding` | no | | [`validation_invalid_amount`](#validation_invalid_amount--http-400) | 400 | `fix_request` | no | | [`validation_error` / `_missing_field`](#validation_error--validation_missing_field--http-400) | 400 | `fix_request` | no | | [`rate_limit_exceeded` / `_per_key`](#rate_limit_exceeded--rate_limit_exceeded_per_key--http-429) | 429 | `wait_and_retry` | yes | | [`provider_unavailable`](#provider_unavailable--http-502) | 502 | `wait_and_retry` | only with the **same `Idempotency-Key`** — see below | | [`provider_charge_failed`](#provider_charge_failed--http-402) | 402 | `no_action` | no | | [`session_expired`](#session_expired--http-410) | 410 | `create_new_session` | no | | [`session_already_completed`](#session_already_completed--http-409) | 409 | `no_action` | no | `merchant_not_onboarded` (403) is returned by **live-key creation** (not the checkout API) — see [Onboarding & configuration](#merchant_not_onboarded--http-403). The connected-store order codes (`mirror_*`, `order_*`, `upsell_duplicate`) have their own table: see **Connected-platform orders** below. --- ## Recover from a failed charge {#recover-from-a-failed-charge} A charge can fail at three surfaces. Identify which one you're holding, then follow the flow. | Surface | What you're holding | Branch on | |---|---|---| | **Client** (Embedded Fields SDK) | `result.error` (a `VoraMirrorError`) from `submit()` | `error.code` | | **Server** (API) | a non-2xx response with a `code` | `error.code` | | **Webhook** (async) | a `charge.failed` / `payment_intent.failed` event | `data.failure_code` | ``` A failed charge ├─ Client SDK (result.error) │ ├─ frame_3ds_challenge_failed → challenge rejected/failed → re-run submit() │ ├─ frame_3ds_challenge_timeout → buyer ran out of time → re-run submit() │ ├─ frame_3ds_challenge_cancelled → buyer closed the challenge → abandoned, not failed; let them retry │ ├─ frame_tokenization_failed → card rejected before charge → ask for a different card │ ├─ frame_charge_in_progress → a charge is ALREADY in flight → ⛔ do NOT retry, │ │ do NOT open a new session; wait for the first to return │ ├─ frame_consent_requires_buyer → never throws; the buyer PAID and the card was │ │ silently not saved → fix the integration │ ├─ frame_buyer_required_for_saved_card → threw; NOTHING was charged → do NOT fulfil, │ │ create a new session with buyerId │ └─ frame_payment_declined → issuer declined → surface the decline, offer another method ├─ Server API (non-2xx code) │ ├─ provider_charge_failed (402) → buyer decline → surface, do NOT retry the same card │ ├─ validation_* / unsupported_media_type→ fix the request, then retry │ ├─ mirror_* / order_* (connected store) → refused BEFORE the charge → no money moved │ ├─ charge_in_progress (409) → ⛔ do NOT retry, do NOT open a new session, │ │ and do NOT mint a fresh Idempotency-Key — a new key │ │ for the same payment is what double-charges. Read the │ │ original's status instead (session retrieve, or the │ │ payment by id). Two scopes, one code: session-keyed on │ │ the hosted surface; key- or saved-card-keyed on /v1/payment_intents │ ├─ buyer_required_for_saved_card (422) → nothing charged; retry can never work → new session + buyerId │ ├─ idempotency_key_required (400) → a refund OR a charge was refused for carrying no │ │ Idempotency-Key (any charge after the 28 Oct 2026 │ │ cut-off; some routes already) → ADD a stable key. │ │ Adding one is the fix; VARYING one is the double-charge │ ├─ provider_unavailable (502) → the provider WAS called and the outcome is unknown. │ │ on a CHARGE: retry only with the SAME Idempotency-Key. │ │ Keyless, a retry can charge the buyer twice — reconcile. │ │ ⛔ on a REFUND: state UNKNOWN — the reversal may have gone │ │ through. Retrying pays the buyer twice; reconcile instead │ └─ auth_* / merchant_* → key / config issue → see the per-code recipes below └─ Webhook (data.failure_code) └─ branch per the Decline reasons table → retry / different card / surface to buyer ``` Two things the tree cannot say in a line: [`blocked_by_rule`](reference/error-codes.md#blocked_by_rule) is **not** an issuer decline — a rule on your own account refused the charge and no bank saw it, so offer another method but do not send the buyer to their bank; and a transient error on a charge is retried **only with the same `Idempotency-Key`** — the SDKs withhold their own auto-retry on money calls unless you supplied one. Per-`failure_code` guidance is in [Decline reasons](reference/error-codes.md#decline-reasons-failure_code). --- # Auth & key errors ## Keys and access errors The request never reached your account. Nothing was charged by any code in this group. ### `auth_invalid_key` — HTTP 401 {#auth_invalid_key--http-401} **What it means:** The API key is malformed or does not exist in our auth registry. **Next action:** `rotate_key`  ·  **Retryable:** no **Likely causes (ranked):** 1. **Env var unset or misnamed.** Check `VON_PAY_SECRET_KEY` in your environment. The SDK looks here by default. 2. **Key has rotated past its grace window** (`1h` / `24h` / `7d`, whichever was chosen at rotation). A previously-valid key was rotated and the grace window expired. The old key is permanently dead. 3. **The key was issued by a different deployment.** The key is valid where it was created, but you're calling a host whose registry has never seen it. Note this is *not* a mode problem — `checkout.vonpay.com` serves both `_test_` and `_live_` keys; the prefix picks the mode, not the host. See [Which environment am I talking to?](guides/sandbox.md#which-environment). **Diagnose with:** ```bash # Confirm the API + your key reach us vonpay checkout health --json # Check the key's age + grace state in the dashboard open https://app.vonpay.com/dashboard/developers/api-keys ``` **Escalate when:** the dashboard shows the key as **Active**, its mode matches the URL you're hitting, and you still get `auth_invalid_key`. That's an auth-service issue — open a ticket with the `X-Request-Id`. --- ### `auth_invalid_key_publishable` — HTTP 401 {#auth_invalid_key_publishable--http-401} **What it means:** The publishable key your **browser** presented is malformed, revoked, or not present in the registry of the host it called. **Next action:** `fix_request`  ·  **Retryable:** no The API says `fix_request`, not `rotate_key`, because for the most common cause — a host mismatch — rotating discards a working key and costs a redeploy. Rule out cause 1 first. **The tell:** your server calls succeed while **every** browser call fails, and the key shows no recorded use on the host you expected; a bad or revoked key fails on both sides. **Likely causes (ranked):** 1. **Your browser and server are talking to different hosts.** `apiBaseUrl` on `new Vora({ … })` defaults to the production host and is **not** inferred from your key. If your server created the session somewhere else, the browser presents a key that host never issued. See [Which environment am I talking to?](guides/sandbox.md#which-environment). 2. **Copy-paste truncation.** Publishable keys are long; a clipped tail fails the registry lookup. Confirm the value that reached the browser, not the value in your `.env` — a build-time variable that was never wired through arrives as `undefined`. 3. **Key revoked from the dashboard.** Check it still shows **Active**. 4. **A secret key was passed instead.** That throws a `TypeError` in the constructor rather than reaching this error — but it's worth ruling out if you never see a network call at all. **Diagnose with:** 1. **Find the host your browser actually used.** Open DevTools → **Network**, filter to the failing request, and read its domain. That is the host the SDK used — whether you set `apiBaseUrl` or inherited the default. 2. **Confirm the key reached the browser intact.** Add this next to your `new Vora({ … })` call (not in the console — bundler variables aren't readable there): ```javascript // Logs length + a safe prefix. A publishable key is safe to log; a secret key is not. console.log("key:", publishableKey?.length, publishableKey?.slice(0, 12)); console.log("host:", apiBaseUrl ?? "(unset — using the production default)"); ``` 3. **Confirm your server names that same host** when it creates the session. If step 1 and step 3 disagree, set `apiBaseUrl` to match — and don't rotate the key. **Escalate when:** the key shows **Active** in the dashboard, your browser and server provably name the same host, and the full untruncated key still fails. Open a ticket with the `X-Request-Id` from the failing browser response. --- ### `auth_key_expired` — HTTP 401 {#auth_key_expired--http-401} **What it means:** A key was rotated and the previous key has passed its grace window — `1h`, `24h` or `7d`, whichever was requested at rotation (24h when unspecified). **Next action:** `rotate_key`  ·  **Retryable:** no **Likely causes:** 1. **A deploy missed the rotation.** A service is still configured with the old key. Find the deploy and update it. 2. **Multiple rotations within 24h** — when you rotate while a previous grace is still active, the oldest key deactivates immediately. If you rotated twice within 24h, the very first key is already dead. **Diagnose with:** ```bash # Check rotation badges in the dashboard open https://app.vonpay.com/dashboard/developers/api-keys # Find services still using the old key grep -rn "vp_sk_" --include="*.env*" . ``` **Escalate when:** All of your services are on the active key and you still get `auth_key_expired`. --- ### `auth_merchant_inactive` — HTTP 401 {#auth_merchant_inactive--http-401} **What it means:** The merchant account is disabled or suspended. **Next action:** `contact_support`  ·  **Retryable:** no **Likely causes:** 1. **Account suspension**, by Von Payments or by the merchant. 2. **A sandbox account that is still pending approval hitting live.** Test keys are scoped to sandbox merchants regardless of mode. 3. **The application was declined, or the account deleted.** **Diagnose with:** Check the merchant's status at [app.vonpay.com/dashboard](https://app.vonpay.com/dashboard) (if you have access). Merchant status is set by Von Payments, not something you fix in code. **Escalate when:** Always escalate on this code unless it's a brand-new sandbox account waiting for the auto-activation grace. --- ### `webhook_invalid_signature` — HTTP 401 {#webhook_invalid_signature--http-401} **What it means:** The HMAC signature on a webhook does not match what we computed. **Next action:** `fix_request`  ·  **Retryable:** no (don't retry; fix the verifier) **Likely causes (ranked):** 1. **Wrong secret.** Each webhook endpoint has its own `whsec_*` signing secret, minted when you registered the endpoint at `/dashboard/developers/webhooks`. Confirm the secret in your handler env matches the endpoint your URL was registered against — not a different endpoint's secret, and not your API key. See [Webhook Signing Secrets](integration/webhook-secrets.md). 2. **Body was JSON-parsed before HMAC.** You must hash the **raw bytes** of the request body, not the re-stringified JSON. Different JSON serializers normalize whitespace differently and produce different signatures. 3. **Timestamp outside the replay window.** Reject if more than 5 min in the past or 30 sec in the future. Check your server clock against NTP. 4. **A signing secret was rotated and your handler has not picked up the new value.** Deliveries you answered with a 4xx in that window are not retried — see [Rotating a signing secret](integration/webhook-secrets.md#3-rotate). **Diagnose with:** ```typescript // Node — log what's reaching your verifier const rawBody = await req.text(); // NOT req.json() console.log("body length:", rawBody.length); console.log("signature header:", req.headers.get("x-vonpay-signature")); // the timestamp is the t= field INSIDE x-vonpay-signature — there is no separate header console.log("body first 80 chars:", rawBody.slice(0, 80)); ``` ```python # Python (Flask/FastAPI) — same shape raw_body = request.get_data() # NOT request.get_json() print(f"body length: {len(raw_body)}, sig: {request.headers.get('X-VonPay-Signature')}") ``` **Escalate when:** You're computing the HMAC correctly (verified against our [reference implementations](integration/webhook-verification.md#code-examples) byte-for-byte), the secret is the right key, the timestamp is fresh, and verification still fails. --- # Onboarding & configuration ## Account and onboarding errors The key is valid but the account behind it cannot take this payment yet. ### `merchant_not_configured` — HTTP 422 {#merchant_not_configured--http-422} **What it means:** The merchant is missing required configuration — no payment provider is bound, or the gateway routing is incomplete. **Next action:** `complete_onboarding`  ·  **Retryable:** no **Likely causes:** 1. **Sandbox merchant with no payment provider attached** — sandbox activation did not complete. 2. **Live merchant whose payment provider configuration was removed.** **Diagnose with:** This is a **merchant**-side action, not an integrator code fix — the merchant must attach a payment provider in the dashboard (onboarding). If you're the integrator and not the merchant, surface a "your account isn't finished setting up payments" message and capture the `X-Request-Id`. **Escalate when:** Onboarding shows complete in the dashboard but the API still returns `merchant_not_configured`. Contact support with your `X-Request-Id`. --- ### `merchant_not_onboarded` — HTTP 403 {#merchant_not_onboarded--http-403} > **Scope:** this code comes from **live-key creation** (the merchant/onboarding surface), not the checkout API. You'll see it when trying to mint live keys before the merchant is approved — not on `POST /v1/sessions`. **What it means:** Live keys are gated behind merchant application approval. The merchant hasn't completed KYC + contract review. **Next action:** `contact_support`  ·  **Retryable:** no **Likely causes:** 1. **Trying to create live keys before onboarding completes.** 2. **A live API call with a merchant still in `pending_approval`** (you'll usually get `auth_merchant_inactive` on the checkout API for this — `merchant_not_onboarded` is the key-creation gate). **Diagnose with:** Look at `app.vonpay.com/dashboard` — the banner names the missing onboarding step. **Escalate when:** Onboarding is complete but live keys are still gated. --- # Validation errors ## Request and rate-limit errors The request was refused before it reached a payment provider, so nothing was charged. ### `validation_invalid_amount` — HTTP 400 {#validation_invalid_amount--http-400} **What it means:** The `amount` field is not a positive integer (in minor units), or it exceeds the maximum. **Next action:** `fix_request`  ·  **Retryable:** no (fix the input) **Likely causes (ranked):** 1. **Sending major units instead of minor units.** `14.99` for $14.99 is wrong; it must be `1499`. Float-rounding errors compound. 2. **Zero or negative.** `amount` must be `>= 1`. 3. **Above the ceiling.** The maximum is **`99,999,999`** minor units (e.g. `$999,999.99` for USD). 4. **Wrong type.** `amount` must be a JSON **number**, not a string. Decimals are rejected. 5. **Currency-exponent mismatch.** JPY has no minor units (`1499` = ¥1,499). KWD has 3 (`1499000` = KWD 1,499.000). **Diagnose with:** ```javascript // Confirm you're sending a positive integer in minor units console.log("amount type:", typeof params.amount, "value:", params.amount); // MUST be a number, 1..99_999_999. $14.99 → 1499; ¥1499 → 1499; KWD 1.499 → 1499 ``` **Escalate when:** Never. This is always a code fix on the integrator side. --- ### `validation_error` / `validation_missing_field` — HTTP 400 {#validation_error--validation_missing_field--http-400} **What it means:** Request body failed schema validation. **Next action:** `fix_request`  ·  **Retryable:** no **Likely causes:** Missing required fields, wrong types, malformed strings (non-ISO-4217 currency, non-ISO-3166 country, etc.). **Diagnose with:** The error message names the failing field. For example: `"Expected number, received string at \"amount\""` — the fix is to coerce that field to a number. **Escalate when:** Never. Always a code fix. --- # Rate limits ### `rate_limit_exceeded` / `rate_limit_exceeded_per_key` — HTTP 429 {#rate_limit_exceeded--rate_limit_exceeded_per_key--http-429} **What it means:** You exceeded a rate limit. Two distinct codes share the 429: - **`rate_limit_exceeded`**: a route's own limit, per IP on most routes. With a secret key, the payment endpoints (`/v1/payment_intents`, `/v1/refunds`, `/v1/tokens`, `/v1/buyers`, `/v1/payment_methods`) share **300 / minute per key**, plus a 600 / minute per-IP ceiling; without one they get 100 / minute per IP. On `POST /v1/sessions` the **10 / minute / IP** limit applies only to calls with a publishable key or no key; a secret-key call (`vp_sk_*`, or a legacy `vp_key_*`) gets a loose 300 / minute / IP ceiling instead. - **`rate_limit_exceeded_per_key`**: only on `POST /v1/sessions`, **30 / minute / key**. The full per-endpoint ceilings are in the [rate-limit table](reference/rate-limits.md#buckets). **Next action:** `wait_and_retry`  ·  **Retryable:** yes **Likely causes:** 1. **Burst from a single deployment**, usually a retry loop without backoff. 2. **Missing or wrong `Idempotency-Key` causing duplicate creates** that each count against the limit. 3. **Session creates from a shared address** (serverless or edge workers) sent with a publishable key, so many callers share one 10/min per-IP quota. Create sessions from your server with your `vp_sk_*` key. **Diagnose with:** Read the `Retry-After` header (1 to 60 seconds) and wait that long, then retry with jittered backoff; don't retry sooner. The SDK auto-retries with backoff; if you're seeing this surfaced, retries are exhausted. **Escalate when:** Your legitimate volume needs a higher per-key ceiling. Don't work around it by rotating keys (it creates more problems). Contact support with your projected volume. --- # Provider & charge outcomes ## Charge and session errors These reach the payment provider or the session, so **whether money moved depends on the code** — read it before you retry anything. ### `provider_unavailable` — HTTP 502 {#provider_unavailable--http-502} **What it means:** The upstream payment provider is not responding. Transient. **Next action:** `wait_and_retry`  ·  **Retryable:** `true` — the API returns the same value here whether the call was a charge or a refund. On a charge, act on it. On a refund, do not: :::danger On a refund this is "state unknown", not "it didn't happen" On `POST /v1/refunds` a `provider_unavailable` (or a timeout) does **not** tell you the reversal was refused — it may already have gone through. **Retrying it can pay the buyer twice.** [`GET /v1/refunds`](reference/refunds.md#list-refunds) lists the refunds that were recorded, but an ambiguous refund may not appear there, and it does not reach you as a [`refund.failed`](integration/webhook-events.md#refundfailed) webhook. Record it as **unresolved** in your own ledger and settle it with support rather than re-issuing it. Full remedy: [`provider_unavailable`](reference/error-codes.md#provider_unavailable). ::: **Likely causes:** Upstream provider incident or transient connectivity issue. **Diagnose with:** Capture the `X-Request-Id`. Retry with exponential backoff starting at ~3 seconds (the SDK auto-retries on 502). If it still fails after 2–3 retries, contact support with that ID. **Escalate when:** Persistent for >10 minutes across multiple sessions while the upstream provider's status is green. --- ### `provider_charge_failed` — HTTP 402 {#provider_charge_failed--http-402} **What it means:** The card was declined or the charge was rejected by the issuer/provider. This is a buyer-side outcome, **not** an integration bug. **Next action:** `no_action` (terminal, expected)  ·  **Retryable:** no **Likely causes:** Insufficient funds, card blocked, fraud-prevention rejection by the issuer. **Diagnose with:** Surface the decline to the buyer and offer another payment method. Do **not** retry the same card. **Escalate when:** Never on this code — it's the issuer's call. If **every** transaction fails, that's a config issue ([`merchant_not_configured`](#merchant_not_configured--http-422)), not a per-charge decline. --- # Session & embedded checkout ### Session gone — two opposite outcomes (branch on the `code`) {#http-410--two-opposite-outcomes-branch-on-the-code} `410 session_expired` and `409 session_already_completed` need **opposite** remediations. Read the `code`, never just the status. ### `session_expired` — HTTP 410 **What it means:** The session passed its TTL, **or** it ended with no successful charge (`failed` / `cancelled`). **Next action:** `create_new_session`  ·  **Retryable:** no **Likely causes:** The session sat unpaid past its TTL (configurable at create — **default 30 minutes, range 5 minutes to 7 days**), or the buyer's payment failed/was cancelled. **Diagnose with:** Create a fresh session via `sessions.create()` with the original parameters. Sessions cannot be extended. **Escalate when:** Never. ### `session_already_completed` — HTTP 409 **What it means:** A completion call landed on a session that is **already finished, or still finishing**. Either it reached `succeeded` and the buyer was charged exactly once (on a `captureMethod: "manual"` session, authorised exactly once), or a charge for it is in flight right now and your call lost the race. The code does not say which, and an in-flight charge can still decline. **Next action:** `no_action` (already done)  ·  **Retryable:** no **Likely causes:** A duplicate or late call: a retry, a double-submit, or a buyer reloading the page after paying, arriving either after the payment went through or while it is still running. **Diagnose with:** Do **not** retry and do **not** create a new session, since either risks re-charging the buyer. **Read the outcome** from your `payment_intent.succeeded` / `charge.succeeded` / `charge.failed` webhook and show the buyer whatever it reports, which may be a decline. Do not mark the order paid on the strength of this code alone, and do not settle it by polling `GET /v1/public/sessions/:id`: while a charge is in flight that endpoint answers `200` with no charge-status field, so finished and unfinished look identical from the browser. On a [`captureMethod: "manual"`](integration/create-session.md#capture-method) session, read the payment intent rather than waiting for a webhook. Mind which object you are reading: on a payment intent `authorized` means the money is held and yours to capture, while `succeeded` means it has already moved. If your UI showed the buyer a generic error here, surface the outcome state instead. **Escalate when:** The buyer was charged but no `charge.*` webhook and no `succeeded` record arrives after a few minutes (then it's a recording issue, not this). --- ## Something looks wrong, but you got no error No failure was reported, and the outcome is still not what you expected. ### Buyer charged twice on an embedded checkout {#buyer-charged-twice-on-an-embedded-checkout} **What it means:** A buyer was charged two times for a single embedded checkout submit. **Next action:** `fix_request` (remove the duplicate charge call)  ·  **Retryable:** no **Likely cause:** Your account uses the charge-and-save flow — the embedded checkout (Vora / Embedded Fields) **charges on submit** and, with a buyer on the session, also vaults a reusable `vp_pmt_*` in one step. If your integration then *also* calls `POST /v1/payment_intents` with the result, the buyer is charged a second time. **Diagnose with:** Inspect the submit result. On the charge-and-save flow a successful submit resolves to `{ token }` (buyer attached) or `{ charged: true }` (guest / no buyer) — it has **already** moved money. Any subsequent `POST /v1/payment_intents` for that same session is a double-charge. ```typescript const result = await collection.submit(); if (result.error) { // handle the error } else if (result.token) { // a reusable vp_pmt_* was vaulted AND the buyer was charged — do NOT charge again } else if (result.charged) { // the buyer was charged (guest path) — do NOT charge again } ``` **Fix:** Remove the `POST /v1/payment_intents` call for that session — the embed already charged. Confirm settlement via the webhook before fulfilling; the client result is a UX signal, not a settlement guarantee. **Escalate when:** Never. This is an integration fix. --- ### Submit succeeded but `result.token` is undefined {#submit-succeeded-but-resulttoken-is-undefined} **What it means:** An embedded checkout submit resolved without an error, but `result.token` is `undefined`, so there's nothing to vault. **Next action:** `fix_request`  ·  **Retryable:** no **Likely cause:** one of two, and they need opposite fixes. 1. **No buyer on the session** (charge-and-save). The embed charges once and saves nothing — the result is `{ charged: true }`, not `{ token }`. There is no reusable `vp_pmt_*` to read. 2. **The card is still being authorised** (charge-at-submit). A `requires_action` result carries no token — the card is vaulted when the charge completes, and every 3-D Secure card lands here because the challenge finishes after `submit()` returns; `pending` may not carry one yet. Read the card back from the payment intent on `charge.succeeded`. **Diagnose with:** read `chargeStatus` before concluding anything from a missing token. On a charge-at-submit session `requires_action` and `pending` match neither `token` nor `charged`, so a three-way branch drops them silently: ```typescript const result = await collection.submit(); if (result.error) { // tokenization / charge failed } else if (result.chargeStatus === "requires_action") { // Not charged yet, so not vaulted yet. Redirect to result.redirectUrl; // read the saved card back after settlement (see the fix below). } else if (result.chargeStatus === "pending") { // Authorised, settling asynchronously — the token arrives after settlement. } else if (result.token) { // buyer attached, charge completed — a reusable vp_pmt_* was vaulted } else if (result.charged) { // guest path — charged once, nothing vaulted; result.token is undefined by design } ``` **Fix:** depends which cause you're in. - **Cause 1 (guest):** attach a buyer to the session so the submit returns `{ token }`. - **Cause 2 (not settled yet):** the token is not lost — attaching a buyer changes nothing, because you already have one. Once the payment reaches a settled state, read `payment_method_id` from [`GET /v1/payment_intents/{id}`](/integration/payment-intents) using `result.paymentIntentId`. That is the same saved-card handle the charge response would have carried. In neither case is the missing token `frame_tokenization_failed` — the charge did not fail. **Escalate when:** Never. This is an integration fix. --- # Connected-platform orders These codes come from `POST /v1/sessions` and `POST /v1/payment_intents` when the request describes a connected-store order — with `order` / `mirrorTo`, or with a raw `mirror` block — so the charge also creates the matching order in your store. The block itself is documented in [Connected Platforms](connected-platforms/index.md). > **Every code in this section refused *this* request before the charge ran**, including the one `503`. This call authorized nothing and captured nothing, so do not reconcile it as a maybe-charge. > > One carve-out: `upsell_duplicate` means an **earlier** charge already went through and its line was already added to the store order. There money did move and the order does exist. Look up the payment named in `original_payment_intent_id` and treat it as the outcome. For every other code here, there is no payment to find. Three codes have a single fix and no recipe below: - **`mirror_dual_specification`** (hosted checkout) - you sent the store block both as the top-level `mirror` object and as a text value inside `metadata`. Keep the top-level object and delete the one in `metadata`. Putting the block in `metadata` is not a working alternative: values there are text, and a text block is never read, so the payment succeeds and no store order is ever created. - **`mirror_alias_conflict`** (direct charge) - you sent a block in both places and the two **disagree**. Identical copies are accepted; only a genuine difference is refused, because picking one silently could attach the wrong order. Send one. - **`mirror_sandbox_email_not_reserved`** - the store block must use a reserved test address (`@example.com`, `@example.org`, `@example.net`, or an address ending `.test` / `.invalid` / `.localhost`). **Switching to a live-mode key does not lift this.** The rule fires when *either* the account is a sandbox/playground account **or** the request used a test-mode key — so a sandbox account is caught in both key modes. Only a live account charging on a live provider is exempt. If you're on a sandbox account, use a reserved address rather than hunting for a different cause. Two of these checks do **not** run on a raw `mirror` block with `destination: "shopify"` sent to `POST /v1/payment_intents`: the store-authorization check and the store-permission check are skipped on that one path. An unconnected store or a missing permission surfaces there as a successful charge with no store order, rather than as an error. The hosted flow checks both up front. | Code | HTTP | Next action | Retryable | |---|---|---|---| | [`mirror_shop_not_authorized`](#mirror_shop_not_authorized--http-400) | 400 | `fix_request` | no | | [`mirror_shop_missing_scopes`](#mirror_shop_missing_scopes--http-422) | 422 | `fix_request` | no | | [`mirror_too_large`](#mirror_too_large--http-400) | 400 | `fix_request` | no | | [`mirror_malformed`](#mirror_malformed--http-400) | 400 | `fix_request` | no | | [`mirror_upsell_unsupported`](#mirror_upsell_unsupported--http-400) | 400 | `fix_request` | no | | [`mirror_line_missing_product_ref`](#mirror_line_missing_product_ref--http-400) | 400 | `fix_request` | no | | [`mirror_voucher_disabled`](#mirror_voucher_disabled--http-400) | 400 | `fix_request` | no | | [`mirror_voucher_rejected`](#mirror_voucher_rejected--http-400) | 400 | `fix_request` | no | | [`mirror_voucher_amount_mismatch`](#mirror_voucher_amount_mismatch--http-400) | 400 | `fix_request` | no | | [`mirror_voucher_unverifiable`](#mirror_voucher_unverifiable--http-503) | **503** | `retry` | **yes** | | [`mirror_shopify_shipping_unsupported`](#mirror_shopify_shipping_unsupported--http-400) | 400 | `fix_request` | no | | [`mirror_shopify_voucher_unsupported`](#mirror_shopify_voucher_unsupported--http-400) | 400 | `fix_request` | no | | [`order_total_mismatch`](#order_total_mismatch--http-400) | 400 | `fix_request` | no | | [`order_mirror_conflict`](#order_mirror_conflict--http-400) | 400 | `fix_request` | no | | [`order_model_disabled`](#order_model_disabled--http-400) | 400 | `contact_support` | no | | [`upsell_duplicate`](#upsell_duplicate--http-409) | 409 | `no_action` | no | | `mirror_dual_specification` | 400 | `fix_request` | no | | `mirror_alias_conflict` | 400 | `fix_request` | no | | `mirror_sandbox_email_not_reserved` | 400 | `fix_request` | no | **No error, but no order either?** On most accounts the store order is created after the charge settles, so a clean `2xx` isn't proof it landed. (On an account using **order-before-charge** the order is created *first* — see [`created` does not mean paid](connected-platforms/index.md#mirror-failure-codes) before acting on a `created` there.) Poll `GET /v1/public/sessions/{sessionId}/mirror-order` (hosted checkout) or `GET /v1/payment_intents/{id}/mirror-order` (direct charge). The status is one of `created`, `pending`, `failed`, or `not_mirrored`. `not_mirrored` is terminal, so stop polling, but it does **not** prove no order exists. It covers three different situations: no store block was attached; the mirror was recorded but never sent (store disconnected, access revoked, mirroring turned off); or an order **was** created and later cancelled in your store. `order_number` is empty in all three, so the response alone cannot tell them apart. Check the store before you reconcile or refund. In particular, do not issue a second refund on the strength of this status. Note the two routes differ on one case: a **direct charge** carrying no store block at all reports `pending`, not `not_mirrored`, so cap your poll attempts rather than waiting for a terminal value that will not arrive. ## Connected store errors These only fire when you are mirroring orders into a connected store. Every one of them is refused **before** the charge, so the buyer is never charged when you see one. ### `mirror_shop_not_authorized` — HTTP 400 {#mirror_shop_not_authorized--http-400} **What it means:** The store you named is not a connected, active store on this merchant, so the order can't be mirrored to it. A merchant cannot mirror orders into a store they don't own. **Next action:** `fix_request`  ·  **Retryable:** no **Likely causes (ranked):** 1. **The store was never connected.** Connect-first is required: the store has to be in the merchant's active list before any mirror can reference it. 2. **Wrong host or a typo.** The value must be the exact store host: `acme.myshopify.com` (Shopify) or `acme.29next.store` (Next Commerce). 3. **The connection was revoked.** An uninstall or a disconnect leaves the store inactive. 4. **The store name is right but the connection is not the one you think.** Re-check which store the account is actually connected to. **Diagnose with:** Compare the `mirror.shop` value you sent against the stores listed in the Vora dashboard under Connected Platforms. Connect that exact host, then retry. Both platforms support several stores on one account; each charge routes to the store you name. **Escalate when:** The dashboard lists the store as connected and active, the host matches character for character, and the call still fails. --- ### `mirror_shop_missing_scopes` — HTTP 422 {#mirror_shop_missing_scopes--http-422} **What it means:** The connected Shopify store named in the request is missing a permission the order mirror needs, so the store cannot create the order. We refuse *before* charging the buyer rather than charge and then fail to mirror. **Next action:** `fix_request`  ·  **Retryable:** no **Likely causes:** 1. **A permission was declined or reduced** during install or re-install. **Diagnose with:** The permissions we're missing are listed in `missing_scopes` on the response. Re-connect the store in the Vora dashboard under Connected Platforms and grant `write_orders` (to create the order) and `write_customers` (to attach the buyer). This is a store-side re-grant, **not** a request-field change. Editing the request body will not clear it. **Escalate when:** You re-granted both permissions, the connection reads as healthy, and the call still returns this code. --- ### `mirror_too_large` — HTTP 400 {#mirror_too_large--http-400} **What it means:** The serialized `mirror` block exceeds the 4096-byte envelope cap. **Next action:** `fix_request`  ·  **Retryable:** no **Likely causes (ranked):** 1. **An address was packed into `customer`.** Only `email` / `first_name` / `last_name` belong there. 2. **Free-text descriptions on line items.** Move long copy into `metadata`. 3. **Store-side objects pasted in wholesale**, carrying fields the block doesn't need. **Diagnose with:** ```javascript // Measure the block before you send it console.log("mirror bytes:", JSON.stringify(mirror).length); // must be <= 4096 ``` Trim `line_items` titles, drop the optional `customer.first_name` / `customer.last_name`, or move the bulk into `metadata`. The cap is enforced on hosted `POST /v1/sessions` for every destination and on `order` / `mirrorTo` on both routes; only a **raw** Shopify `mirror` block on `POST /v1/payment_intents` is not size-checked — keep that one under 4096 bytes yourself. **Escalate when:** Never. This is always a code fix. --- ### `mirror_malformed` — HTTP 400 {#mirror_malformed--http-400} **What it means:** Two different causes, both on the direct-charge flow, both refused before the charge so the buyer is never charged for an un-mirrorable request. **(1)** `metadata.mirror` arrived as a **non-object** (a string or a number), which cannot be a mirror block. **(2)** The block contains the reserved key `_gdpr_redacted` somewhere inside it. **Next action:** `fix_request`  ·  **Retryable:** no **Likely causes:** 1. **A client-side serialization bug** (cause 1, and by far the more common). The mirror object was `String()`-coerced (which yields the literal `"[object Object]"`), template-interpolated, or `JSON.stringify`'d into a string. 2. **A reserved key** (cause 2). `_gdpr_redacted` marks an order whose buyer data was erased on a privacy request. It is written only by the erasure process and may never be supplied by a caller, so any request carrying it is refused regardless of its value, at the top level or nested anywhere in `customer`, `metadata`, an address, or a line item. **Diagnose with:** ```javascript // Cause 1: must log "object", never "string" console.log(typeof body.metadata.mirror); ``` For cause 1, send `metadata.mirror` as a nested JSON **object** (`{ destination, shop, customer, line_items }`). For cause 2, the error message names the exact path where the key was found. Rename or remove it there; if you mirror your own privacy state into the block, carry it under a different key name. Don't audit your serialization for cause 2: the block shape is fine, the key name is the problem. **Escalate when:** Never. This is always a code fix. --- ### `mirror_upsell_unsupported` — HTTP 400 {#mirror_upsell_unsupported--http-400} **What it means:** A Shopify mirror carried an upsell append (`parent_order_ref`). Shopify mirrors can only **create** a new order, never append a line to an existing one, so this would charge the buyer and never reach the store. Refused before the charge. Next Commerce supports append; Shopify does not. **Next action:** `fix_request`  ·  **Retryable:** no **Diagnose with:** Remove `parent_order_ref` / `upsell_key` and mirror the item as its own new order, or route the upsell to a connector that supports append (Next Commerce). > **If you branch on the error `code`, do not match on `mirror_upsell_unsupported` alone.** This exact code is only returned for a **raw** `mirror` block on `POST /v1/payment_intents`. The same combination sent as a raw block on hosted `POST /v1/sessions` is refused with the same `400` for the same reason but carries the generic code `validation_error`. The message is what identifies it, so a hosted caller will never match the specific code. **Escalate when:** Never. This is a request-shape fix. --- ### `mirror_line_missing_product_ref` — HTTP 400 {#mirror_line_missing_product_ref--http-400} **What it means:** A Next Commerce mirror has a line whose catalog id is absent or is not a positive integer. Next Commerce requires the store's **variant id** on every line; Shopify mirrors don't. Validated before the charge on both `POST /v1/sessions` and `POST /v1/payment_intents`, so the buyer is never charged for a line that can't be mapped. **Next action:** `fix_request`  ·  **Retryable:** no **Diagnose with:** The offending index is in `line_index` on the response. Set the catalog id on **every** line: - **Raw block:** `mirror.line_items[].external_product_ref`, the variant id as a positive-integer string (e.g. `"76"`). - **Order model:** `order.lineItems[].variantId`, the same id. > **`sku` is not a fallback on Next Commerce.** A line carrying only `sku` keeps failing this check no matter how valid the id looks, because a sku can resolve to a different product and would attach the wrong item after the buyer is charged. Only the variant id counts there. (Shopify accepts either field, and doesn't require one at all.) **Escalate when:** Never. This is a request fix. --- ### `mirror_voucher_disabled` — HTTP 400 **What it means:** Store-priced vouchers are not available on this call. On `POST /v1/sessions` that is always the case — a hosted charge bills the amount declared at session-create and never re-checks a discounted total; on `POST /v1/payment_intents` it means the platform has store-priced vouchers switched off. (A Shopify voucher on the hosted route fails schema validation first and returns `validation_error`.) **Next action:** `fix_request`  ·  **Retryable:** no **Diagnose with:** On the hosted route, move the charge to `POST /v1/payment_intents` and send the voucher there; on the direct charge, the feature is off. Either way `mirror.coupon` records the code as a display-only label, or send full-price lines, let the store's own promotions engine price the order, and charge the total it returns. Do **not** work around it by removing the voucher and charging the discounted amount anyway — the mirrored order is created at full price and permanently disagrees with the money taken. **Escalate when:** It comes back on `POST /v1/payment_intents` — that is the platform switch, not your request. ### `mirror_voucher_rejected` — HTTP 400 **What it means:** The connected store answered and **refused the code**: it's unknown, expired, or not applicable to these items. This is a verdict, not a hiccup: re-sending the identical request returns the same answer until the store's own promotion changes. **Next action:** `fix_request`  ·  **Retryable:** no **Diagnose with:** Treat it as an invalid discount code and say so to the buyer. Confirm in the connected store that the code exists, is active, and applies to these products, then retry. To complete the sale now, drop `mirror.voucher` and charge the undiscounted total. Do **not** retry on a timer; a backoff loop here just replays the same refusal. **Escalate when:** The code is active in the store and applies to those products, and it's still refused. ### `mirror_voucher_amount_mismatch` — HTTP 400 **What it means:** The store priced the basket with the code applied and its total does **not** equal the `amount` you're charging. The store owns the discount arithmetic (it excludes shipping from the discount base and applies its own rounding), so a discount computed on your side will not reliably match. **Next action:** `fix_request`  ·  **Retryable:** no **Likely causes (ranked):** 1. **Pre-discounted line prices.** The usual cause: you subtracted the discount yourself, then the store subtracted it again. 2. **Local rounding** that doesn't match the store's. 3. **Shipping folded into the discount base**, which the store excludes. **Diagnose with:** Re-quote and resend. Call `POST /v1/mirror/quote` with the same lines and the same code, then charge `item_total` plus the shipping you will send, or supply `shipping_amount` on the quote and charge the `amount_to_charge` it returns. Send **full-price** `line_items` and let the store subtract. **Escalate when:** The quote total and the amount you charge agree and it still fails. ### `mirror_voucher_unverifiable` — HTTP 503 **What it means:** We could not get an answer from the connected store about the discounted total: the store was unreachable, throttled, timed out, or cannot price codes. This is **not** a verdict on your code or your amount, and it is **not** an ambiguous charge: the check fails closed and the request was refused before any money moved — nothing to void or refund. **Next action:** `retry`  ·  **Retryable:** **yes** **Diagnose with:** The one voucher error where the identical request can succeed unchanged: retry with backoff, same body, same `Idempotency-Key`. Do **not** get past it by dropping the voucher and charging the discounted amount. **Escalate when:** It persists across retries. That points at the store connection, not at your request. --- ### `mirror_shopify_shipping_unsupported` — HTTP 400 {#mirror_shopify_shipping_unsupported--http-400} **What it means:** You sent a shipping amount on a **Shopify** mirror. Shopify does not carry that amount onto the created order, so the store's total would be short by exactly the shipping. Refused before the charge. This code is the raw block's on `POST /v1/payment_intents`; on `POST /v1/sessions` and on `order` / `mirrorTo` the same refusal returns `validation_error`. **Next action:** `fix_request`  ·  **Retryable:** no **Diagnose with:** Send shipping as a **line item** instead — a line whose price is the shipping cost: ```json "order": { "lineItems": [ { "name": "Trail Runner - Size 10", "quantity": 1, "unitAmount": 3499, "sku": "TR-10" }, { "name": "Standard shipping", "quantity": 1, "unitAmount": 599 } ]} ``` The lines then add up to the charge `amount`, and the store order totals what you took. **Escalate when:** Never. This is a request-shape fix. --- ### `mirror_shopify_voucher_unsupported` — HTTP 400 {#mirror_shopify_voucher_unsupported--http-400} **What it means:** You sent a store-priced `voucher` on a **Shopify** mirror. Shopify does not price the code, so the order would be created at full price while the buyer is charged the discounted amount. Refused before the charge. This code is the raw block's on `POST /v1/payment_intents`; on `POST /v1/sessions` the same refusal returns `validation_error`, and `order` / `mirrorTo` has no voucher field at all (`validation_unknown_field`). **Next action:** `fix_request`  ·  **Retryable:** no **Diagnose with:** Use `mirror.coupon` to record the code as a display-only label without changing any total, or charge the full line-item amount. Store-priced vouchers are Next Commerce only. **Escalate when:** Never. This is a request-shape fix. --- ### `order_total_mismatch` — HTTP 400 {#order_total_mismatch--http-400} **What it means:** You sent the order model (`order` + `mirrorTo`) and the order doesn't add up to the amount you're charging. In minor units, the sum of `order.lineItems[].unitAmount` × `quantity` — plus `order.shipping.amount` on Next Commerce, where that field is accepted — must equal the top-level `amount`. On Shopify shipping is a line item, so the lines alone carry the whole total. A `coupon` is display-only and **excluded**. **Next action:** `fix_request`  ·  **Retryable:** no **Likely causes (ranked):** 1. **A coupon subtracted locally.** A coupon records a code for reference and never reduces the charge, so don't take it off `amount`. 2. **Shipping charged but not declared** in `order.shipping.amount` (or declared but not charged). 3. **A per-unit price or rounding error** on one line. **Diagnose with:** The response carries `derived_amount` (what the order adds up to) and `charge_amount` (what you sent). Fix whichever is wrong, then retry. **Escalate when:** Never. This is always a code fix. --- ### `order_mirror_conflict` — HTTP 400 {#order_mirror_conflict--http-400} **What it means:** The request carries **both** the `order` / `mirrorTo` order model **and** a raw `mirror` block (in `mirror` or `metadata.mirror`). They describe the same store order two ways — the order model compiles into exactly that raw block internally — so accepting both would mean silently picking one and attaching a possibly-wrong order to the charge. **Next action:** `fix_request`  ·  **Retryable:** no **Diagnose with:** Send one. Keep `order` / `mirrorTo` and drop the raw block, or keep the raw block and drop `order` / `mirrorTo`. Usually one of the two is a leftover from an earlier integration path, or a shared request builder is adding a block the caller already set. **Escalate when:** Never. This is a request-shape fix. --- ### `order_model_disabled` — HTTP 400 {#order_model_disabled--http-400} **What it means:** This deployment is not serving the `order` / `mirrorTo` order model. It's refused rather than ignored, because ignoring it would charge the buyer with **no** store order created. This request charged nothing. **Next action:** `contact_support`  ·  **Retryable:** no **Diagnose with:** Confirm you are calling the environment you expect — a sandbox or regional endpoint can differ from the one your integration was built against. If you are on the right endpoint, contact support. :::warning Do not reshape a retry to get past this error If this request reused an `Idempotency-Key` you have already sent, **do not** rewrite the body into a raw `mirror` block to work around the refusal. Reshaping strips the fields that produced the error, so the retry sails past this gate and reaches the charge carrying the same key — which is how one order becomes two charges. Retrieve the original outcome with `GET /v1/payment_intents/{id}` instead, or re-send the **identical** request. Sending the identical body with the same key is always safe and returns the original result. ::: **Escalate when:** This code *is* the escalation. Ask support to enable the order model. --- ### `upsell_duplicate` — HTTP 409 {#upsell_duplicate--http-409} **What it means:** The `upsell_key` you sent already has a charge against this store, so this request is a re-fire of an upsell decision that already succeeded. Classically a timeout-then-retry where the first attempt actually landed. It fires before any charge, so **this** call did not charge the buyer. **Next action:** `no_action`  ·  **Retryable:** no **Diagnose with:** Treat the original payment as the outcome. Its id is in `original_payment_intent_id` on the response; read its status with `GET /v1/payment_intents/{id}`. Do **not** retry with the same key, and do **not** strip the key to force the call through: that re-creates the double-charge this guard exists to prevent. A genuinely new upsell decision needs a new unique key. **Escalate when:** The id in `original_payment_intent_id` doesn't resolve to a payment you recognize. --- ## Working with an AI agent ### For AI agents {#for-ai-agents} If you're an AI agent (Claude Code, Cursor, GitHub Copilot, ChatGPT, etc.) reading a Von Payments error and trying to fix it autonomously, branch on the `code`, then use the structured surfaces below. ### Read the error envelope directly {#option-1--read-the-error-envelope-directly} Every API error response (and every `VonPayError` thrown by `@vonpay/checkout-node`) carries: ```typescript err.code // canonical error code, e.g. "auth_invalid_key" — BRANCH ON THIS err.status // HTTP status, e.g. 401 err.fix // human-imperative remediation err.docs // canonical reference URL — this page or a sibling err.requestId // X-Request-Id for support correlation err.rateLimit // { limit, remaining, reset } on 429s ``` The raw API response body also carries a **`selfHeal`** block — `retryable`, `nextAction` (one of `retry` · `rotate_key` · `fix_request` · `wait_and_retry` · `contact_support` · `complete_onboarding` · `create_new_session` · `reconcile` · `no_action`), and `llmHint` (a 1–3 sentence diagnostic written for an LLM). The per-code **Next action** values on this page are exactly those `nextAction` values. Use `selfHeal.nextAction` to decide what to do and `selfHeal.llmHint` for the most-likely root cause. To reproduce or verify, use the [MCP tools](sdks/mcp.md) (`vonpay_checkout_diagnose_error`, `vonpay_checkout_health`, `vonpay_checkout_get_session`) or have the human run the [CLI](sdks/cli.md) (`vonpay checkout health --json`, `vonpay checkout sessions get `). Never surface a raw API key value to the human or into your context — the SDK and CLI redact all but the prefix; preserve that. Do not invent error codes that are not in the [catalog](reference/error-codes.md); an unrecognised code is `contact_support` with the `requestId`. ## Getting support ### Contacting support {#contacting-support} When the recipe above says `contact_support` or you've ruled out an integrator-side fix, open a ticket through one of the channels below. **Always include the `X-Request-Id` from the failing response** — every Von Payments error envelope carries one, and our triage flow is keyed off it. - **Status page** — [status.vonpay.com](https://status.vonpay.com). Check here first for ongoing incidents before opening a ticket. - **Email** — `support@vonpay.com` for production issues; `engineering@vonpay.com` for SDK / API / spec-level questions. - **Dashboard** — `/dashboard/support` (when signed in to [app.vonpay.com](https://app.vonpay.com)) — preferred for merchant-account issues since it auto-attaches your merchant context. What to include in the ticket: the `X-Request-Id`(s) from one or more failing responses, the session (`vp_cs_*`) or transaction (`vp_tx_*`) id, the time window, the API key prefix (`vp_sk_test_xxxx…yyyy` — never the full key), the SDK + version you're using, and a one-paragraph description of the expected vs. actual behavior. For anything about a buyer — a duplicate charge, "is this the same person?" — include the `buyerId` and `buyerEmail` you sent on the session; a stable per-user `buyerId` is what lets us link a buyer's transactions ([Buyer identification](integration/create-session.md#buyer-identification)). ### Related {#related} - [Error Codes catalog](reference/error-codes.md) — the full `ErrorCode` catalog + the rate-limit table - [Connected Platforms](connected-platforms/index.md) — the `mirror` block, for the `mirror_*` codes - [Webhook Verification](integration/webhook-verification.md) — for `webhook_invalid_signature` - [API Keys](reference/api-keys.md) — for `auth_invalid_key` / `auth_key_expired` - [CLI reference](sdks/cli.md) — `vonpay checkout health` / `sessions get` for verification - [MCP reference](sdks/mcp.md) — the tools an AI agent can call ---