# 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/overview # 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://checkout.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), both at 0.11.0 - 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://checkout.vonpay.com/llms.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)** --- ## 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 three 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. | | **Third-party router** (legacy) | We hand the payment to an external orchestrator. | From your code, all three 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/charge-and-save.md) for the result-shape contract. ## What changes in your integration **Nothing.** Session creation is unchanged, session retrieval is unchanged, return-signature verification 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 a processor. VORA does that. - 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 your signed-redirect verification. The return signature is the same regardless of which processor was used. ## 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) — return-URL signature verification --- ## 3D Secure Source: https://docs.vonpay.com/embedded-fields/3ds # Embedded Fields — 3D Secure 3D Secure (3DS / SCA) authentication is the issuer-side challenge that proves the buyer is the cardholder. Embedded Fields handles 3DS uniformly across every supported card processor — the merchant sees one challenge contract regardless of which underlying provider their account is configured for. This page covers how 3DS surfaces in your code, the modal-harmonization toggle, the cancel + timeout paths, and what test cards to use. --- ## When 3DS fires 3DS triggers on the binder side, not in your code. You don't decide whether to challenge — the issuer does. From your point of view: 1. You call `elements.submit()` on the collection (`const elements = vora.elements.create(); const card = elements.create("card", {}); card.mount("#card-element"); const result = await elements.submit();`) 2. If the issuer requires 3DS, Embedded Fields intercepts the binder's challenge and renders it 3. On success, the result resolves as if 3DS had never happened — `result.token` (a reusable `vp_pmt_*`) when a buyer was on the session, or `result.charged === true` (a guest one-time charge, no token) on a charge-only session 4. On failure / cancel / timeout, the result resolves with `result.error` (a `VoraMirrorError` with a 3DS-specific code) The challenge UI runs **before** Embedded Fields resolves the result. There is no separate "wait for 3DS" callback in the merchant code. --- ## The VORA 3DS modal Different card processors render their native 3DS sheet in different ways — some use an iframe overlay, some use their own modal style, some redirect to a hosted page. To give buyers a consistent checkout experience across every supported processor, Embedded Fields wraps each provider's challenge UI in a VORA-styled modal: - **Backdrop** — semi-transparent dark layer over your checkout - **Spinner + status text** — "Authenticating with your bank…" - **Cancel button** — buyer can abandon the challenge cleanly - **Accent color** — inherits from the card element's `style.color.text` The modal renders by default. The provider's native sheet shows on top of it (overlay mode or in-modal-iframe, depending on what the active processor's adapter declares via its `threeDsMode`). ### Opting out — `disable3dsModal` If your checkout's UX requires the binder's native sheet to render directly (no VORA chrome — for full custom branding, third-party analytics hooked to the binder's challenge events, etc.), pass `disable3dsModal: true` on the card element: ```javascript const card = collection.create("card", { disable3dsModal: true, }); ``` The merchant flag wins over any per-adapter default. When `disable3dsModal: true`, the binder's native sheet shows directly with no VORA chrome. --- ## Cancel + timeout paths ### Cancel The VORA modal includes a Cancel button. If the buyer clicks Cancel during the challenge: ```javascript catch (err) { if (err.code === "frame_3ds_challenge_failed") { // err.message will mention "User cancelled" if cancel was the cause // Surface a "you cancelled — try again or use a different card" message } } ``` ### Timeout Default timeout is 5 minutes (`challengeTimeout: 300_000`); the maximum is 10 minutes (`600_000`). Override per element: ```javascript const card = collection.create("card", { challengeTimeout: 120_000, // 2 minutes }); ``` If the timeout elapses without buyer interaction: ```javascript catch (err) { if (err.code === "frame_3ds_challenge_timeout") { // Surface "the bank's authentication timed out — please try again" } } ``` The timer starts when the challenge UI mounts. Networks-side delays before the challenge appears don't count against the timeout. --- ## Two server-driven flows For most integrations, the synchronous tokenize-then-charge path is enough — call `collection.submit()`, send the `vp_pmt_*` to your server, your server creates a `payment_intent` (which charges on create), and the result tells you whether the charge succeeded. 3DS happens transparently inside `submit()`. > **Charge-and-save sessions are different.** When the session is configured to charge and save, the embed charges at submit — so for that session do **not** create a server-side `payment_intent`, or you'll double-charge. A guest (no-buyer) charge-only result carries no `vp_pmt_*` token to forward, and the SDK does not run a confirm/3DS step against an already-charged guest result. Treat the client result as a UX signal only and confirm settlement via the webhook before fulfilling. Two patterns exist for cases where your server needs more control: ### Tokenize → server creates intent → forward `client_confirm` to browser → SDK confirms The canonical end-to-end pattern is three round-trips: #### 1. Browser tokenizes the card ```javascript const result = await collection.submit(); // The result is a 3-way discriminated union — branch on all three: if (result.error) { // Failed before any charge — surface result.error.message } else if (result.token) { // A buyer was on the session → reusable vp_pmt_* (already charged on // charge-and-save; charge server-side on tokenize-only). e.g. "vp_pmt_test_..." } else if (result.charged) { // GUEST / no buyer → one-time charge, NO token. Confirm via the webhook; // do NOT forward to your server to charge again. } ``` #### 2. Browser sends the token to your server; server creates the intent ```javascript // Browser const intent = await fetch("/api/charge", { method: "POST", body: JSON.stringify({ payment_method: result.token, amount: 4999 }), }).then((r) => r.json()); ``` ```typescript // server/api/charge.ts (Node example) app.post("/api/charge", async (req, res) => { const response = await fetch(`${API}/v1/payment_intents`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${SECRET_KEY}`, // vp_sk_test_* }, body: JSON.stringify({ amount: req.body.amount, currency: "USD", payment_method: req.body.payment_method, }), }); const intent = await response.json(); // For NON-3DS cards: intent.status === "succeeded", you're done. // For 3DS cards: intent.status === "requires_action" + intent.client_confirm populated. if (intent.status === "requires_action" && intent.client_confirm) { // Forward the client_confirm block to the browser. Server must NOT // log this value or persist it beyond the request — `client_secret` // is bearer-equivalent for this single intent. return res.json({ id: intent.id, status: intent.status, action: intent.client_confirm, // forwarded as-is to the SDK }); } // Non-3DS path res.json({ id: intent.id, status: intent.status }); }); ``` #### 3. Browser passes `action` back into the SDK; the SDK renders the 3DS modal ```javascript // Browser — back in the submit handler if (intent.status === "requires_action") { const confirmed = await collection.submit({ paymentIntent: { id: intent.id, action: intent.action }, }); // confirmed.confirmationStatus === "succeeded" if 3DS passed } ``` The SDK treats `intent.action` as opaque — each binder adapter unwraps it internally and calls the appropriate confirmation method on the underlying processor. Your code never sees inside `action`. The harmonized 3DS modal renders during step 3. Buyer authenticates with their bank; result returns to the merchant page. Both `disable3dsModal: false` (default — VORA chrome) and `disable3dsModal: true` (binder native sheet) work the same way — just different UI. ### `confirmPaymentIntent` (React hook variant) For React integrations, the same flow runs through the `useVora()` hook: ```javascript const { confirmPaymentIntent } = useVora(); // 1. Tokenize (branch on the 3-way result union) const result = await collection.submit(); if (result.error) { // Failed before any charge — surface result.error.message and stop return; } if (result.charged) { // GUEST / no buyer → one-time charge, NO token. Confirm via the webhook; // there is nothing to forward to /api/charge. return; } const token = result.token; // a buyer was on the session → reusable vp_pmt_* // 2. Server creates a manual-confirm intent (same as above) const intent = await fetch("/api/charge", { method: "POST", body: JSON.stringify({ payment_method: token, amount: 4999 }), }).then((r) => r.json()); // 3. SDK handles the 3DS challenge if (intent.status === "requires_action") { const result = await confirmPaymentIntent(intent); // result.status: "succeeded" | "requires_action" | "failed" } ``` ### `handleAction` — next-action flow Your server returns a `nextAction` object directly: ```javascript const result = await handleAction(intent.nextAction); ``` Same end-state as `confirmPaymentIntent`. Use this when your server is already wrapped in a third-party SDK that produces `next_action`-shaped payloads instead of VORA's `client_confirm` block. All three flows render inside Embedded Fields' harmonized 3DS modal (or the binder's native sheet when `disable3dsModal: true`). --- ## Test cards | Card | Behavior | |---|---| | `4242 4242 4242 4242` | Tokenizes; **no** 3DS challenge | | `4000 0027 6000 3184` | 3DS required → **succeeds** — recommended for exercising the VORA 3DS modal | | `4000 0084 0000 0029` | 3DS required → **fails** (`frame_3ds_challenge_failed`; `failure_code: fraudulent`) | | `4000 0000 0000 9995` | Tokenizes; charge declines with `insufficient_funds` (no 3DS) | These are the sandbox-allowlisted cards (see [Test cards](../reference/test-cards.md)) — the embedded sandbox accepts only allowlisted numbers, so use these for modal testing rather than a generic processor 3DS card. Any future expiry, any 3-digit CVC. The `frame-react` sample's [test recipe](https://github.com/Von-Payments/vonpay/tree/master/samples/frame-react#readme) walks the modal-on / modal-off / cancel / timeout paths step by step. For binder-specific test cards, see: - See your active processor's test-card documentation (linked from your dashboard's integration details page) - [Reference → Test cards](../reference/test-cards.md) for the cross-binder matrix --- ## Error codes 3DS surfaces these `VoraMirrorErrorCode` values; full handling reference on the [errors page](errors.md): | Code | When | |---|---| | `frame_3ds_required` | The issuer requires a 3DS challenge that hasn't been completed for this submit — re-run submit (or forward the server `client_confirm`) to render the challenge | | `frame_3ds_challenge_failed` | Buyer failed the challenge (wrong code, cancelled, issuer rejected) | | `frame_3ds_challenge_timeout` | Buyer didn't complete within `challengeTimeout` | | `frame_tokenization_failed` | Binder rejected the card outright (decline before challenge fired) | | `frame_payment_declined` | The issuer declined the charge on a charge-and-save / charge-only submit | --- ## What's next - [Quickstart](quickstart.md) — end-to-end card-only happy path (no 3DS) - [React](react.md) — `useVora()` patterns including `confirmPaymentIntent` + `handleAction` - [Errors](errors.md) — full `VoraMirrorError` reference - [`samples/frame-react`](https://github.com/Von-Payments/vonpay/tree/master/samples/frame-react) — interactive 3DS modal toggle in the sample app --- ## 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 needs to verify that the domain serving the page is one you've registered with our payments network. This is a **one-time setup per domain** — once the file is hosted and verified, the Apple Pay button renders automatically for every eligible buyer on that domain. You do all of this from the **Wallet Domains** dashboard in your Vonpay account: add a domain, download its verification file, host the file on your server, and click **Verify**. No support request needed — the whole ceremony is self-serve and takes about five minutes per domain. > **Google Pay does NOT need this step.** Google Pay domains verify automatically — there's no file to host. The full Apple-Pay-domain-association ceremony below applies only to Apple Pay. --- ## Why Apple requires this Apple Pay's security model treats every checkout page as a potential phishing surface. When a buyer's device asks Apple to authorize a payment for `acmehats.com`, Apple double-checks that whoever requested this Apple Pay session actually owns `acmehats.com`. The double-check works by: 1. The payments-processor (us via our underlying network) tells Apple: "this merchant has registered `acmehats.com`." 2. Apple's verifier fetches `https://acmehats.com/.well-known/apple-developer-merchantid-domain-association` and reads its contents. 3. If the file's content matches what the processor registered, Apple flips the domain to **verified** — Apple Pay buttons start working on that domain. If Apple ever fails to fetch the file (we serve a 404, the TLS handshake fails, the file's content changes), Apple flips the domain back to **unverified** and the button stops appearing on the buyer's device. There's no email, no alert — buyers just stop seeing Apple Pay. You don't need an Apple Developer account — Vonpay handles the Apple Pay registration with Apple on your behalf. > **Google Pay's model is different.** Google Pay needs no domain-association file — its domains verify automatically when you add them in the Wallet Domains dashboard. If you're integrating Google Pay only, you can skip this whole page. --- ## When you need to set up a domain Run the setup for every distinct domain where buyers will see the Embedded Fields card field. Examples: - `checkout.acmehats.com` — your live checkout subdomain - `pay.acmehats.com` — alternate buyer-facing path - `staging.acmehats.com` — your staging environment (separate Apple Pay registration; not the live one) - `mirror-test.vonpay.com` — Vora's hosted test sample (if you're using it instead of standing up your own) Subdomains count as separate domains. Wildcards aren't supported by Apple Pay — each subdomain registers independently. If you serve checkout through a third-party CDN (Cloudflare, Fastly, etc.) — Apple Pay always verifies against the domain in your buyer's browser address bar, not the CDN's domain. --- ## Step-by-step setup ### 1. Download the verification file In your Vonpay dashboard, open **Settings → Wallet Domains**. - **New domain?** Click **Add domain**, enter the hostname (e.g. `checkout.acmehats.com` — hostname only, no `https://` and no path), choose **Apple Pay**, then click **Add domain**. The domain's detail page opens automatically. - **Already listed?** Click the domain row to open its detail page. On the detail page, click **Download verification file**. Your browser saves a single binary file named exactly: ``` apple-developer-merchantid-domain-association ``` No extension. The file content is unique to your account-and-domain pair — registering the same domain under a different account produces a different file. Each domain row shows its current status — **Verified**, **Pending**, **Failed**, or **Revoked** — and flags any domain whose annual re-check is approaching as **Expiring soon**. ### 2. Host the file on your domain The file must be served at: ``` https://{your-domain}/.well-known/apple-developer-merchantid-domain-association ``` **Path is exact and case-sensitive.** Apple's verifier doesn't follow redirects to a different path or filename; doesn't accept content-encoding gzip; doesn't accept a 200 with HTML. Plain raw bytes at exactly this URL, served over HTTPS with a valid TLS cert. **Content-Type:** any (`application/octet-stream`, `text/plain`, even `application/json` all work). Apple ignores the header and reads the bytes directly. #### 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 Before asking Vora to verify the domain with Apple, check that the file is live: ```bash curl -v https://{your-domain}/.well-known/apple-developer-merchantid-domain-association ``` Expect: - HTTP 200 - Non-empty body (the file is a few KB) - Valid TLS cert (not self-signed, not expired) - No redirects If you see `301` / `302`, `404`, `403`, or TLS errors, fix those first — Apple's verifier won't follow redirects or accept errors. ### 4. Click Verify in the dashboard Back on the domain's detail page in **Settings → Wallet Domains**, click **Verify**. Vonpay triggers the verification call with Apple, and the page polls for the result on its own — the status badge updates automatically, usually within 30 seconds. No need to refresh or reload. You'll land on one of: - **✅ Verified** — Apple Pay buttons start working on the buyer's next page load. No restart needed; the SDK picks up the verification state on the next session retrieve. - **❌ Failed** — the detail page shows Apple's reason. Common ones: - `file_not_found` — the URL returned 404 / 5xx. Re-verify reachability with the curl above. - `content_mismatch` — file content doesn't match what we registered. Re-download the file from the dashboard and re-host. - `tls_error` — Apple couldn't trust your cert chain. Likely a missing intermediate cert or expired cert. If verification is still running when the dashboard stops checking, it shows a "still in progress" note and stops polling. Reopen the domain in **Settings → Wallet Domains**, or click **Verify** again, to pick up the result once Apple has responded. ### 5. Keep the file in place Apple re-verifies on a rolling annual cycle. If the file goes missing (CI nuked the `public/` directory, CDN cache purged, DNS moved to a host that doesn't serve dotfile paths), Apple flips the domain back to **unverified** silently — buttons stop appearing. Vora's daily renewal worker watches for this and re-runs the verification call with Apple when the file disappears. But Apple can only verify what you serve — if the file is gone, the renewal call still fails. **Treat the file as load-bearing infrastructure**: pin its source in version control, include it in your deploy pipeline, don't let cleanup scripts touch it. --- ## 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 returns `content_mismatch` after a redeploy | 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 Apple flips the domain to **verified**: 1. **Use a real Apple device.** Safari on macOS (with Touch ID + a paired iPhone with a card in Wallet) or iOS Safari with a card in Wallet. Chrome on Mac, Firefox, and any non-Apple device cannot render the Apple Pay button — that's an Apple platform restriction, not a VORA limit. 2. **Visit your checkout page** at the registered domain. 3. **Initialize a session** through the normal Embedded Fields integration — see [Quickstart](quickstart.md). 4. **Mount the card field.** The Apple Pay button appears inside the card mount when the buyer is eligible — see [Elements reference → Apple Pay & Google Pay](elements.md#apple-pay--google-pay--built-into-the-card-mount). 5. **Tap the button** → Apple Pay sheet opens → authenticate → `elements.submit()` resolves. Branch on the 3-way result union — never read `result.token` unconditionally: - `result.error` — submit failed before any charge. - `result.token` — a buyer was on the session, so a reusable `vp_pmt_*` was vaulted (with `wallet: "apple_pay"`); already charged on the charge-and-save flow, charge server-side on tokenize-only. - `result.charged` — a guest with no buyer on the session: a one-time charge completed and **no token** is returned (still carries `wallet: "apple_pay"`). If the button doesn't appear, check the browser console for one of: - `frame_wallet_domain_unverified` — verification didn't complete. Open the domain in **Settings → Wallet Domains** and click **Verify** again. - `frame_wallet_unavailable` — the buyer's device doesn't have a card in Apple Wallet. Add one and retry. See [Errors → Wallet errors](errors.md#wallet-errors-apple-pay--google-pay) for the full event surface. --- ## 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 - [Errors](errors.md#wallet-errors-apple-pay--google-pay) — the `frame_wallet_*` event codes - [Quickstart](quickstart.md) — end-to-end Mirror integration --- ## Advanced — binder profiles Source: https://docs.vonpay.com/embedded-fields/binder-profiles # Advanced — binder profiles :::note Most integrations don't need this page Embedded Fields runs on the **monolith-embed** binder profile by default, and the [Elements reference](elements.md) documents that path completely. Read this page only if your account's capability map reports a different profile — your integration contact will tell you, or you'll see it in the capability map returned by `vora.sessions.retrieve()`. ::: Embedded Fields exposes one developer API across more than one underlying binder. Different binders render the payment surface differently, and that difference is captured by a **binder profile**. Your account is provisioned on exactly one profile; the SDK reports it in the capability map so your code never has to name or detect a vendor. --- ## How the SDK reports it — element render modes `vora.sessions.retrieve(sessionId)` returns a capability map. Each element renders in one of three modes for the active binder: - **NATIVE** — the binder ships a UI primitive for this element and the SDK wraps it (one iframe; the binder owns internal layout; your `style` option translates to the binder's theme API). - **PROXY** — the SDK renders the DOM itself, no iframe; your CSS has full control. Identical across every binder. - **MONOLITH** — the binder's main embed iframe already contains this element's surface. There's no separate mountable slot — the element returns `null` at create time. --- ## The three binder profiles | Profile | Card-field shape | What lives in the card iframe | |---|---|---| | **Monolith-embed** *(default)* | One iframe for the entire checkout | PAN + expiry + CVC + cardholder + wallets + payment-method picker, all together | | **Direct-card** | One combined card iframe | PAN + expiry + CVC packed in one row | | **Split-field** *(coming soon)* | Multiple iframes — one per PCI input | PAN iframe + CVC iframe; expiry and cardholder render as your DOM inputs | --- ## Capability matrix | Element | Monolith-embed *(default)* | Direct-card | Split-field *(coming soon)* | |---|---|---|---| | `card` | MONOLITH (one iframe contains the entire checkout) | NATIVE (combined PAN/MM-YY/CVC iframe) | NATIVE (PAN + CVC iframes; merchant-DOM expiry + cardholder) | | `cardholder` | MONOLITH (inside the main card iframe) | PROXY (SDK-rendered input) | PROXY | | `email` | PROXY | PROXY | PROXY | | `address` (billing) | MONOLITH (inside the main iframe) | NATIVE (binder-shipped address iframe) | PROXY | | `payment-method-picker` | MONOLITH | PROXY (SDK-rendered button row) | PROXY | | `save-for-future-use` | PROXY (consent surface is always SDK-rendered for compliance) | PROXY | PROXY | | `saved-methods` | MONOLITH | NATIVE / PROXY *(coming soon)* | PROXY | > **Wallet buttons (Apple Pay / Google Pay)** render inside the `card` mount on every profile when the buyer's device and your domain are eligible — see [Elements reference → Apple Pay & Google Pay](elements.md#apple-pay--google-pay--built-into-the-card-mount). There's no separate row element to position. --- ## What each profile means in practice - **Monolith-embed** *(default)* — you mount `card` and that's effectively your whole payment surface. Its iframe contains the card fields, wallets, picker, and cardholder. `email` and `save-for-future-use` are still separate PROXY elements you can place and style freely; the other elements either return `null` or render inside the monolith. This is the path the [Elements reference](elements.md) documents. - **Direct-card** — you can mount `email`, `cardholder`, `address`, `card`, and `save-for-future-use` as five independently-placed elements, with full outer-styling control on the PROXY ones. Wallet buttons render alongside the card inputs inside the `card` mount. - **Split-field** *(coming soon)* — the most-separable surface: PCI iframes for card number + CVC only, plus PROXY elements for everything else, all under your CSS. --- ## Composing multiple elements On the **direct-card** profile, 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", fields: { line2: { hidden: false } }, }); const email = elements.create("email", {}); card.mount("#card-element"); address.mount("#address-element"); email.mount("#email-element"); // Submit collects from every element + drives binder tokenization, // returning a unified result — every field optional. Branch on all three outcomes: const result = await elements.submit(); if (result.error) { // Failed before any charge — surface result.error.message } else if (result.token) { // A buyer was on the session → reusable vp_pmt_* (already charged on // charge-and-save; charge server-side on tokenize-only). result.email, // result.cardholder, result.billingAddress, result.setupForFutureUse are // each present iff the matching element was mounted. } else if (result.charged) { // GUEST / no buyer → one-time charge, NO token. Confirm via the webhook; // never re-charge a charged result. } ``` The `elements` collection shares theme defaults across child elements. On the **monolith-embed** profile the same code is valid — but `address` and `cardholder` render inside the `card` iframe rather than at their own mount points, so mounting them separately has no visible effect. ### `SubmitResult` shape `SubmitResult` is **one flat interface — every field is optional**. There are no separate `SubmitTokenResult` / `SubmitChargedResult` types to import; you branch at runtime on which fields are populated. Check `error` first, then `token` (a buyer was on the session → reusable `vp_pmt_*`), then `charged` (guest / no-buyer one-time charge, no token): ```typescript interface SubmitResult { // Card-route fields — present when a card element was mounted and tokenize succeeded: token?: string; // vp_pmt_* — present on a saved-card submission last4?: string; brand?: string; // "visa" | "mastercard" | … charged?: true; // GUEST / no-buyer one-time charge — NO token vaulted transactionId?: string; // vp_tx_* — reconcilable ref for a guest charge amount?: number; // minor units (guest charge) currency?: string; // ISO-4217 (guest charge) wallet?: WalletType; // set when minted via Apple Pay / Google Pay // Aggregated form data — present iff the matching element was mounted: email?: string; cardholder?: string; // the cardholder name (a plain string, not an object) billingAddress?: BillingAddressValue; setupForFutureUse?: boolean; // true when the buyer opted to save (vaulted off_session) // Error path: error?: VoraMirrorError; // submit failed before any charge / tokenize } ``` The submit result is one object regardless of how many elements you mount. Because every field is optional, **never read `token` unconditionally** — branch `error` → `token` → `charged`. Optional form fields are present iff the corresponding element was mounted in the collection. --- ## `cardholder` — Cardholder name input On the **direct-card** and **split-field** profiles, `cardholder` is a separate SDK-rendered input (no iframe — cardholder name isn't PCI-restricted, and a VORA-controlled input gives better styling + native browser autofill). On the **monolith-embed** profile the cardholder name is collected inside the `card` mount and this element isn't mounted separately. ### Options ```typescript interface CardholderElementOptions { style?: VoraFieldStyle; placeholder?: string; // default "Name on card" required?: boolean; // default true } ``` ### Collect Returns `{ cardholder: string }` from the collection's collect step. Forwarded to the binder at tokenize as the cardholder billing-details name. --- ## `address` — Billing address form On the **direct-card** and **split-field** profiles, `address` is a separately-mountable billing-address form. On the **monolith-embed** profile the billing address is collected inside the `card` mount. Field set: `line1`, `line2`, `city`, `state`, `postalCode`, `country`. On the direct-card profile it renders as a binder-shipped iframe; on split-field as native HTML inputs with `autocomplete="billing street-address"` browser-autofill compatibility. ### Options ```typescript interface AddressElementOptions { style?: VoraFieldStyle; mode: "billing"; // required; billing only today ("shipping" reserved for a future release) fields?: { line1?: { required?: boolean }; // default required line2?: { hidden?: boolean }; // default visible city?: { required?: boolean }; // default required state?: { required?: boolean }; // default required postalCode?: { required?: boolean }; // default required country?: { default?: string }; // ISO-3166-1 alpha-2; default "US" }; allowedCountries?: string[]; // restrict country dropdown (ISO 3166-1 alpha-2) } ``` ### Collect ```typescript interface BillingAddressValue { line1: string; line2?: string; city: string; state: string; postalCode: string; country: string; // ISO-3166-1 alpha-2 } // emitted as { billingAddress: BillingAddressValue } ``` --- ## Styling the direct-card profile The direct-card card iframe renders only the input values — no internal labels — so a floating-label pattern works cleanly: ```html
``` The wrapper draws the box; your absolutely-positioned label floats at the top-left; the iframe content sits in the lower padded area. On the **monolith-embed** profile the iframe already includes its own labels — skip the floating-label wrapper and let the iframe drive the visual. The inner-iframe vs outer-iframe boundary itself — what you can and can't style, and why — is the same on every profile. See [Customize → inner vs outer iframe](customize.mdx#inner-iframe-vs-outer-iframe). --- ## What's next - [Elements reference](elements.md) — the default monolith-embed path, per-element options, theming - [Quickstart](quickstart.md) — end-to-end integration walkthrough - [Error handling](errors.md) — `VoraMirrorError` codes and recovery --- ## Charge-and-save Source: https://docs.vonpay.com/embedded-fields/charge-and-save # Embedded Fields — Embedded charge-and-save On the embedded charge-and-save flow, the collection's `submit()` does the payment in one step: the embed **charges the buyer's card on submit**. When the session has a buyer attached, that same submit **also vaults a reusable `vp_pmt_*` token** so you can charge the card again later. There is no separate server-side charge step — the charge already happened inside the embed by the time the promise resolves. > Use `elements.submit()` (the collection method) here. The legacy single-field `card.tokenize()` is **deprecated** and refuses on this embedded monolith binder with `frame_method_not_supported_for_session` — the canonical path is `vora.elements.create()` + `collection.create('card')` + `collection.submit()`. This is different from the [tokenize-only model](tokenization.md), where `submit()` only mints a token and your server charges later via [`POST /v1/payment_intents`](../integration/payment-intents.md). Under charge-and-save, calling `/v1/payment_intents` for the same session **double-charges the buyer** — see the warning below. --- ## What submit does | Session state | What the embed does on submit | What the result carries | |---|---|---| | **Buyer on the session** | Charges the card **and** vaults a reusable payment method in one step | `{ token: "vp_pmt_..." }` — already charged, and the token is reusable | | **Guest / no buyer** | Charges the card once, saves nothing | `{ charged: true }` — no token | Vaulting requires a buyer. With a buyer attached to the session, submit resolves with a reusable `vp_pmt_*` token (the card was charged and saved). Without a buyer, submit resolves with `{ charged: true }` and **no** token — the card was charged once and nothing was saved for reuse. --- ## The three-way result Every `elements.submit()` call resolves to one of three shapes — a discriminated union you branch on in order: ```javascript const elements = vora.elements.create(); const card = elements.create("card", {}); card.mount("#card-element"); const result = await elements.submit(); // collection-level submit — there is no card.submit() if (result.error) { // Pre-charge failure — nothing was charged. Show the error, let the buyer retry. showError(result.error.message); } else if (result.token) { // Buyer on session: the card was charged AND a reusable vp_pmt_* was vaulted. // Do NOT charge again — the embed already charged under charge-and-save. // Store result.token for future charges; confirm settlement via webhook. } else if (result.charged) { // Guest charge-only: the card was charged once. No token, nothing vaulted. // Do NOT charge again. Confirm settlement via webhook before fulfilling. } ``` Branch on `result.error` first, then `result.token`, then `result.charged`. A guest charge resolves with `result.charged === true` and no `result.token`; a buyer charge resolves with `result.token` set (and the card is already charged). Only `result.error` means nothing was charged. > **Do NOT call `POST /v1/payment_intents` for this session.** On the charge-and-save flow the embed already charged the buyer at submit. Calling `/v1/payment_intents` for the same session charges the buyer a **second time**. The server-side `/v1/payment_intents` leg belongs to the tokenize-only flow, not this one. The vaulted `vp_pmt_*` token is for *future* charges (a later, separate session), never for re-charging the session that just created it. --- ## Confirm settlement before fulfilling The client-side `charged` / `token` result is a **UX signal only** — it tells your front-end the embed accepted the card so you can advance the buyer to a confirmation screen. It is not proof of settlement. **Confirm the charge server-side via the [webhook](../integration/webhooks.md) before you fulfill the order, grant access, or ship anything.** Treat the resolved result as "show the success state"; treat the webhook as "money is actually settled." --- ## A successful guest charge is not an error On the charge-and-save flow, a successful **guest** charge resolves as `{ charged: true }` — it is **not** `frame_tokenization_failed`. A missing `result.token` on a guest session is the expected charge-only success, not a failure. Branch on `result.charged` before treating an absent token as a problem. Reserve `frame_tokenization_failed` handling for a genuine pre-charge failure (it arrives under `result.error`). **Never re-charge after a charged result** — the card has already been charged. See [Error handling](errors.md) for the full `VoraMirrorError` union. --- ## Related - [Quickstart](quickstart.md) — end-to-end embedded integration - [Tokenization](tokenization.md) — the tokenize-only model, where your server charges later - [Elements reference](elements.md) — Card, Email, Save-for-future-use - [Using React](react.md) — `useVora()` and the imperative elements API - [Error handling](errors.md) — `VoraMirrorError` codes and recovery - [Webhooks](../integration/webhooks.md) — confirm settlement server-side --- ## 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: discrete card fields you position and style yourself — and, optionally, standalone wallet buttons — instead of the single drop-in [Embedded Fields](quickstart.md) embed that owns the whole payment surface. You opt in with **one field** when you create the session — `integrationMode: "elements"`. 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. There is no Elements-specific charge flow. :::note Availability Discrete card fields are available where your account's gateway supports the Elements integration mode. Your account's capability — and the canonical list of supported surfaces — is reported at [`GET /.well-known/vonpay.json`](../reference/discovery.md) (`embedded.supported_binders`) and in the capability map returned by `vora.sessions.retrieve()`. Where Elements isn't available, the session falls back to the standard embed — the `integrationMode` echoed back on the session tells you which surface you got — and you can always fall back to [hosted checkout](../integration/quickstart.md). ::: 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 discrete `card` element 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 gateway's field iframes (the SAQ-A boundary). You only ever hold the resulting `vp_pmt_*` token plus PCI-safe display metadata (`brand`, `last4`). --- ## Step 0 — Get your keys You need both a publishable key (`vp_pk_test_*`, for the browser) and a secret key (`vp_sk_test_*`, server-only). See [Quickstart §0](../integration/quickstart.md#step-0-get-your-test-keys). Your secret key never reaches the browser. --- ## Step 1 — Create an Elements session (server) Create the session from your backend with your **secret** key. The one field that switches a session to discrete fields is `integrationMode: "elements"`: ```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.VONPAY_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's gateway doesn't support Elements, the echoed `integrationMode` comes back `"embed"` rather than erroring — check it if you need to branch your UI. --- ## Step 2 — Load vora.js and retrieve the session (browser) Load the SDK from the CDN — `@vonpay/vora-js` is **not** on npm; the ` ``` For production, pin a version with Subresource Integrity — copy the current version and hash from [`js.vonpay.com/integrity.json`](sri.md). Then construct the client and retrieve the session your server created: ```javascript const vora = new window.Vora({ publishableKey: "vp_pk_test_…", // a secret key throws a TypeError }); // Fetch the session id from your server (Step 1), then: await vora.sessions.retrieve(sessionId); // loads binder routing for this session ``` `vora.sessions.retrieve()` resolves the gateway and loads the matching field adapter for you — your browser code never names or selects a processor. --- ## Step 3 — Mount the card field Create an elements collection, create a `card` element, and mount it into a container you own. Listen for the `change` event 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 (the same `color` / `font` / `border` tokens across every gateway). 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**. It vaults the card onto the session and resolves a `vp_pmt_*` token — it does **not** charge (the charge happens server-side in Step 6, 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.token) { await chargeOnYourServer(result.token); // vp_pmt_* — send to your backend } else { // No token and no error → the session was not created with // integrationMode: "elements". Recheck Step 1. } }); ``` `SubmitResult` is one flat object (every field optional) — branch `error` → `token`. `result.token` is the reusable `vp_pmt_*`; `result.last4` / `result.brand` are display-only. --- ## 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. The `payment_method` field 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.VONPAY_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 }), }); const intent = await response.json(); res.json({ intent_id: intent.id, status: intent.status, next_action: intent.next_action ?? null }); }); ``` 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 3D Secure challenge before it settles. Resolve the intent's `next_action` (or, on gateways that support client-side confirmation, forward the `client_confirm` block back to the SDK). See **[3D Secure & SCA](3ds.md)** for the full server-driven flow. The client-side result is a **UX signal only**. Always confirm settlement server-side via the `payment_intent.succeeded` / `charge.succeeded` [webhook](../integration/webhooks.md) before fulfilling the order. --- ## 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. Separate collections keep each `submit()` scoped to its own surface. --- ## What's next - [Elements 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 - [Advanced — binder profiles](binder-profiles.md) — how discrete fields render across gateway profiles - [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. You style it with the **same `style` API** (`VoraFieldStyle`) on both [Embedded Fields](quickstart.md) and [Elements (custom)](custom-checkout.md). One API — but it lands differently per surface: How `style` lands depends on **your account's binder** — the SDK normalizes one `VoraFieldStyle` onto whichever binder backs your account: - The **current Embedded Fields binder** applies the **font + color** tokens inside the card iframe, but **`border`, `padding`, and `margin` are *not* applied to the iframe** — style those on your own outer wrapper `
` (see [inner vs outer iframe](#inner-iframe-vs-outer-iframe)). - The **discrete-field binder behind Elements** applies more of the token set — `border.width` / `border.radius` map to that binder's preset scale (e.g. `none` / `subtle` / `rounded`), or pass through as field CSS. It's the same token set, validated the same way — but **what each token actually does is binder-specific**. `vora.sessions.retrieve()` reports your account's capability map; confirm the exact result with the live render. This is the **one home for everything visual**: the interactive playground, reference themes, the complete `VoraFieldStyle` token set, and the inner-vs-outer-iframe model. (The [Elements reference](elements.md) covers per-element *options*; styling lives here.) --- ## 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. :::note The preview is the real iframe — not a mock This tool does **not** fake-render the card field. The only visual preview is the **real** iframe via *Try with live SDK*, because the field is rendered by your account's binder (which applies the `style` tokens its own way) — a faithful preview can only come from the live iframe. If the live sandbox isn't enabled in this environment (an endpoint returns `503`), the JSON editor + generated snippet still work; run the [sample app](#test-the-themes-locally) to see it rendered. **The `style` JSON and the snippet are the exact contract; trust those, and confirm the look with the live iframe or the sample app.** ::: --- ## 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. ### Default (inherits your site) ```javascript const elements = vora.elements.create(); const card = elements.create("card", { // No style override — inherits browser defaults. }); ``` ### Light + branded accent ```javascript const card = elements.create("card", { style: { font: { family: "system-ui, -apple-system, sans-serif", size: "16px", weight: "400" }, color: { text: "#1a1a1a", placeholder: "#9ca3af", error: "#dc2626" }, border: { color: "#e5e7eb", width: "1px", radius: "8px" }, background: "#ffffff", spacing: { padding: "12px" }, }, }); ``` ### Dark mode ```javascript const card = elements.create("card", { style: { font: { family: '"Inter", system-ui, sans-serif', size: "16px", weight: "500" }, color: { text: "#e5e7eb", placeholder: "#6b7280", error: "#f87171" }, border: { color: "#374151", width: "1px", radius: "12px" }, background: "#1f2937", spacing: { padding: "14px" }, }, }); ``` --- ## 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 and ignored silently where it doesn't — a neutral-by-design contract, so 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. ### 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). --- ## 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 binder renders (card number, expiry, CVC — the PCI-restricted inputs). It's served from the binder's 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, shipping, billing, summary, CTA), 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(...)` | Text / label / placeholder / error / accent / focus colors, per-layer surface backgrounds, border, font, padding, margin | | 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 binder-rendered — can't inject DOM | On the direct-card binder profile a floating-label wrapper works; see [Advanced — binder profiles](binder-profiles.md#styling-the-direct-card-profile) | --- ## Test the themes locally The fastest way to see all three themes against a real sandbox processor is the sample app: ```bash git clone https://github.com/Von-Payments/vonpay.git cd vonpay/samples/frame-react cp .env.example .env # fill in vp_sk_test_* + VITE_VONPAY_PUBLISHABLE_KEY pnpm install pnpm dev ``` Open `http://localhost:5173`, click **Create checkout session**, then edit the `style` prop in `src/App.tsx` to swap between the themes. Vite hot-reloads each change. Test card `4242 4242 4242 4242` (any future expiry / any CVC) renders the full successful tokenization path. --- ## What's next - **[Quickstart](quickstart.md)** — end-to-end card-only happy path - **[Custom checkout with Elements](custom-checkout.md)** — discrete card fields you lay out yourself - **[Elements reference](elements.md)** — per-element options + collect shapes - **[React](react.md)** — `` + `useVora()` patterns - **[3D Secure](3ds.md)** — modal harmonization + `confirmPaymentIntent` --- ## Elements reference Source: https://docs.vonpay.com/embedded-fields/elements # Embedded Fields — Elements reference Embedded Fields ships individual UI **elements** that you mount into your checkout page. Each element handles one slice of the buyer's input and exposes a uniform mount / event / collect API. On the default binder the `card` element is a single iframe that renders the entire payment surface — card fields plus, when the buyer is eligible, Apple Pay / Google Pay. Alongside it you can mount `email` and `save-for-future-use` as separate fields. This page covers what's available, the options each element accepts, and the value shapes they emit on collect. --- ## Status overview | Element type | Status | Notes | |---|---|---| | `card` | ✅ Live | The card iframe — your payment surface. Generally available. | | `email` | ✅ Live | VORA-rendered email input with validation. | | `save-for-future-use` | ✅ Live | Compliance-locked opt-in checkbox. | | `express-checkout` | ✅ Live | Standalone Apple Pay / Google Pay buttons (Elements mode). See [Standalone wallets](../guides/standalone-wallets.md). | | `payment-method-picker` | ✅ Live | 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). | > **Cardholder name and billing address** are collected inside the `card` mount on the default binder — you don't mount separate elements for them. If your account's capability map reports a non-default binder profile, where these mount as standalone fields, see [Advanced — binder profiles](binder-profiles.md). --- ## Apple Pay & Google Pay — built into the card mount 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. Placement inside the mount is decided by the binder 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; existing merchants who haven't yet completed registration fall back to the card field with no degradation in the checkout flow. **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. The two relevant error codes (`frame_wallet_unavailable` for device-side ineligibility, `frame_wallet_domain_unverified` for the domain-registration gate) are emitted on the `change` event so your analytics layer can record coverage; neither blocks the buyer or the card field. **Buyer flow when the button appears:** 1. Buyer taps the wallet button. The wallet sheet (Apple Pay sheet, Google Pay sheet) opens on the device. 2. Buyer authenticates (Face ID, Touch ID, Chrome dialog). 3. `elements.submit()` resolves with a `vp_pmt_*` token plus a `wallet: "apple_pay" | "google_pay"` discriminator on the result. The downstream `POST /v1/payment_intents` is identical to a manually-typed card — same `payment_method` field, same response shape. **3DS for wallet-derived tokens.** Wallets short-circuit issuer-side authentication via the wallet network's device attestation; a wallet token rarely requires a separate 3DS challenge. When the issuer does require one (some EU markets, high-value transactions), the same [VORA 3DS modal](3ds.md) drives the challenge — no wallet-specific code path on your side. **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.** Wallet buttons require HTTPS plus a domain registered with each wallet network. Local development on `http://localhost` shows the card field but not the wallet buttons (Apple Pay refuses non-HTTPS; Google Pay's `TEST` environment is browser-version-sensitive on `localhost`). To test the wallet buttons end-to-end, run the [`frame-react`](https://github.com/Von-Payments/vonpay/tree/master/samples/frame-react) sample on your own HTTPS dev domain (any tunnel that gives you a public `https://` URL works, e.g. an ngrok/Cloudflare-tunnel subdomain), then register that domain for wallets yourself: add it under **Settings → Wallets** in the dashboard and complete the [Apple Pay domain verification](apple-pay-setup.md) (Google Pay needs no extra step). No account-rep touch is required. --- ## 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", {}); 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 + drives the charge. const result = await elements.submit(); if (result.error !== undefined) { // Validation or charge failure — surface result.error.message } else if (result.token !== undefined) { // A reusable "vp_pmt_*" was vaulted (buyer on the session). // The card was also charged at submit — do NOT create a server-side // payment_intent for this session. Pass the token to your server only // if you need it for a later off-session charge. // result.email and result.setupForFutureUse are present iff their // elements were mounted. See Tokenization for the reusability model. } else if (result.charged) { // Guest charge-only (no buyer on the session): the card was charged at // submit and nothing was vaulted — there is no token. Do NOT create a // server-side payment_intent for this session (it would double-charge). // Confirm settlement via the webhook before fulfilling. } // The client result is a UX signal. Always confirm settlement via the // webhook before fulfilling the order. ``` 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?: true; // present on a guest charge-only submit (no token vaulted) last4?: string; // display-only brand?: string; // "visa" | "mastercard" | … (display-only) 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** (there are no separate `SubmitTokenResult` / `SubmitChargedResult` types to import). Submit charges the card and populates exactly one of three outcomes — discriminate at runtime in this order: 1. **`error`** — validation or charge failure. Surface `result.error.message`. Nothing was charged. 2. **`token`** — a buyer is on the session, so the card was charged **and** a reusable `vp_pmt_*` was vaulted in one step. 3. **`charged`** — a guest (no buyer on the session): the card was charged once and nothing was vaulted, so there is no token. `last4` / `brand` are display-only. ```typescript if (result.error) { // failed } else if (result.token) { // charged + vaulted (buyer on session) } else if (result.charged) { // guest charge-only (no token) } ``` Because submit already charged the card, do **not** also call `POST /v1/payment_intents` for the same session — that double-charges. The client result is a UX signal; confirm settlement via the webhook before fulfilling. The submit result is one object regardless of how many elements you mount. Optional fields are present iff the corresponding element was mounted in the collection. > Every token uses the `vp_pmt_*` prefix. `SubmitResult.setupForFutureUse` is a **boolean** — `true` when the buyer checked the save-for-future-use box (the token was vaulted reusable). The underlying reusability *scope* — `on_session` for in-session reuse like upsells, `off_session` for recurring / MIT, or single-use when unset — is the server-side `setup_for_future_use` wire value on the vault row. See [Tokenization](tokenization.md) for the full reusability model. ### Legacy single-field shorthand (deprecated) > **@deprecated.** `vora.fields.create("card")` + `card.tokenize()` is the original single-field path. It is **deprecated** and on this page's default **monolith-embed** binder it now **refuses** with `frame_method_not_supported_for_session`. Use the canonical collection path instead: > > ```javascript > const elements = vora.elements.create(); > const card = elements.create("card", {}); > card.mount("#card-element"); > const result = await elements.submit(); > ``` > > See [Creating elements](#creating-elements) for the full `SubmitResult` discrimination. ```javascript // Deprecated — kept for reference. Refuses on a single-embed binder with // code frame_method_not_supported_for_session. Use elements.create() above. const card = vora.fields.create("card", { style: {…} }); card.mount("#card-element"); ``` `vora.fields.create` (singular, card-only) is the original API — it returns a `VoraField`. It is **deprecated**; new integrations must use `vora.elements.create()` + `elements.submit()`. On a single-embed binder the legacy `card.tokenize()` call refuses with `frame_method_not_supported_for_session` (an integration-time wrong-method condition, not a server bug). --- ## `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 }; challengeTimeout?: number; // ms — default 300_000 (5 minutes); clamped to 600_000 (10-min) max locale?: string; // BCP-47, e.g. "en-US"; phase 2A is en-only disable3dsModal?: boolean; // opt out of the harmonized VORA 3DS modal; default false (modal on) // 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 } ``` #### 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. | 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.empty: boolean // event.error?: { type: "validation_error", message } }); card.on("focus", () => {}); card.on("blur", () => {}); ``` ### Collect — use the collection's `submit()` The canonical collect path is `elements.submit()` on the collection — see [Creating elements](#creating-elements) for the unified `SubmitResult` shape and the `error` → `token` → `charged` discrimination: ```javascript const elements = vora.elements.create(); const card = elements.create("card", {}); 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, three-outcome `SubmitResult`. > **@deprecated — `card.tokenize()` (singular `vora.fields` API).** The legacy single-field `card.tokenize()` is deprecated. On this page's default **monolith-embed** binder it now **refuses** with `frame_method_not_supported_for_session`. The recovery is: `card.tokenize()` is deprecated. Use `vora.elements.create()` + `collection.create('card')` + `collection.submit()` to charge and save. Your server configuration is fine. This is an integration-time wrong-method condition (single-embed binder), not a server bug. When a `vp_pmt_*` is vaulted it's bound to the buyer on the session that minted it. Reusability is governed by `setup_for_future_use` (`"on_session"` for upsells, `"off_session"` for recurring / MIT, `null` for single-use); see [Tokenization](tokenization.md). On charge-and-save the current charge already happened at `submit()`, so pass the token to your server only for a **later** off-session charge via [`POST /v1/payment_intents`](../integration/payment-intents.md) — never to re-charge the session that minted it. --- ## `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 }`. Used both as `billing_details.email` for the binder and as the buyer email on the session for receipt delivery. --- ## `save-for-future-use` — Opt-in checkbox VORA-rendered checkbox + label that controls the **reusability** of the token your submit returns. Renders as a standalone field on every binder profile — the consent surface is always SDK-rendered for compliance. The element is a plain consent checkbox — it has **no scope / `policy` option**. Every token is a `vp_pmt_*`; what changes is the `setup_for_future_use` field on the vault row. When the box is checked, the submit vaults the token as reusable (`setup_for_future_use: off_session`) so your server can later charge it off-session. When unchecked, the token is registered single-use (`setup_for_future_use: null`) — usable only by the originating intent. To set a scope explicitly without the checkbox, a server-side caller passes `setup_for_future_use` to [`POST /v1/tokens`](../reference/tokens.md), or the SDK's `card.tokenize({ setupForFutureUse })` shorthand forwards it. See [Tokenization — reusability](tokenization.md#reusability--setup_for_future_use) for the full model. ### Compliance lock The checkbox **starts unchecked**. There is no `defaultChecked` option. EU GDPR + several US state privacy laws require active opt-in for storing payment-method tokens for future MIT use. Pre-checked default would void the consent record downstream. This is a hard design lock — do not work around it. ### 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. Flows through to `/v1/public/tokens` registration; a checked box vaults the returned `vp_pmt_*` as `off_session` (eligible for future MIT charges by your server), unchecked as single-use. See [Tokenization — reusability](tokenization.md#reusability--setup_for_future_use) for the full model. --- ## `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 — it is the only field the picker populates today, and reading it with optional chaining keeps your code stable as the picker's nested shape grows in future releases. --- ## Where Embedded Fields lives in your page Two pieces plug into your HTML/server: ```html
``` Plus two server endpoints in your backend: - `POST /v1/sessions` — call with your secret key (`vp_sk_*`), return `session_id` to the browser. - `POST /v1/payment_intents` — call to mint the intent your browser passes to `elements.submit({ paymentIntent })`. The browser never sees your secret key. The session ID is short-lived and scoped to one buyer. --- ## 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)** --- ## Other binder profiles This page documents the **monolith-embed** binder — the default, and the profile the large majority of merchants integrate against. A small number of accounts are provisioned on a different binder profile, where elements like `cardholder` and `address` mount as separate fields and the card surface is split differently. If `vora.sessions.retrieve()` reports a profile other than monolith-embed, see [Advanced — binder profiles](binder-profiles.md) for the capability matrix and the multi-element composition model. --- ## What's next - [React wrapper](react.md) — ``, ``, `useVora()` for the React-shaped version of every element - [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. This page covers the error union, when each fires, and recommended recovery patterns. For server-side API errors (e.g. `/v1/payment_intents` 4xx responses), see [Reference → Error codes](../reference/error-codes.md). Server-side errors share the [self-healing envelope](../agents/overview.md) shape with structured `code`, `fix`, `docs`, and `selfHeal` fields; client-side `VoraMirrorError` is a separate union. > **v1.x alias note.** `FrameError` is the same class — the SDK shipped under that name through v1.1.x and the symbol is preserved as a deprecated alias through v1.x. New code should import `VoraMirrorError` / `isVoraMirrorError` / `VoraMirrorErrorCode`. Existing imports of `FrameError` keep working unchanged. `err instanceof VoraMirrorError` and `err instanceof FrameError` return identical results for any instance — they're two named bindings of one class. --- ## Detection ```javascript try { const elements = vora.elements.create(); const card = elements.create("card", {}); card.mount("#card-element"); const result = await elements.submit(); } catch (err) { if (isVoraMirrorError(err)) { // err.code is one of the VoraMirrorErrorCode union below console.error(err.code, err.message); } else { // Network errors, programming bugs (TypeError on construction, etc.) throw err; } } ``` Always use `isVoraMirrorError()` — `instanceof VoraMirrorError` can fail across module boundaries (bundle-splitting / multiple package copies). --- ## Code union ```typescript type VoraMirrorErrorCode = | "frame_insecure_context" | "frame_session_not_found" | "frame_session_expired" | "frame_session_not_ready" | "frame_binder_load_failed" | "frame_unsupported_element" | "frame_field_validation_failed" | "frame_tokenization_failed" | "frame_3ds_challenge_failed" | "frame_3ds_challenge_timeout" | "frame_3ds_required" | "frame_payment_declined" | "frame_wallet_unavailable" | "frame_wallet_domain_unverified" | "frame_wallet_rendered_by_card_element" | "frame_style_invalid" | "frame_method_not_supported_for_session" | "frame_rate_limited"; ``` The wire-level code strings (`frame_*`) are stable contract and are NOT renamed — your log shippers, alerting rules, and grep patterns over historical data keep working unchanged. The type name `VoraMirrorErrorCode` is the canonical TypeScript identifier; `FrameErrorCode` remains as a deprecated alias for v1.x callers. Each code has a distinct merchant-facing recovery path. Codes are NEVER collapsed when their recovery differs. --- ## Codes by recovery family ### Configuration / setup errors (fix the integration) | Code | When it fires | Recovery | |---|---|---| | `frame_insecure_context` | `new Vora(...)` outside HTTPS / localhost | Serve the page over HTTPS in production. `localhost` is allowed for development. | | `frame_session_not_found` | Session ID is unknown to VORA | Verify the merchant's `vp_sk_*` is for the same merchant whose session you're loading. Check test/live mode parity (publishable key prefix vs session ID prefix must match). | | `frame_unsupported_element` | `elements.create(type, ...)` called for an element the active session's binder doesn't support — or `payment-method-picker`'s methods all filter out against per-binder capabilities | The active binder declared `not_available` (or `monolith`, for elements whose surface the binder renders natively — e.g. a processor that renders its own payment-method picker as part of an all-in-one embed). Swap the element type, route the merchant to a binder that supports it, or — for picker-on-monolith — remove the element from your `elements.create()` list and rely on the binder's native UI. The error message identifies which binder and which element. | | `frame_method_not_supported_for_session` | The deprecated `card.tokenize()` single-field path was called against a single-embed binder session | "card.tokenize() is deprecated. Use vora.elements.create() + collection.create('card') + collection.submit() to charge and save. Your server configuration is fine." This is an integration-time wrong-method condition (single-embed binder) — **not** a server bug, not high-severity, and should not page. Switch to the canonical `vora.elements.create()` + `collection.create('card')` + `collection.submit()` path. | | `frame_style_invalid` | Style option contains a key outside the allowlist | The thrown error's `property` field names the offending key. Drop it or alias to a supported `VoraFieldStyle` key. | ### Transient errors (retry / refresh) | Code | When it fires | Recovery | |---|---|---| | `frame_session_expired` | The session's TTL elapsed before tokenize | Create a fresh session on your server, then `vora.sessions.retrieve(newId)`. Sessions default to 30 minutes. | | `frame_session_not_ready` | An action method (`tokenize`, `confirmPaymentIntent`, `handleAction`) was called before `ready === true` | Wait for `ready === true` before exposing your submit button. In React, gate the button render on `ready`. | | `frame_binder_load_failed` | The active processor's CDN script failed to load | Inspect the `hint` field — `network_error` / `csp_blocked` / `script_blocked` / `timeout`. Most commonly fixed by adding the processor's domain to your CSP `script-src`; see [Reference → Security](../reference/security.md). | | `frame_3ds_challenge_timeout` | Buyer didn't complete 3DS challenge within `challengeTimeout` (default 5 min / 300_000 ms) | Surface a "the bank's authentication timed out" message; let the buyer retry. | | `frame_rate_limited` | The publishable-key endpoints (`sessions.retrieve`, `binder-load`, the embed-token poll) are per-IP rate-limited (HTTP `429`) and the caller exceeded the ceiling — usually rapid reloads or a tight retry loop | **Back off** and retry after a short delay; honor the `Retry-After` header if present. Do **not** retry immediately in a loop — that deepens the throttle. Not a CSP/binder/session problem. | ### Buyer-action errors (surface a message) | Code | When it fires | Recovery | |---|---|---| | `frame_field_validation_failed` | Card number / expiry / CVC failed VORA's pre-tokenize validation | Already surfaced inline by the iframe. Disable the submit button until `change` event reports `complete: true`. | | `frame_tokenization_failed` | The adapter rejected the card **before any charge** (decline, network error at the adapter) — a genuine pre-charge rejection | Show a generic "your card couldn't be authorized" message and suggest a different payment method. The full reason is logged server-side; do not leak adapter messages to buyers. | > **Charge-and-save carve-out.** On the embedded charge-and-save flow, a successful guest / no-buyer submit charges the card once and returns **no token by design** — the result resolves as `{ charged: true }`, NOT `frame_tokenization_failed`. A missing token is therefore not, on its own, a failure. Branch on `result.charged` **before** treating an absent token as `frame_tokenization_failed`, and **never re-charge** after a `charged` result. `frame_tokenization_failed` fires only on a genuine pre-charge adapter rejection (no charge occurred); the charge-only success path is a settlement you confirm via the webhook. | `frame_3ds_challenge_failed` | Buyer failed 3DS challenge (wrong code / cancelled) | Show "authentication failed — please try again or use a different card" and re-enable the submit button. | | `frame_3ds_required` | The issuer requires a 3DS challenge that hasn't been completed for this submit | Re-run the submit so the harmonized 3DS modal renders, or forward the server-issued `client_confirm` block back into the SDK to drive the challenge. See [3D Secure](3ds.md). | | `frame_payment_declined` | The charge was attempted and the issuer declined it (charge-and-save / charge-only flows where the embed charges at submit) | Show a generic "your payment was declined — please try a different card" message and re-enable the submit button. The decline reason is logged server-side; do not leak issuer messages to buyers. | ### Wallet errors (Apple Pay / Google Pay) Wallets render inside the card mount automatically when eligible — see [Elements reference → Apple Pay & Google Pay](elements.md#apple-pay--google-pay--built-into-the-card-mount). When an eligibility gate fails, the wallet button simply doesn't appear and the buyer completes checkout via the card field. The codes below surface on the `change` event so analytics layers can record wallet-coverage gaps; neither blocks the card flow. | Code | When it fires | Recovery | |---|---|---| | `frame_wallet_unavailable` | Buyer's device or browser doesn't support the wallet (e.g. Apple Pay on a non-Safari, non-WebKit browser; Google Pay on a buyer without a saved card). | No action required — the wallet button doesn't render and checkout continues via the card field. Optional: log the event so you can measure wallet-availability coverage across your buyers. | | `frame_wallet_domain_unverified` | Your serving domain isn't registered with the wallet network for this merchant account. The wallet button doesn't appear; the card field still works. | New merchants are auto-registered on first session-create. If this persists, the merchant's domain needs to be registered manually via the merchant dashboard. | | `frame_wallet_rendered_by_card_element` | You tried to mount a standalone wallet element, but the active binder renders Apple Pay / Google Pay inside the `card` mount instead. | No separate wallet element is needed — wallets appear inside the `card` mount automatically when eligible. Remove the standalone wallet element and rely on the card mount. See [Elements reference → Apple Pay & Google Pay](elements.md#apple-pay--google-pay--built-into-the-card-mount). | --- ## `frame_binder_load_failed` — sub-hints When this fires, inspect `error.hint` for the specific failure: ```javascript catch (err) { if (isVoraMirrorError(err) && err.code === "frame_binder_load_failed") { switch (err.hint) { case "network_error": case "timeout": // Transient; suggest retry break; case "csp_blocked": case "script_blocked": // Configuration; merchant needs to update CSP / disable adblockers break; } } } ``` --- ## CSP requirements VORA's iframe-mount model loads the active processor's tokenization library from a processor-owned CDN. Your CSP needs to allow VORA's own CDN plus your active processor's domain. **Always required:** ``` script-src 'self' js.vonpay.com; ``` **Processor-specific domains:** the specific hostname depends on how your merchant account is provisioned. After your account is set up, the active processor's CSP allowlist is provided as part of go-live setup (or available from your dashboard's integration details page). If you need it before then, contact support. If your CSP rejects any of these, you'll see `frame_binder_load_failed` with `hint: "csp_blocked"` (when the failure is detectable) or `hint: "script_blocked"` (when the script tag insertion was blocked outright). --- ## Server-side errors vs client-side errors `VoraMirrorError` is **client-side** (thrown by `@vonpay/vora-js` / `@vonpay/vora-react`). 4xx / 5xx responses from `/v1/sessions`, `/v1/payment_intents`, etc. (your server's calls) are **server-side** and use the [self-healing error envelope](../agents/overview.md): ```json { "error": "Authentication required", "code": "auth_missing_bearer_publishable", "fix": "Include an Authorization: Bearer header (vp_pk_*)", "docs": "https://docs.vonpay.com/reference/security#authentication", "selfHeal": { "retryable": false, "nextAction": "fix_request", "llmHint": "…", "actions": [ { "type": "check_format", "field": "Authorization", "expected_pattern": "^Bearer vp_pk_(live|test)_[a-z0-9]+$" } ] } } ``` Public endpoints (`/v1/public/sessions/:id`, `/v1/public/binder-load`, `/v1/public/tokens`) accept publishable keys (`vp_pk_*`) ONLY. Secret keys (`vp_sk_*`) are rejected with 403 `auth_key_type_forbidden`. Secret keys must never reach the browser; if you're testing from a server-side harness, use `/v1/sessions/:id` and `/v1/tokens` (the secret-key paths) instead. The `*_publishable` variant auth error codes (`auth_missing_bearer_publishable`, `auth_invalid_key_publishable`, `auth_key_expired_publishable`) on these routes carry self-heal envelopes that steer to publishable keys exclusively. See [Reference → Error codes](../reference/error-codes.md) for the full server-side code catalog. --- ## What's next - [Quickstart](quickstart.md) — end-to-end walkthrough - [Elements reference](elements.md) — per-element behavior - [React wrapper](react.md) — `useVora()` error patterns - [Reference → Error codes](../reference/error-codes.md) — server-side code catalog --- ## Element events Source: https://docs.vonpay.com/embedded-fields/events # Element events Every element exposes the **same event API** — wire your checkout UI to it for loading states, button enablement, validation messages, and focus styling. It's **binder-agnostic**: the events and payloads are identical on every gateway, so your code never branches on which binder backs your account. ## The API — `on` / `off` ```javascript const card = elements.create("card", {}); const handler = (e) => { /* … */ }; card.on("change", handler); // subscribe card.off("change", handler); // unsubscribe (pass the same handler reference) card.mount("#card-element"); ``` `on(event, handler)` and `off(event, handler)` are on every element type (`card`, `email`, `cardholder`, `address`, `save-for-future-use`, `express-checkout`, …). ## The four events | Event | Fires when | Typical use | |---|---|---| | `ready` | The element has finished rendering and is interactive. | Hide your loading spinner / skeleton; mark the form ready. | | `change` | The element's value changes (and on unmount, with `complete: false`). | Enable the **Pay** button when `complete`; surface `error` for inline validation. | | `focus` | The field gains focus. | Apply a focus ring or floating-label state on your wrapper. | | `blur` | The field loses focus (or, on the `express-checkout` element, the buyer dismisses the wallet sheet). | Validate on blur; reset focus styling. | All four fire with the **same payload**: ```typescript interface ElementChangeEvent { complete: boolean; // value passes its own validation and is non-empty (or empty + not required) error?: { code: string; message: string }; // scrubbed validation error, undefined when valid } ``` ### Gate the submit button + show validation ```javascript const card = elements.create("card", {}); card.on("change", (e) => { payButton.disabled = !e.complete; // enable only when valid showFieldError(e.error ? e.error.message : null); // inline validation }); card.on("ready", () => hideSpinner()); card.on("focus", () => cardWrapper.classList.add("focused")); card.on("blur", () => cardWrapper.classList.remove("focused")); card.mount("#card-element"); ``` `error.message` is already scrubbed for display; `error.code` is the stable identifier to branch on. ## Wallet buttons (`express-checkout`) use the same events The `express-checkout` element ([standalone wallets](../guides/standalone-wallets.md)) maps the native wallet sheet's lifecycle onto these same four events — so you wire it the same way. When the buyer **authorizes** in the wallet sheet, it fires `change` with `complete: true`; read the resulting `vp_pmt_*` by calling `submit()` on that collection: ```javascript express.on("change", async (e) => { if (e.error) return; // buttons unavailable / declined if (e.complete) { const result = await walletCollection.submit(); if (result.token) await chargeOnYourServer(result.token, result.wallet); } }); ``` (If the buyer dismisses the sheet, you get `blur`; if it can't render, you get `change` with an `error`.) ## Localization (`locale`) The SDK accepts a BCP-47 `locale` — on the client and per element: ```javascript const vora = new window.Vora({ publishableKey: "vp_pk_test_…", locale: "fr-FR", // client-wide default }); const card = elements.create("card", { locale: "de-DE" }); // per-element override ``` It's meant to drive field labels, validation messages, and error strings. :::caution English-only today The `locale` value is **accepted, recorded, and forwarded**, but the default label / validation surface is **English-only right now** — the value is captured so localization can light up in a future release without an integration change. Set it if you want to be forward-compatible; just don't expect translated strings yet. (Some binder-served field text may already reflect the locale where the underlying field provider localizes.) ::: ## Related - [Customize the look & feel](customize.mdx) — styling, themes, the `style` allowlist - [Elements reference](elements.md) — per-element options + collect shapes - [React](react.md) — the same events via `useVora()` / component props - [Standalone wallets](../guides/standalone-wallets.md) — the `express-checkout` element --- ## /embedded-fields Source: https://docs.vonpay.com/embedded-fields # Embedded Fields — embedded payment fields Drop Embedded Fields into your checkout and your buyer's card information flows directly to the gateway — never touching your server, never landing in your DOM. The iframe sits inside your form, styled to match, but the card data reflects straight through to the processor on the other side. You keep full branding control of the page; we keep you out of PCI scope. This is the **embedded** path. The alternative is [hosted checkout](../integration/redirect-to-checkout.md) — buyer redirects to `checkout.vonpay.com`. Embedded Fields gives you full branding control; hosted is faster to integrate. ## Pages in this section - **[Quickstart](quickstart.md)** — end-to-end card-only integration in ~10 minutes - **[Customize the look & feel](customize.mdx)** — interactive playground, three reference themes, the style allowlist, and what's stylable vs not - **[Elements reference](elements.md)** — Card, Email, Save-for-future-use; Apple Pay & Google Pay render automatically inside the card mount when the buyer's device and your domain are eligible. Saved methods coming soon. - **[Using React](react.md)** — ``, ``, `useVora()` patterns + the imperative elements API - **[Charge-and-save](charge-and-save.md)** — the embed that charges on submit and optionally vaults a reusable payment method in one step; the `{ charged: true }` result, buyer-vs-guest split, and confirm-via-webhook rule - **[Error handling](errors.md)** — `VoraMirrorError` codes and recovery - **[3D Secure](3ds.md)** — modal harmonization, `disable3dsModal` opt-out, `confirmPaymentIntent` + `handleAction` flows, test cards ## Working sample [`samples/frame-react`](https://github.com/Von-Payments/vonpay/tree/master/samples/frame-react) in the SDK repo is the canonical end-to-end reference — Express server stub for `/api/create-session` plus a React app using `@vonpay/vora-react`. Clone, fill in `.env`, `pnpm dev`. ## Server-side reference The server-side payment lifecycle (`/v1/payment_intents`, capture, refund, void, MIT, webhooks) is shared with the hosted-checkout flow: > **Two embed behaviors — wire your server accordingly.** > - **Tokenize-only:** `submit()` returns a `vp_pmt_*` token and charges nothing. Pair it with [Payment intents](../integration/payment-intents.md) to charge the saved method server-side. > - **Charge-and-save:** the embed charges on submit and (with a buyer on the session) also vaults a reusable `vp_pmt_*` in one step. **Do not** call `/v1/payment_intents` for that session — doing so double-charges the buyer. The client result is a UX signal; confirm settlement via the [webhook](../integration/webhooks.md) before fulfilling. See [Charge-and-save](charge-and-save.md). - [Create session](../integration/create-session.md) - [Payment intents](../integration/payment-intents.md) - [Webhooks](../integration/webhooks.md) --- ## Quickstart Source: https://docs.vonpay.com/embedded-fields/quickstart # Embedded Fields — Quickstart Get a working Embedded Fields integration in ~10 minutes. Card-only happy path; advanced elements covered on the [Elements reference](elements.md) page. **Embedded Fields** is the embedded path — buyers stay on your domain; sensitive card data never touches your servers; tokenization happens inside provider-issued iframes mounted via the `@vonpay/vora-js` browser SDK. Compare with [Redirect to checkout](../integration/redirect-to-checkout.md) (hosted checkout — buyer goes to `checkout.vonpay.com`) which is faster to integrate but gives less branding control. ## Architecture in one diagram ```mermaid sequenceDiagram participant B as Merchant browser participant M as Merchant server participant V as VORA B->>M: 1. POST /api/create-session M->>V: 2. POST /v1/sessions
Authorization: Bearer vp_sk_* V-->>M: { id: vp_cs_test_... } M-->>B: { session_id } Note over B: 3. new Vora({ vp_pk_* }) B->>V: 4. vora.sessions.retrieve(id)
GET /v1/public/sessions/:id
(Bearer vp_pk_*) V-->>B: { binder, publishableKey,
capabilities, ... } Note over B: 5. card.mount('#card')
(binder iframe loads inside #card) B->>V: 6. elements.submit() V-->>B: { token: "vp_pmt_..." } B->>M: 7. POST /api/charge { vp_pmt_... } M->>V: 8. POST /v1/payment_intents
{ payment_method: { id: "vp_pmt_..." } } V-->>M: { status: succeeded } ``` The buyer's browser never sees your secret key. The publishable key (`vp_pk_*`) is safe to embed in JavaScript bundles — it can only authenticate the public endpoints (`/v1/public/sessions/:id`, `/v1/public/binder-load`, `/v1/public/tokens`). :::danger Submit can move money — check the result before charging The diagram above (steps 6–8) shows the **tokenize-only** flow: submit returns a `vp_pmt_*` token and your server charges it via `POST /v1/payment_intents`. There is a second flow — **embedded charge-and-save** — where submit **already charges the card** (and, with a buyer on the session, vaults a reusable token in the same step). On a charge-and-save session you must **skip step 8 entirely**: do **not** call `POST /v1/payment_intents`, or you will charge the buyer twice. Confirm settlement via the [settlement webhook](../integration/webhooks.md) instead. The submit result tells you which flow ran: `result.charged === true` means the embed already charged (skip the server charge), `result.token` means you have a token to charge server-side. Always discriminate the result before wiring the charge call — see [Step 6](#step-6--tokenize-on-submit) and the [Charge-and-save flow](charge-and-save.md) guide. The client-side result is a UX signal only — confirm settlement server-side via the webhook before fulfilling the order. ::: --- ## Step 0 — Get your test keys If you haven't already, see [Quickstart §0](../integration/quickstart.md#step-0-get-your-test-keys). For embedded checkout you need both: - `vp_sk_test_*` — secret key (your server) - `vp_pk_test_*` — publishable key (your browser bundle) Both are available in the developer dashboard at [app.vonpay.com/dashboard/developers](https://app.vonpay.com/dashboard/developers). --- ## Step 1 — Install the browser SDK :::caution Use the CDN — `@vonpay/vora-js` is not on the public npm registry today The browser SDK ships from `js.vonpay.com` as a ` ``` The `/v1/vora.js` channel auto-updates within the v1 major; integrity is verified continuously by Von Payments. To pin a specific version with browser-enforced Subresource Integrity instead: ```html ``` `vX.Y.Z` is a placeholder — **`vora.js` ships new versions regularly.** Pull BOTH the current version number and its matching `integrity` hash from the published registry at [`js.vonpay.com/integrity.json`](https://js.vonpay.com/integrity.json); the hash is byte-exact to that build, so copy both verbatim or the browser will refuse to execute the script. (Most integrations don't pin at all — the `/v1/vora.js` channel above stays current automatically.) Full guidance on the two channels — what each one buys you, the published registry at `js.vonpay.com/integrity.json`, and how to automate version bumps — lives at [Script-tag integration (SRI)](sri.md). --- ## Step 2 — Create a session on your server A VORA session is the unit of one buyer's checkout attempt. Your server creates it with the secret key (the secret key never reaches the browser): ```typescript // server/api/create-session.ts (Node example) const app = express(); app.use(express.json()); const SECRET = process.env.VONPAY_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", }), }); const session = await response.json(); // session.id is vp_cs_test_* — safe to send to the browser. res.json({ session_id: session.id }); }); ``` Returned session ID format: `vp_cs_test_*` for sandbox keys, `vp_cs_live_*` for live keys. --- ## Step 3 — Initialize VORA in the browser The CDN ` ``` What this means: - The script auto-tracks the latest `v1.x.x` release. New features, bug fixes, and security patches reach your checkout the moment they ship. - Integrity is enforced by Von Payments' continuous integrity monitor — the bundle is verified against its published hash on every request. - `crossorigin="anonymous"` is recommended so browsers can report integrity failures (it's required if you later add `integrity` for pinning; setting it now avoids re-deploying when you pin). This is the right default for most integrations. The whole purpose of the channel is to keep merchants on the safest current version automatically. --- ## Pinned channel (SRI) ```html ``` > `vX.Y.Z` is a placeholder — **`vora.js` ships new versions regularly.** 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 a version or a hash from this page — both are illustrative and will be out of date. What this means: - **Byte-exact pin.** The browser computes the SHA-384 of the fetched bytes and compares against your `integrity=` value. Mismatch → script is rejected; the SDK never loads. - **Manual updates.** You bump the version number AND the `integrity=` hash on each release. Stale pins keep working but miss new features. - **Required for any audit posture** that demands "no untrusted code runs without explicit operator action." Both the version number and the hash come from the published registry at [`https://js.vonpay.com/integrity.json`](https://js.vonpay.com/integrity.json) — see [Where the hashes come from](#where-the-hashes-come-from) below for the registry shape. --- ## Where the hashes come from The published integrity registry lives at: ``` https://js.vonpay.com/integrity.json ``` Shape (truncated to a single version for illustration): ```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 } ] } } } ``` > The `sha384-…` placeholders above stand in for the real hashes — copy the current values from the live [`https://js.vonpay.com/integrity.json`](https://js.vonpay.com/integrity.json) for the version you pin. - `sha384` at the top level is the core-bundle hash — the value you pin in the `integrity=` attribute. `artifacts[]` carries per-file hashes for the core bundle and its internal sub-chunks. - `builtAt` is the publish timestamp. - `bytes` is informational — you don't pass it to the browser, but it lets you sanity-check before pinning (a 0-byte or absurdly small entry is the canonical signal that the registry got corrupted). - The registry is append-only — old version entries stay published forever so pinned snippets in deployed customer code don't break. --- ## Automating updates If you're on the pinned channel, the version + hash should be checked into source control alongside the snippet. A typical CI flow: 1. On each release, your build pipeline fetches `https://js.vonpay.com/integrity.json`. 2. Pipeline reads the latest `versions.{v}` entry. 3. Pipeline 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); }); ``` ## What the Buyer Sees The hosted checkout page displays: - **Header** — your merchant name - **Order summary** — line items with quantities and prices (if provided) - **Billing address form** — pre-filled with `buyerName` if you provided it - **Payment methods** — auto-detected based on device, browser, and location (cards, Apple Pay, Google Pay, Klarna; availability varies by region) - **Pay button** On mobile, a sticky bottom bar shows the total and pay button for easy access. ### 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. The event families below cover what the public ecommerce integration surface supports today. The event picker at `/dashboard/developers/webhooks` is the canonical list of what's available to subscribe to from your dashboard right now. Event families are added to the catalog as each capability ships. New event types may appear without an SDK version bump — handlers should treat unknown `type` values as a no-op (return `200 OK`, log for inspection, do not raise). ## 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 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. Treat unknown event types as a no-op (forward-compatible: new types may appear without an SDK bump). | | `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 from a live merchant; `false` for sandbox / test-mode dispatches. | | `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_test_kJq7Lp...", "payment_intent_id": "vpi_9f2ndx7k...", "transaction_id": "vp_tx_9f2nd...", "amount": 1499, "currency": "USD", "card": { "brand": "visa", "last4": "4242" } } } ``` | `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. | | `transaction_id` | string \| null | The charge transaction ID. Use this for reconciliation against the merchant's transaction list. | | `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. | ### `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_test_pK7nLm...", "payment_intent_id": "vpi_8d4nex2p...", "transaction_id": "vp_tx_8d4ne...", "amount": 1499, "currency": "USD", "failure_reason": "Your card was declined.", "failure_code": "card_declined", "network_decline_code": "05", "card": { "brand": "visa", "last4": "4242" } } } ``` `failure_reason` is a human-readable summary; `failure_code` is the normalized decline code (`card_declined`, `insufficient_funds`, `expired_card`, `fraudulent`, `processing_error`, …) you should branch on; `network_decline_code` is the raw issuer code (`05`, `51`, …). All three are `string | null`. Surface `failure_reason` to your UI so the buyer can act on it. `card` carries the PCI-safe `{ brand, last4 }` (or `null` until card enrichment is active) — same shape as on `charge.succeeded`. ### `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_test_kJq7Lp...", "payment_intent_id": "vpi_9f2ndx7k...", "transaction_id": "vp_tx_9f2nd...", "refund_id": "vpr_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. | | `refund_id` | string \| null | Refund record id (`vpr_*`) — identifies WHICH refund in a multi-partial sequence this event references. | | `amount` | integer | Amount of THIS refund in minor units (not the original charge total — see `original_charge_amount`). To get the cumulative refunded total, sum `amount` across all `charge.refunded` events for the same `transaction_id`. | | `reason` | string \| null | Refund reason — `customer_request` / `duplicate` / `fraudulent` / merchant free-form. | | `is_partial` | boolean \| null | True when `amount < original_charge_amount`. | | `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. | ## 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_abc123x7...", "transaction_id": "vp_tx_abc123", "amount": 1499, "currency": "USD" } } ``` `session_id` is null for payment intents created outside the hosted-checkout flow. ### `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_xyz789k2...", "transaction_id": "vp_tx_xyz789", "amount": 1499, "currency": "USD", "failure_reason": "Your card was declined.", "failure_code": "card_declined", "network_decline_code": "05" } } ``` ### `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_def456m9...", "transaction_id": "vp_tx_def456", "amount": 1499, "currency": "USD", "cancellation_reason": "buyer_abandoned" } } ``` ## Forward compatibility New event types may be added to this catalog without an SDK version bump. Handlers should treat unknown `type` values as no-ops (`return 200 OK`, log for inspection, do not raise). New fields may be added to existing `data` payloads at any time; consumers should ignore unknown keys without failing. The `charge.*` and `payment_intent.*` events above are the ones you can subscribe to via `enabledEvents` (the dashboard event picker). Von Payments also delivers platform-level events your endpoint may receive but that are **not** individually selectable — `session.succeeded` / `session.failed`, `dispute.created` / `dispute.won` / `dispute.lost`, `application.approved` / `application.denied`, `payout.paid` / `payout.failed`, and `merchant.ready_for_payments`. They share the same envelope; the SDK's `WebhookEvent` union models them so you can switch on `type`. ## 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. This page documents the schedule, the response codes that influence it, the per-endpoint circuit breaker that pauses delivery to a chronically-failing URL, and where to see delivery state in the dashboard. This page documents the retry behavior for **subscription-level** webhooks (`/dashboard/developers/webhooks`). The session-level surface ([Webhooks](./webhooks.md)) follows the same retry schedule; see its overview for the surface-specific signature and event shape. ## 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 between two attempts is uniform in `[0, base]` — full jitter. This spreads retries so a wave of failed deliveries (e.g., an endpoint coming back from a 30-minute outage) doesn't all retry at the same instant and flatten the endpoint again. What this means for you: - A given delivery's attempt-2 might come anywhere from 0 to 30 seconds after attempt 1. - A given delivery's attempt-5 might come anywhere from 0 to 60 minutes after attempt 4. - The cumulative-time column above is an upper bound on a "typical" trajectory; some deliveries finish their 8 attempts noticeably faster. ## 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 further attempts to this subscription for any event. | | `429 Too Many Requests` | Endpoint rate-limited the request | Row marked **dead immediately** — see the `429` note below. Return `503` instead if you rate-limit inbound webhook traffic. | | Other `4xx` (`400`, `401`, `403`, `404`, `422`, …) | Endpoint rejected the request | Row marked **dead immediately**. No retry — a retry can't fix a misconfigured signature check or a route that doesn't exist. | | `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. If your handler does heavy work synchronously, queue it and return `200` immediately — see [Best Practices](./webhooks.md#best-practices). ### Why 4xx is non-retryable The retry schedule exists to absorb transient failures (network blips, brief endpoint restarts, short load spikes). It can't paper over wrong code: - A `401` means your signature verification rejected the request. The next attempt carries the same signature; it'll fail the same way. - A `404` means the URL doesn't route. The next attempt hits the same URL. - A `422` means your handler parsed the body and explicitly rejected it. Retrying won't change the body. Marking these dead immediately surfaces the misconfiguration to you faster (you see one failed row in the dashboard, not eight). **`429 Too Many Requests` is the exception worth flagging.** Standard rate-limiting middleware returns `429` when an endpoint is over budget — that *is* a transient state, but the retry engine treats every `4xx` outside `410` the same way and marks the row dead. If your handler may rate-limit inbound webhook traffic, return `503` from the rate-limiter instead so the retry engine backs off. The alternative is silent event loss whenever your rate-limit window overlaps a webhook delivery. If you're returning any other `4xx` on transient state (e.g., "I haven't seen this customer yet, come back later"), return `503` instead — that's the contract the retry engine speaks. ## Per-endpoint circuit breaker Layered on top of the retry schedule is a **per-endpoint circuit breaker**. It pauses outbound delivery to any one endpoint that's repeatedly returning 5xx so a single broken consumer doesn't queue up against itself — and so one merchant's failing endpoint doesn't slow delivery to every other merchant. ### 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 A row delivered through a closed breaker shows the normal attempt sequence. A row whose attempt was suppressed while the breaker was open appears in the dashboard with a `circuit_open` annotation on the attempt — you can tell apart "endpoint didn't respond" from "delivery was suppressed because the endpoint was misbehaving moments ago." 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. ## Dead-letter queue When a delivery hits any of these terminal states, the row moves to the dead-letter queue (DLQ): - All 8 retry attempts exhausted - A `4xx` (non-`410`) marked it dead immediately - A `410 Gone` disabled the subscription - The subscription was deleted mid-flight DLQ rows are retained for **30 days**. They're visible in the dashboard at `/dashboard/developers/webhooks/{id}/dlq` with the final response code, the error message excerpt (response body or network error reason), 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. It's retained for the 30-day DLQ window and visible to anyone with merchant-dashboard access for your account. Keep error responses short and avoid including PII (customer emails, full request bodies, internal stack traces) — there's no need for them in a webhook reject path, and they'd outlive the failure itself in the DLQ. ::: You can manually retry a DLQ row from the dashboard — useful if you've fixed the handler and want to re-process events that died during the outage. After the 30-day retention window, the DLQ row is purged and the event can no longer be redelivered. ## Where to see delivery state | Surface | What you see | |---|---| | **`/dashboard/developers/webhooks/{id}` → Deliveries** | All recent deliveries with status (delivered / retrying / dead), attempt count, last response code, next-retry timestamp if still retrying. | | **`/dashboard/developers/webhooks/{id}/dlq`** | Dead-lettered deliveries with the final response code, error message, full payload, and a manual "Retry" button. | | **Per-event view** | Click any event to see its full attempt history (each attempt's timestamp, response code, response body excerpt, and the `circuit_open` annotation if applicable). | ## Sending a test event Use the CLI to fire a fully-signed test event at your handler — see [Test your handler](./webhooks.md#test-your-handler). Test events go through the same delivery code path that production events do, so the retry schedule, circuit breaker, and DLQ all work identically against your `localhost` handler. ## Designing your handler around the retry contract A few invariants worth coding against: - **Idempotency.** The same `id` (event ID) may arrive more than once if a previous attempt succeeded but you returned a 5xx by mistake, then the next attempt also delivered. Idempotency-guard on `id` and return `200` for known-processed IDs. - **Order is not guaranteed.** Out of two events emitted close together, the second can be delivered before the first if the first hits 5xx on attempt 1 and retries. Build your handler to be order-tolerant: read state from the API by ID rather than from the event payload alone where ordering matters. - **Fast `200`, async work.** Return `200` as soon as you've persisted the event ID for idempotency. Do the heavy work (charge order, send email, update analytics) in a background job. A handler that takes >10s to respond hits the timeout and gets retried — at which point you're racing your own background work. - **`503` over `4xx` for transient.** If you genuinely can't process the event right now but expect to be able to soon (e.g., a backing service is briefly down), return `503` — the retry engine will back off and try again. A `4xx` marks the event dead immediately and you'll only see it again via a manual DLQ retry. ## Related - [Webhooks (session-level)](./webhooks.md) — how the session.* webhook surface works - [Webhook Event Reference](./webhook-events.md) — subscription-level event catalog - [Webhook Signature Verification](./webhook-verification.md) — verifying both signature formats - [Webhook Signing Secrets](./webhook-secrets.md) — create, view-once, rotate --- ## /integration/webhook-secrets Source: https://docs.vonpay.com/integration/webhook-secrets # Webhook Signing Secrets :::note Manage endpoints via the dashboard or the public API Webhook endpoints and their `whsec_*` signing secrets are created, rotated, and revoked either in the [developer dashboard](https://vonpay.com/developers) → **Webhooks** or programmatically via the public **Webhook Subscriptions API** (`POST /v1/webhook_subscriptions`, secret-key auth) — see the [REST API reference](../sdks/rest-api.md#webhook-subscriptions). Every webhook Von Payments delivers is signed with the **per-endpoint `whsec_*` secret** — **not** your merchant API key (`vp_sk_test_*` / `vp_sk_live_*`). See [Webhook Signature Verification](webhook-verification.md) for the verifier. ::: Each webhook endpoint you register in the developer dashboard → **Webhooks** gets its own `whsec_*` signing secret. This page covers the lifecycle: create, view-once, rotate, revoke. Registering a webhook endpoint generates a per-endpoint signing secret. You see it **once** at creation time — store it immediately; you cannot retrieve it again. That secret is what signs every delivery to the endpoint: the `x-vonpay-signature` header is `t=,v1=`, where `v1` is the HMAC-SHA256 of `${t}.${rawBody}` keyed by the `whsec_*` secret (the raw UTF-8 string, prefix included). The merchant API key is **not** used for webhook signing. The verifier algorithm and reference implementations are on the [Webhook Signature Verification](webhook-verification.md) page. ## Lifecycle ### 1. Create Creating a webhook endpoint mints a fresh `whsec_*` signing secret bound to that endpoint, via the dashboard or the public API. **Dashboard path** — `/dashboard/developers/webhooks` → **Add endpoint** → enter your URL + select event types → **Create**. The full secret is shown **once** on the success page — copy it immediately. **API path** — `POST /v1/webhook_subscriptions` (server-to-server, secret-key auth) with body: ```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": "wsub_kJ8mP3...", "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-04-14", "createdAt": "2026-05-04T18:30:00Z" } ``` The full `signingSecret` appears in the **create** (and **rotate**) response only. Store it server-side immediately — it is not retrievable later. See the [REST API reference](../sdks/rest-api.md#webhook-subscriptions) for the full field list + the other endpoints. ### 2. View-once After creation, the dashboard shows only a truncated prefix (the first ~16 characters) of the secret. The remaining bytes are hashed at rest and cannot be retrieved — reads of the subscription (`GET /v1/webhook_subscriptions/{id}`) never return the secret at all. Treat the create-time copy as authoritative. If you've lost the secret, rotate (Section 3) — there is no "show me the secret again" path by design. ### 3. Rotate Rotation is **zero-downtime**: during the rotation grace window both the new and the previous secret can verify, so events in flight never fail signature checks. The `x-vonpay-signature` header carries a **second `v1=` entry** signed with the previous secret while it remains in its grace window — your verifier accepts on **any** `v1=` match (the SDK's `constructEvent` does this for you). See [Webhook Signature Verification — Header format](webhook-verification.md#header-format-v1) for the multi-`v1=` rules. This bounds the blast radius of a rotation: - An attacker can't withdraw funds with a webhook secret; the worst case is forging events to your handler, and only if they also control your inbound endpoint. - Endpoint secrets are scoped to one endpoint, not your whole API surface. - The dual-signature grace window means you can roll the new secret to your handler env without a verification-failure gap. Rotate via either path: **Dashboard path** — `/dashboard/developers/webhooks/{id}` → **Rotate signing secret** → new secret shown once → update your handler env → confirm an inbound webhook verifies cleanly under the new secret. **API path**: ```http POST /v1/webhook_subscriptions/wsub_kJ8mP3.../rotate_signing_secret Authorization: Bearer vp_sk_live_… ``` Response is the same shape as **Create**, with a new `signingSecret`. The previous secret stays valid for the duration of the rotation grace window, so events signed just before the cutover still verify. **Recommended deploy sequence:** 1. Rotate (dashboard or `POST /rotate`) and capture the new secret. 2. Push the new secret to your handler env (Vercel env-var set, Railway variable, AWS Secrets Manager rotate, etc.). 3. Redeploy your handler. Your handler now verifies with the new secret — and, because the header carries both `v1=` entries during the grace window, in-flight events signed with the old secret also still verify. 4. Confirm at least one inbound webhook verifies cleanly under the new secret. 5. Done. The old secret falls out of use once the grace window closes. If you need to fully break trust with the old secret immediately (a leak, not a routine rotation), see [Compromise path](#compromise-path) below — that flow stands up a parallel endpoint and gives you a clean audit boundary before retiring the original. ### 4. Revoke Revoking an endpoint permanently disables it — under the hood this is the `DELETE` (soft-delete) shown below: the server flips the subscription's status to `disabled` and stamps `deleted_at`. Different from rotate: rotate keeps the endpoint alive with a new secret; revoke deactivates the endpoint entirely. (The only caller-settable statuses via `PATCH` are `active` and `paused`; `disabled` is set server-side by the delete.) Use revoke when: - You're decommissioning the handler endpoint - You're consolidating multiple endpoints into one - You've created a parallel endpoint as part of a compromise response (below) Revoked endpoints cannot be re-activated. Their record is retained for audit; their signing secret can never be reissued. To resume webhook delivery, create a fresh endpoint. Revoke an endpoint from the dashboard at `/dashboard/developers/webhooks/{id}` → **Revoke endpoint**, or programmatically: ```http DELETE /v1/webhook_subscriptions/wsub_kJ8mP3... Authorization: Bearer vp_sk_live_… ``` Returns `204 No Content`. (To stop deliveries temporarily without deleting, `PATCH` the subscription to `status: "paused"` instead — see the [REST API reference](../sdks/rest-api.md#webhook-subscriptions).) Pending events queued for the now-deleted endpoint move to the dead-letter queue and are NOT retried — your handler will not see them. ### Compromise path If a `whsec_*` is exposed (committed to a public repo, leaked in a log, captured in a bug report), do **not** simply rotate. A routine rotation keeps the leaked secret valid for the duration of the grace window — long enough for an attacker to forge events your handler will accept — and more importantly, you can't safely audit which events arrived during the compromise window without a clean break. Recommended runbook: 1. **Stand up a fresh endpoint** at a NEW URL (e.g., add a path suffix or use a fresh subdomain). This gives you a clean audit boundary — every event arriving at the new URL is post-compromise; every event at the old URL is pre/during-compromise and untrusted. 2. **Update the merchant's UI** (or platform-integrator config) to point at the new endpoint. 3. **Audit events received at the OLD endpoint** during the window between leak time and the new endpoint going live. Treat them as untrusted; reconcile against the source-of-truth API (`/v1/sessions/:id`, `/v1/payment_intents/:id`) before acting on any business-state changes. 4. **Revoke the old endpoint** (step 4 above) once you're satisfied the new path is healthy. 5. **Rotate any secondary credentials** that may have been adjacent in the leak (API keys, DB passwords, webhook URLs that contained endpoint IDs). This costs one extra deploy and a brief period of running two endpoints in parallel — that's the price of a clean compromise response. ## 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. **Never process a webhook without verifying the signature first** — the verification guard is what stops an attacker from posting fake events to your endpoint. :::note Managing webhook endpoints and secrets Webhook endpoints and their `whsec_*` secrets can be managed in the [developer dashboard](https://vonpay.com/developers) → **Webhooks**, or programmatically via the secret-key-authed `POST /v1/webhook_subscriptions` API — which returns the raw signing secret **once** in the create response. The reference verifiers below are the production contract regardless of how the endpoint was created. ::: ## 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 During a secret rotation window, a second `v1=` entry appears (signed with the previous secret, still in its grace window): ``` x-vonpay-signature: t=1714406400,v1=,v1= ``` **Accept the request if ANY `v1` matches.** The header carries at most two `v1=` entries — if you see three or more, reject as malformed. ## 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`. Extract all `v1=…` values into a list. If the list has **more than two** entries, reject with 401 — operational invariant is at most two active secrets (current + grace). 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** — **without any length-based early exit.** For each `v1` from the header, constant-time compare against `expected`. Length mismatches must still go through the same constant-time path (wrap your timing-safe compare in try/catch; a short candidate throws and is treated as no-match). Early-returning on length leaks a 1-bit timing signal. If any constant-time compare returns true, accept. Otherwise reject with 401. **Never use `==` or `===`.** Variable-time comparison leaks the secret a byte at a time under repeated-request timing attacks. 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.** A stolen-at-rest request older than 5 minutes is useless. 5 minutes is generous enough for one in-flight retry + modest network latency; the Von Payments delivery engine re-signs on each retry attempt, so fresh-at-send is the norm even under retry pressure. - **Future: 30 seconds.** A future timestamp should never happen under normal flow (we're the signer). 30 seconds only covers minor receiver-clock skew — anything further indicates a clock problem worth diagnosing. ## Idempotency Events carry an `id` field on the envelope (e.g. `vp_evt_live_V1StGXR8Z5jdHi6B`). If the same `id` is redelivered (our retry after your 5xx, for example), your handler should idempotency-guard on `id` and return 200. **Do not rely on the signature alone** — during secret rotation, a request can be re-signed with a new secret but carry the same `id`. ## 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 — `openssl dgst` plus shell string handling is enough. **Do not** wire this into a production handler: shell `=` and `[ "$a" = "$b" ]` are variable-time, the rotation case is not handled, and the replay window check is left as a manual `date` compare. Use one of the SDK examples 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 is exposed in process args + shell history** for the brief moment the command runs. `openssl dgst -hmac "$SECRET"` puts the secret on the command line, visible to `ps aux` / `/proc/$PID/cmdline`. The `SECRET='whsec_...'` assignment lands in `~/.bash_history` / `~/.zsh_history` too. Run on a machine where other local users can't observe `ps`, and prefix the `SECRET=` line with a space (with `HISTCONTROL=ignorespace`) or `unset HISTFILE` for the session to suppress history capture. - During a rotation window, the header carries a second `v1=` entry. Extend the parse to iterate both — note that the loop body uses the same variable-time `=` compare as the primary block, so the rotation case stays in the "sanity-check only" bucket even with the extension: ```bash printf '%s' "$HEADER" | tr ',' '\n' | grep '^v1=' | cut -d= -f2 | while read CAND; do [ "$CAND" = "$EXPECTED" ] && { echo "signature OK (matched candidate)"; exit 0; } done echo "signature MISMATCH" ``` To capture a real event for replay-verification: open `/dashboard/developers/webhooks`, select the endpoint, click into a recent delivery in the **Attempts** log, and copy the raw body + headers 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) | | Only accepting one `v1` entry | Iterate all `v1=` entries (up to 2) and accept on any match | | Accepting >2 `v1=` entries | Reject as malformed | | Base64-decoding the `whsec_` secret | Use the raw string as UTF-8 bytes | ## Stripe-compatibility note The header shape (`t=…,v1=…`, HMAC-SHA256 over `t.payload`) is deliberately similar to Stripe's webhook signing scheme, so developers familiar with Stripe can read the format at a glance. But there are **intentional differences** that will bite anyone who copies a Stripe verifier verbatim: - **Replay window:** we reject past > 5 min **and** future > 30 sec. Stripe rejects past only, with no future tolerance. - **Multi-`v1=` cap:** we reject headers with more than 2 `v1=` entries. Stripe does not cap. - **Header name:** `x-vonpay-signature` (lowercase, hyphenated), not `Stripe-Signature`. - **Key encoding:** use the raw `whsec_…` string as UTF-8 bytes. Treat the shape as a starting point, not a drop-in. The reference verifiers above already reflect our specific choices. ## 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 Receive real-time notifications when payment events happen on your merchant account. Von Payments delivers signed `POST` requests to endpoints you register in the [developer dashboard](https://vonpay.com/developers) → **Webhooks**. ## How it works 1. **You register a webhook endpoint** in the dashboard — your URL plus the event types you want to receive (`charge.succeeded`, `payment_intent.failed`, etc.). Von Payments mints a per-endpoint `whsec_*` signing secret, shown once at create time. 2. **A payment event occurs** — a charge succeeds, a refund settles, a dispute opens. 3. **Von Payments POSTs the event** to your URL with a single `x-vonpay-signature` header — format `t=,v1=`, with the timestamp carried inside it as the `t=` field (there is no separate timestamp header). The body is a signed JSON envelope. 4. **Your handler verifies the signature**, processes the event, and returns `200`. Non-2xx (or no response within 10 seconds) triggers the retry curve. 5. **On repeated failure**, Von Payments retries on an exponential schedule that spans up to ~79 hours — see [Webhook Retries](./webhook-retries.md) for the schedule, response-code semantics, and circuit-breaker behavior. Register endpoints, view delivery state, and rotate signing secrets at `/dashboard/developers/webhooks`. --- ## Choose your events Endpoints subscribe to a subset of the [event catalog](./webhook-events.md). The catalog covers the supported event families: charge lifecycle, payment-intent lifecycle, refunds. ``` charge.succeeded payment_intent.succeeded charge.failed payment_intent.failed charge.refunded payment_intent.cancelled ``` Pick the events your integration actually consumes. A new endpoint with zero events selected receives nothing — set at least one. The dashboard event picker is the canonical list of what's currently subscribable. --- ## Envelope Every event ships in the same envelope. See [Webhook Events](./webhook-events.md) for per-event `data` shapes. ```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_test_kJq7Lp...", "transaction_id": "vp_tx_9f2nd...", "amount": 1499, "currency": "USD" } } ``` The `id` is unique per outbound event — use it for idempotent processing. The same `id` is never delivered twice with different payloads, but the same `id` may be redelivered after a 5xx response from your handler. --- ## 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. During a rotation window, a second `v1=` entry is present (signed with the previous secret); accept on any match. | | `Content-Type` | `application/json` | | `User-Agent` | `Von-Pay-Webhooks/1.0` | --- ## Signature verification Signatures are HMAC-SHA256 over `t.`, keyed with your `whsec_*` endpoint secret. Verifiers in Node, Python, Go, Ruby, and PHP — plus a shell/openssl sanity-check snippet (debugging only, not production) — are at **[Webhook Verification](./webhook-verification.md)**. Copy one and run it as-is. Key rules (the rest are on the verification page): - **HMAC the raw request body bytes**, not the parsed JSON. JSON re-serialization changes whitespace and key order; the signature won't match. - **Use a constant-time compare helper** (`crypto.timingSafeEqual`, `hmac.compare_digest`, etc.) — `==` leaks the secret a byte at a time under timing attack. - **Enforce the replay window**: reject if `now - t > 300` (older than 5 min) or `t - now > 30` (more than 30 sec in the future). - The `whsec_*` secret is the raw UTF-8 string — do **not** base64-decode it, do **not** strip the `whsec_` prefix. --- ## Test your handler Send a fully-signed synthetic event to your endpoint — including `localhost` — with the CLI. No tunnel (ngrok, Cloudflare Tunnel) required. ```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 the payload with the same HMAC-SHA256 algorithm and `x-vonpay-signature` header format as the live delivery engine, but keyed with **your API key** (not the endpoint's `whsec_*` secret). So for CLI-triggered tests, point your verifier at the API key; the live engine signs with the per-endpoint `whsec_*`. A passing test confirms your verification code path runs, not just that the JSON parsed. `vonpay checkout trigger` supports `session.succeeded` / `session.failed` / `payment_intent.succeeded` / `payment_intent.failed` / `payment_intent.cancelled` / `charge.refunded`. See [CLI reference](../sdks/cli.md#vonpay-checkout-trigger) for full flags. The synthetic event uses identifiers prefixed `vp_evt_test_…` / `vp_tx_test_…` / `vp_cs_test_…` so your handler can tell test traffic apart from production. The `User-Agent` is `VonPay-Webhook/1.0 (CLI trigger)`. --- ## 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_* 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 ); switch (event.type) { case "charge.succeeded": await fulfillOrder(event.data.session_id, event.data.transaction_id); break; case "charge.failed": await handleFailure(event.data.session_id, event.data.failure_reason); break; case "charge.refunded": await processRefund(event.data.session_id, event.data.amount); 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_* ) if event.type == "charge.succeeded": fulfill_order(event.data["session_id"], event.data["transaction_id"]) elif event.type == "charge.failed": handle_failure(event.data["session_id"], event.data.get("failure_reason")) elif event.type == "charge.refunded": process_refund(event.data["session_id"], event.data["amount"]) return jsonify(received=True), 200 except Exception as e: return jsonify(error=str(e)), 400 ``` ### Manual verification (any language) If you're not using an SDK, the algorithm, reference implementations in 5 languages, and a shell/openssl sanity-check snippet (debugging only) are on the [Webhook Verification](./webhook-verification.md) page. --- ## Retry behavior If your endpoint returns a 5xx, times out, or refuses the connection, Von Payments retries on an exponential schedule that spans up to ~79 hours: **8 attempts total** at roughly 0 / 30s / 2m / 10m / 1h / 6h / 24h / 48h, each jittered to spread reconnects. Non-recoverable 4xx responses (other than `410 Gone`) mark the event dead immediately. `410 Gone` disables the failing endpoint. A per-endpoint circuit breaker pauses delivery to any one URL that returns 5xx repeatedly. Full schedule, response-code semantics, circuit-breaker thresholds, and dashboard inspection paths: **[Webhook Retries](./webhook-retries.md)**. --- ## Best practices - **Respond `200` quickly.** Return `200` immediately, then process the event asynchronously. If your handler takes longer than 10 seconds, the request times out and triggers a retry. - **Make your handler idempotent.** You may receive the same event more than once (after a 5xx, on a manual resend from the dashboard, during a secret rotation). Deduplicate on the envelope's `id` field. - **Verify the signature before trusting the payload.** Treat every byte as attacker-controlled until your HMAC compare returns true. - **Use the SDK.** `constructEvent` handles signature verification, the replay window, multi-secret rotation, and payload parsing in one call. - **Subscribe only to events you handle.** Selecting `charge.*` when you only consume `charge.succeeded` triples your inbound volume for no benefit. - **Rotate signing secrets on a schedule** — quarterly is a reasonable cadence. See [Webhook Signing Secrets → Rotate](./webhook-secrets.md#3-rotate) for the zero-downtime sequence. --- ## What this surface doesn't cover today There is no `session.expired` / "buyer abandoned the checkout" event today. The shipped catalog only fires when a charge is attempted — a buyer who lands on the hosted checkout page and closes the tab without paying never produces an event. If you need abandoned-cart signals (recovery emails, inventory release, CRM "lost opportunity" tracking), poll `GET /v1/sessions/:id` after the session's 30-minute TTL elapses. A hosted-checkout / session-level webhook surface with a buyer-abandonment event (and a `session.*` vocabulary alongside the existing `charge.*`) is on the roadmap. No commitment to a delivery window — it ships when integrator pull justifies the work over other priorities. --- ## Related - [Webhook Event Reference](./webhook-events.md) — full catalog, per-event payload shapes - [Webhook Signature Verification](./webhook-verification.md) — algorithm + reference verifiers in 5 languages - [Webhook Signing Secrets](./webhook-secrets.md) — create, view-once, rotate, revoke - [Webhook Retries](./webhook-retries.md) — schedule, response-code semantics, circuit breaker, DLQ - [Reconciliation](./reconciliation.md) — redirect-signal vs webhook-signal interplay --- ## /platforms 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. Audience: you've already had the partnership conversation (or you're scoping work before one) and you want the API surface mapped against the gateway-adapter shape your platform already uses for other third-party gateways. ## What VORA is VORA exposes three front-end integration paths (Hosted Checkout, Embedded Fields, and Elements) that all 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`) → refund returns synchronously with `status: succeeded | pending` plus async webhook confirmation 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; key prefix (`vp_sk_test_*` vs `vp_sk_live_*`) selects the environment. Test keys cannot accidentally hit live data and vice versa. ### 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: ` — see [Idempotency](#idempotency) below. Not server-enforced today, but every connector should send one to make retries safe. ### 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) | | `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. | | `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. Your platform's webhook URL receives: | Event | When fired | `data` payload | |---|---|---| | **Charges** ||| | `charge.succeeded` | Buyer completed a hosted-checkout payment OR a direct `/v1/payment_intents` charge succeeded | `session_id`, `transaction_id`, `amount`, `currency` | | `charge.failed` | Charge attempt failed | `session_id`, `transaction_id`, `amount`, `currency`, `failure_reason` | | `charge.refunded` | A refund settled against a prior charge (full or partial; fires once per refund) | `session_id`, `transaction_id`, `amount`, `currency` | | **Payment Intents** ||| | `payment_intent.succeeded` | Intent reached `succeeded` (auto-capture, manual capture, or post-3DS settle) | `session_id`, `transaction_id`, `amount`, `currency` | | `payment_intent.failed` | Intent reached `failed` | `session_id`, `transaction_id`, `amount`, `currency`, `failure_reason` | | `payment_intent.cancelled` | Intent was voided before capture (the object's `status` field reports `voided`; the event name is `cancelled` — same terminal state) | `session_id`, `transaction_id`, `amount`, `currency` | Every event is wrapped in the envelope `{id, type, created, livemode, merchant_id, data}` — full per-event shapes at [Webhook Events](../integration/webhook-events.md). New event families (`dispute.*`, `application.*`, `payout.*`) are in the [subscription catalog](../integration/webhook-events.md) but not in the public platform-integrator surface today. There is no `session.expired` / "buyer abandoned" event today — closing this gap is on the roadmap. For abandoned-cart detection in the meantime, 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) - **Multi-secret:** during a rotation window the header carries two `v1=` entries; accept on any match. Reject headers with more than two `v1=` entries Reference verifier code in five languages (Node, Python, Go, Ruby, PHP) is on the [Webhook Verification](../integration/webhook-verification.md) page. **Always use a constant-time comparison helper** (`hmac.compare_digest`, `crypto.timingSafeEqual`, `subtle.ConstantTimeCompare`, etc.) — variable-time `==` leaks the secret a byte at a time under repeated-request timing attacks. ## 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` with `failureCode` indicating 3DS rejection. 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 (there is no `GET /v1/payment_intents/{id}` retrieve endpoint). Don't trust the browser's return signal as authoritative. ### Embedded Fields + Payment Intents If the merchant pairs Embedded Fields with Payment Intents, the browser SDK can drive the 3DS modal inline using a `client_confirm: { binder, client_secret }` block that `POST /v1/payment_intents` returns on `status === "requires_action"`. The connector forwards `client_confirm` to the browser; the SDK does the rest. 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 + a stable per-attempt suffix). The header is not server-enforced on every endpoint today — requests without it succeed — but a connector that omits it cannot make POST operations safely retryable, which matters under transient network failures. - Replaying the same `Idempotency-Key` returns the original response — including the original `checkoutUrl` — for as long as the session record is retained (today, 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}_{attempt_count} ``` Example: `sticky_order-789012_attempt-1`. On retry after a transient failure, increment the suffix to get a fresh session; on retry of the same logical operation, keep the suffix to dedupe. ## 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/error-codes#auth_invalid_key" } ``` 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. Per-IP bucket is 10 req / 60 s; per-API-key is 30 req / 60 s on session-creates. | | 502 | `provider_unavailable` | Upstream payment provider is unreachable. Retry with exponential backoff. | | 402 | `provider_charge_failed` | Card declined — surface the decline to the merchant's UI | A connector that handles `auth_*`, `merchant_*`, `validation_*`, and `provider_*` error families idiomatically is structurally complete. The remaining codes are operational (rate limits, idempotency conflicts, internal errors) and should be retried per standard exponential-backoff practice. ## Sandbox Every Von Payments merchant has a sandbox with deterministic mock outcomes. See [Sandbox & Test Mode](../guides/sandbox.md) for the outcome matrix, and the [Platform Integrator Sandbox guide](../guides/platform-sandbox.md) for the provisioning walkthrough — keys in under a minute, no KYC. ## 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_*` + `ss_*` stored on the platform side; `lib/tenants.ts` is shaped like a real DB query) - Tenant-scoped session creation with `Idempotency-Key` and pinned `Von-Pay-Version` - Tenant-scoped return-URL signature verification (uses the right tenant's `ss_*`) - **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 `vp_sk_*` - Composite-key event idempotency dedup (Webhooks v1 doesn't emit a top-level event id; the sample composes `sessionId:event:timestamp` and stores in-memory — swap for 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/tree/master/samples/checkout-nextjs), [`checkout-express`](https://github.com/Von-Payments/vonpay/tree/master/samples/checkout-express), [`checkout-paybylink-nextjs`](https://github.com/Von-Payments/vonpay/tree/master/samples/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 partnership conversation — see [Going from sandbox to a partnership](../guides/platform-sandbox.md#going-from-sandbox-to-a-partnership). ## Related - [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) — sandbox behavior contract + outcome matrix - [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 ## Self-service vs. gated issuance Key issuance depends on mode: - **Test keys (`vp_sk_test_*`, `vp_pk_test_*`, `ss_test_*`) — fully self-service.** Start at [vonpay.com/developers](https://vonpay.com/developers) (or deep-link straight to [app.vonpay.com/dashboard/developers](https://app.vonpay.com/dashboard/developers) if signed in), click **Activate VORA Sandbox**, and your test keys are issued in seconds — no ops-side approval queue. A sandbox merchant record is seeded automatically and pre-wired for sandbox transactions so you can create and route test sessions immediately. - **Live keys (`vp_sk_live_*`, `vp_pk_live_*`, `ss_live_*`) — gated behind merchant application approval.** You must complete onboarding and have your merchant application approved (KYC + contract) before live-mode keys can be generated. Contact Von Payments to start the merchant onboarding process. Until your merchant account is approved for live payments, requesting live keys returns **`403 merchant_not_onboarded`** with a `fix` string pointing back to the onboarding flow ("Complete the merchant application and wait for ops approval before creating live keys. Use test keys (`mode: 'test'`) during integration."). A denied account gets a distinct `fix` message asking you to contact `support@vonpay.com`. See [Error Codes → `merchant_not_onboarded`](error-codes.md#merchant_not_onboarded). ## 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) | | **Session secret** | `ss_test_`, `ss_live_` | Server-side verification of return redirects | Rotated independently | The `ss_*` prefix is the format Von Payments uses when provisioning the secret. **Always use the session secret provided by Von Payments in the dashboard verbatim** — do not generate your own. The checkout runtime does not enforce a minimum key length, so a short or predictable secret enables signature forgery. Copy-paste from `/dashboard/developers/api-keys` and store in a secret manager. 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` accepts publishable keys — this is the only write operation they can perform). They **cannot** retrieve the full session details that a secret key sees, nor perform any other server-authorized action. They *can* fetch a redacted public view of a session via `GET /v1/public/sessions/:id` (id, expiry, amount, currency, routing — no PII, no merchant-internal metadata, no provider session id); that endpoint accepts publishable keys only. 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` (which is set to `NOW()` plus the requested interval), 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*). This soft-deletes the key (`is_active = false`) so it rejects on the very next request — no grace. 2. Create a fresh key. 3. Rotate deployed services to the fresh key. 4. Von Payments also flags the revoked key internally so downstream audit logs record the revocation. 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) The `/dashboard/developers/api-keys` UI shows a badge on each key reflecting its rotation state. Useful when you're debugging why a deploy is still getting authentication errors somewhere: - **Active** — the primary key, created or rotated-into most recently. - **Grace: ends in <N>h** — previous primary, still accepted until `grace_ends_at`. - **Revoked** — manually revoked via *Revoke*. Will never accept again (`auth_invalid_key`). - **Expired** — grace window passed naturally (`auth_key_expired`). If a live-mode service suddenly starts failing authentication after a rotation, check the badge on the key that service is configured with. "Expired" means you missed the grace window for at least one deploy. ## 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** → `is_active = false`, 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. - **Manual rotation forced by ops** — e.g. response to a breach report. Shows the same *Revoked* badge. Key verification reads straight from the database on every request — there is no separate replication cache to wait on. A freshly-rotated key is live as soon as the rotation write commits. ## 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. ## Three front-end integration paths VORA provides three front-end integration paths. All vault a `vp_pmt_*` payment method and settle through **Payment Intents** — the server-side engine that handles authorization, capture, refunds, and voids. Pick the path that fits your integration: - **Hosted Checkout (Sessions)** — `POST /v1/sessions` returns a checkout URL. You redirect the buyer to it; Von Payments hosts the card form, handles 3DS, and redirects back to your `successUrl`/`cancelUrl`. Simplest path. - **Embedded Fields** — browser SDK ([`@vonpay/vora-js`](../embedded-fields/quickstart.md)) that mounts card / email / address / cardholder elements directly on your page. Submit behavior depends on how the session is configured: under tokenize-only behavior, submit returns a `vp_pmt_*` token your server charges via Payment Intents; under charge-and-save behavior, submit charges and (with a buyer on the session) vaults a reusable `vp_pmt_*` in one step, while guest sessions charge once and return no token. On charge-and-save you must **not** also call `POST /v1/payment_intents` for that session — doing so double-charges. Uses the publishable-key `/v1/public/*` endpoints from the browser. - **Elements (custom)** — same `@vonpay/vora-js` SDK with `integrationMode:"elements"` on `POST /v1/sessions`. Discrete card fields you position and style on your page, plus optional standalone Apple Pay / Google Pay buttons. Highest UI control; tokenize to `vp_pmt_*` and charge via Payment Intents. **Payment Intents (server-side engine)** — `POST /v1/payment_intents` creates an intent your server controls directly for authorization → capture → refund → void. All three front-end paths vault a `vp_pmt_*` and settle through Payment Intents. Best for delayed-capture flows, fraud-check-before-capture, subscriptions, or direct server-driven transactions where no buyer redirect is needed. See the [Payment Intents guide](../integration/payment-intents.md). See [Choose your integration](../integration-paths.md) for the side-by-side comparison. ## 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. ### 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. | Method | Path | Auth | Description | |--------|------|------|-------------| | `POST` | `/v1/sessions` | Publishable or Secret | Create a checkout session (Hosted Checkout) — the only endpoint publishable keys can call | | `POST` | `/v1/sessions?dry_run=true` | Publishable or Secret | Validate params without creating a session | | `GET` | `/v1/sessions/{id}` | Secret | Get session status | | `POST` | `/v1/payment_intents` | Secret | Create a payment intent (auth or auth+capture) — see [guide](../integration/payment-intents.md) | | `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) | | `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/capabilities` | Publishable or Secret | Per-merchant capability matrix (`supported_operations`, `settlement_currencies`, `rate_limits`) | | `GET` | `/v1/public/sessions/{id}` | 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` | `/api/health` | None | Health check (add `?deep=true` for deep variant) | Endpoints marked "internal" are called by the hosted checkout page, not by merchants. Inbound provider-webhook endpoints exist server-side under `/api/webhooks/...` for processor-to-platform delivery; they are not part of the merchant API surface and integrators never call them. ## 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_*`). Publishable keys can only create sessions and call the public browser endpoints; they are rejected on secret-only routes 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}/confirm`, `/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` | No | Unique key to prevent duplicate operations. If you retry a request with the same key, the original response is returned instead of creating a duplicate. Recommended for all `POST` requests in production. Max 255 printable-ASCII characters. See [Idempotency](#idempotency) below for retention. | ## 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 returned only on `429` responses. 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 unique keys per attempt (e.g. `order_123_attempt_1`, `order_123_attempt_2`) rather than reusing a key across attempts that could legitimately differ. **Format.** Max 255 characters, printable ASCII only (codepoints 0x20–0x7E). Whitespace-only keys are treated as no key. ## Errors `session_already_completed` is returned as HTTP 409 Conflict, distinct from `session_expired`'s HTTP 410 Gone. The 409 is **not** returned by `POST /v1/sessions` (the create endpoint); it is returned by the public completion and read endpoints — `GET /v1/public/sessions/{id}`, `POST /v1/public/tokens`, and `POST /v1/public/sessions/{id}/confirm` — when a duplicate or late completion lands on a session that has already reached `succeeded`. 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/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. Available in SDK 0.5.0+. ## 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": "rerouted_to_refund", "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": 100 } } ``` 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 | `supported` — voiding a captured intent works. `not_supported` — it does not. `rerouted_to_refund` — a void on a captured intent is transparently handled through the refund pathway. 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. | ### `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 for `POST /v1/payment_intents`. | ## 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 === "rerouted_to_refund") { // Voiding a captured intent goes through the refund pathway — call refunds.create. await client.refunds.create({ paymentIntent: pi.id }); } else if (caps.supportedOperations.voidAfterCapture === "supported") { await client.paymentIntents.void(pi.id); } ``` 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:** the embedded path works in sandbox against a Von-Payments-owned mock binder by default — it does **not** return `merchant_not_configured` in the common case. 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 — a plain-text summary designed for AI assistants to quickly understand 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` | 401 | No `Authorization: Bearer` header provided | | `auth_invalid_key` | 401 | API key is malformed or does not exist | | `auth_key_expired` | 401 | Key has rotated past its grace window | | `auth_key_type_forbidden` | 403 | Publishable key used on a secret-only endpoint, or sandbox/live mode mismatch | | `auth_merchant_inactive` | 401 | Merchant account is disabled or suspended | | `merchant_not_onboarded` | 403 | Live-key creation is blocked until merchant-onboarding review completes | | `auth_service_unavailable` | 503 | Authentication service is temporarily unavailable | | `session_not_found` | 404 | Session ID does not exist | | `session_expired` | 410 | Session expired (TTL elapsed), or a terminal session with no successful charge — create a new one | | `session_already_completed` | 409 | A completion call hit an already-`succeeded` session — the buyer was charged once; do **not** retry or create a new session (would double-charge). Read the result via webhook or session retrieve | | `session_wrong_state` | 409 | Session is in the wrong state for this operation | | `session_integrity_error` | 500 | Internal session state mismatch — contact support | | `validation_error` | 400 | Request body failed schema validation | | `validation_missing_field` | 400 | A required field is missing from the request body | | `validation_invalid_amount` | 400 | Amount is not a positive integer or exceeds maximum | | `merchant_not_configured` | 422 | Merchant is missing required configuration (e.g., payment provider credentials) | | `binder_unavailable` | 409 | Merchant has no live payment provider configured and is not in sandbox mode — complete onboarding or use a test-mode key | | `capability_not_supported` | 422 | The merchant's payment provider does not support the requested operation — check `GET /v1/capabilities` | | `rate_limit_exceeded` | 429 | IP-axis rate limit — retry after the `Retry-After` interval | | `rate_limit_exceeded_per_key` | 429 | Per-API-key rate limit (30 session-creates/min) — contact support if you need a higher ceiling | | `provider_unavailable` | 502 | Upstream payment provider is not responding | | `provider_attestation_failed` | 403 | Payment provider rejected the session-bound attestation | | `provider_charge_failed` | 402 | Card declined or charge rejected by the upstream provider | | `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` | 500 | Unexpected server error | | `webhook_missing_signature` | 401 | Inbound provider webhook is missing its signature header | | `webhook_invalid_signature` | 401 | Webhook signature does not match the expected value | | `webhook_not_configured` | 503 | Webhook verification secret is not configured on the server | | `webhook_test_delivery_failed` | 502 | A synchronous webhook test-send probe could not be delivered — retriable | | `origin_forbidden` | 403 | Internal endpoint called from outside the checkout page | | `transaction_verification_failed` | 403 | Transaction could not be verified with the payment provider | | `unsupported_media_type` | 415 | Content-Type header is missing or not `application/json` | | `endpoint_not_implemented` | 501 | A payment-intent operation is not yet implemented for this merchant's provider (capability gate) | | `idempotency_replay_incompatible` | 422 | Idempotency key was reused with a different request body | | `invalid_transition` | 409 | Payment intent is in a state that forbids the requested operation (e.g. capture on `succeeded`) | | `refund_intent_not_refundable` | 422 | The parent payment intent is not in a refundable state (refunds require `status: "succeeded"`) | | `refund_amount_exceeds_remaining` | 422 | Requested refund amount is greater than the remaining refundable balance | | `refund_currency_mismatch` | 422 | Refund `currency` does not match the parent intent's currency | | `payment_method_required` | 422 | This merchant's payment provider requires a vaulted payment method on the request | | `payment_method_not_found` | 404 | The `payment_method.id` does not exist or does not belong to this merchant | This table covers the error codes you are most likely to encounter; the full catalog is larger and continues to grow. Rate-limit buckets are documented on the [Rate Limits](rate-limits.md) page. ## Validation Errors (400) Validation errors include a descriptive message from the schema validator. The `error` field carries the raw validator output — a JSON array of issue objects, each with the failing field's `path` and a message: ```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_error", "fix": "Ensure 'amount' is a positive integer in minor units (e.g., 1499 for $14.99)", "docs": "https://docs.vonpay.com/reference/error-codes#validation_error" } ``` 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` is always required. `amount` is required for payment-mode sessions (the default); a setup-mode (card-on-file) session may omit it. ## 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 Each error code emitted in a response body's `docs` field links to its section below. Each section gives the HTTP status, the cause, and the fix. ### 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`. ### auth_key_type_forbidden **HTTP:** 403. Primary cause: a publishable key (`vp_pk_*`) used against a secret-only endpoint like `GET /v1/sessions/:id`. Also fires on sandbox/live-mode mismatches — for example, a sandbox key attempting to create a live payment session. 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, but your merchant account has not yet completed onboarding review, so live-key issuance is blocked. Test-mode keys (`vp_sk_test_*`) remain self-serve at any time via `/dashboard/developers` → *Create sandbox*; live keys can only be minted once your account reaches the ready-to-transact (or live) state. If review is still in progress, check `/dashboard/settings` for your application status and any outstanding requirements; if your application was declined, contact Von Payments support with the details provided in your denial notice. Distinct from `auth_merchant_inactive` (401), which fires on a previously-approved account that has since been deactivated. ### 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. ### session_expired **HTTP:** 410. The session expired (TTL configurable at create — default 30 minutes, range 5 minutes to 7 days), **or** it reached a terminal state with no successful charge (`failed` / `cancelled`). 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 `…/confirm`) — or a session retrieve — hit a session that already `succeeded`. The buyer was charged **exactly once** and it's recorded. This is a duplicate or late call (a retry, double-submit, or page reload). **Do not** retry, and **do not** create a new session for the same purchase — either would charge the buyer again. Treat it as success: read the result from your `payment_intent.succeeded` / `charge.succeeded` webhook or `GET /v1/public/sessions/:id` (status `succeeded`). This is a distinct **409 Conflict** (the session is in a terminal `succeeded` state) — not `session_expired`'s **410 Gone** — so even a status-only client won't "start over" into a re-charge. Branch on the `code` for the remediation. ### 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. Rare; indicates session metadata in the database no longer matches an invariant the runtime expects. Capture the `X-Request-Id` and contact support — this is not safely retriable without investigation. ### 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`. ### validation_missing_field **HTTP:** 400. A required field is missing. See [Create a Session](../integration/create-session.md) for the required fields. ### 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 live payment provider configured and is not in sandbox mode, so the requested operation cannot be routed to a provider. The merchant (not the integrator) must complete onboarding to attach a payment provider. To keep building in the meantime, switch to a sandbox API key (`vp_sk_test_*`) — test mode runs without a configured live provider. Not retriable as-is. ### 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; an operation supported on one provider may not be on another, and switching providers requires re-onboarding. Not retriable without a different capability or provider. ### rate_limit_exceeded **HTTP:** 429. The generic rate-limit code, emitted on the IP-axis limiter (and the global primary limiter). Retry after the `Retry-After` interval. The SDK auto-retries up to `maxRetries` times. The per-API-key axis has its own distinct code, `rate_limit_exceeded_per_key`. ### rate_limit_exceeded_per_key **HTTP:** 429. Per-API-key bucket exceeded on `POST /v1/sessions` (30 session creates/min). 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 — the SDK auto-retries with backoff. ### 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. 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. ### internal_error **HTTP:** 500. Unexpected server error. Capture the `X-Request-Id` and contact support. ### webhook_missing_signature **HTTP:** 401. An inbound provider webhook arrived without the provider's expected signature header. This error is internal to Von Payments' inbound webhook handling, not something your endpoint emits. (The signature on webhooks **Von Payments sends to you** is the `x-vonpay-signature` header — verify it with the SDK's `webhooks.verifySignature`; see below.) ### webhook_invalid_signature **HTTP:** 401. Webhook signature does not match the expected HMAC. When verifying webhooks Von Payments delivers to you, HMAC the **raw** request body (not the parsed JSON), using your endpoint or subscription signing secret (`whsec_…`) — **not** your API key. See [Webhook Signature Verification](../integration/webhook-verification.md). ### webhook_not_configured **HTTP:** 503. Webhook verification secret is not configured on the Von Payments server side. This is an infra-level misconfiguration, not a merchant-side issue. Capture the `X-Request-Id` and contact support. ### 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. This is **retriable**: 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. ### 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 yet available for this merchant's payment provider. Capability-gated — read `client.capabilities.get()` and check `supportedOperations` before invoking optional operations like `paymentIntents.capture`, `paymentIntents.void`, or `refunds.create` with partial amounts. The capability matrix is provider-dependent; an operation that works on one provider may not work on another. Available in SDK 0.5.0+. Not retriable — the operation will not succeed without a different capability or provider. ### 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. Available in SDK 0.5.0+. ### 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`, with values such as `already_captured`, `already_voided`, `not_authorized`, and `terminal_state` — so the SDK / agent can branch without a follow-up retrieve. Available in SDK 0.6.0+. Not retriable as-is — fix the request (e.g., call `refunds.create` instead of `paymentIntents.void` once the intent has captured). ### 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. --- ### 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_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. ### 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. --- ## Decline reasons (`failure_code`) A declined charge surfaces two ways: - **Synchronously** — the API returns `402` [`provider_charge_failed`](#provider_charge_failed). - **Asynchronously** — a [`charge.failed`](../integration/webhook-events.md#chargefailed) or [`payment_intent.failed`](../integration/webhook-events.md#payment_intentfailed) webhook fires, carrying three fields (all `string | null`): - `failure_code` — the **normalized** decline reason to branch on. - `failure_reason` — a human-readable summary to show the buyer. - `network_decline_code` — the raw issuer code (e.g. `05`, `51`); for logging/analytics only. Branch on `failure_code`. It is normalized to a fixed vocabulary of values. The full set and recommended handling: | `failure_code` | Meaning | Retry the same card? | Recommended next step | |---|---|---|---| | `card_declined` | Generic issuer decline, no specific reason given. | Unlikely to help | Prompt for a different payment method. | | `insufficient_funds` | The card lacks available funds. | Not now | Suggest a different card; the same card may succeed later. | | `expired_card` | The card has passed its expiry date. | No | Ask the buyer to re-enter a valid card. | | `incorrect_cvc` | The CVC/security code was wrong. | Maybe | Ask the buyer to re-enter the security code. | | `incorrect_zip` | The billing postal code did not match. | Maybe | Ask the buyer to re-enter the billing ZIP/postal code. | | `card_velocity_exceeded` | The card hit an issuer velocity limit. | Not now | Suggest a different card or trying again later. | | `fraudulent` | 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` | The card was reported stolen. | No | Show a generic decline message; suggest a different method. | | `lost_card` | The card was reported lost. | No | Show a generic decline message; suggest a different method. | | `do_not_honor` | The issuer declined without a specific reason. | Unlikely to help | Prompt for a different payment method or contacting the issuer. | | `issuer_unavailable` | The issuer could not be reached. | Yes | Safe to retry shortly; if it persists, try a different method. | | `processing_error` | A transient processing failure. | Yes | Safe to retry; if it persists, try a different method. | | `generic_decline` | An unmapped or unrecognized raw decline. | Unlikely to help | Prompt for a different payment method. | Show `failure_reason` to the buyer so they can act on it; keep `network_decline_code` out of buyer-facing copy. The `failure_code` vocabulary above is fixed; an unmapped raw code is normalized to `generic_decline` before it reaches you, so you will always receive one of these values. Trigger each of these deterministically with the [test cards](test-cards.md). Note that the 3-D Secure / fraud decline test card surfaces `failure_code` as `fraudulent` on the wire. --- ## /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 - **[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, `ss_*` session-signing, `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 --- ## 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 | Yes | Current lifecycle status. One of `requires_action`, `authorized`, `captured`, `succeeded`, `voided`, `failed` (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 | 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 | **Embedded-checkout** 3DS handle: `{ binder, client_secret }`. Populated when `status` is `requires_action` on gateways that support client-side confirmation. Forward `client_secret` to the browser SDK (`collection.submit({ paymentIntent: { id, action: { client_secret } } })`). Coexists with `next_action`. | | `decline_code` | string \| null | No | Normalized decline reason on a `failed` intent (see [Decline codes](#decline-codes)). | | `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. | | `created_at` | string \| null | No | ISO 8601 creation timestamp. | | `metadata` | object | No | Merchant-provided key-value pairs you set at creation. | | `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. | :::note Vendor-neutral by design The payment intent has the **same shape regardless of which gateway processes it** — your code never branches on a processor. Raw vendor decline codes are normalized into `decline_code`; `client_confirm` exposes only a `client_secret` you forward verbatim. Processor selection happens server-side via [VORA](../concepts/vora.md). ::: ## Status lifecycle | Status | Meaning | |--------|---------| | `requires_action` | A 3D Secure (or other) challenge must complete before the intent can settle. Resolve `next_action` (redirect flow) or `client_confirm` (embedded flow). | | `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 — money has moved. | | `succeeded` | Authorized **and** captured in one step (`capture_method: "automatic"`). Money has moved. | | `voided` | An `authorized` intent was cancelled before capture. No money moves. | | `failed` | The authorization or capture failed. See `decline_code` for the reason. | ``` ┌──► captured ──► (refund via POST /v1/refunds) requires_action ──► authorized ──► voided │ └──► (manual capture only) ▼ succeeded ◄── (automatic capture) │ failed (decline_code set) ``` 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): `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` · `generic_decline` It's `null` on any non-failed intent. See [Error codes](error-codes.md) for the full decline-handling guidance. ## 3D Secure: `next_action` vs `client_confirm` A `requires_action` intent populates **one or both** of these, depending on how you integrate: - **Server-redirect** integrations read `next_action.redirect_to_url.url` and send the buyer there. - **Embedded-checkout** integrations read `client_confirm.client_secret` and forward it to the browser SDK, which renders the harmonized 3DS modal in place. Both can populate on the same response — consume the one that matches your integration. `client_confirm.client_secret` is **bearer-equivalent** for the intent it's bound to; never log or persist it beyond the request/response round-trip. 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 Von Payments enforces rate limits via sliding-window counters. Most limits are per-IP; the session-create limit additionally has a per-API-key axis. ## Buckets | Bucket | Endpoint(s) | Limit | |---|---|---| | `sessions` | `POST /v1/sessions`, `POST /api/sessions` | 10 / 60s per IP | | `sessionsPerKey` | `POST /v1/sessions` (per-API-key axis) | 30 / 60s per key | | `sessionRead` | `GET /v1/sessions/:id`, `GET /api/checkout/session` | 30 / 60s per IP | | `checkoutInit` | `POST /api/checkout/init`, `POST /api/checkout/complete` | 20 / 60s per IP | | `webhooks` | `POST /api/webhooks/*` (provider inbound) | 100 / 60s per IP | | `clientError` | `POST /api/checkout/client-error`, `POST /api/csp-report` | 10 / 60s per IP | | `admin` | `POST /api/admin/*`, `POST /api/merchant-accounts`, `/api/cron/*` | 5 / 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. A session-create request can be rate-limited on either the per-IP `sessions` bucket or the per-key `sessionsPerKey` bucket. The per-IP rejection emits `rate_limit_exceeded`; the per-key rejection emits `rate_limit_exceeded_per_key` so SDKs can tell them apart and react differently. ## Response headers `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` are emitted on **both successful responses and `429`s** — the proxy sets them when it admits a request and when it rejects one. `Retry-After` is emitted on **`429` responses only**. | 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 only | Seconds to wait before retrying | ## Handling 429 ```json { "error": "Too many session creation requests", "code": "rate_limit_exceeded", "fix": "Too many requests — wait and retry (see Retry-After header)", "docs": "https://docs.vonpay.com/reference/api#rate-limits" } ``` 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 ## Hitting the per-key ceiling If you legitimately need to exceed 30 session creates/minute per API key (e.g. high-volume batch fulfillment), contact support. The per-key limit is platform-protective, not commercial — we can raise it for real integrations. --- ## /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_*`). Available in SDK 0.11.x. ## 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. ```bash curl https://checkout.vonpay.com/v1/refunds \ -H "Authorization: Bearer vp_sk_test_YOUR_KEY" \ -H "Content-Type: application/json" \ -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 }); ``` ### 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`. | | `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 | — | One of `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` ```json { "id": "vpr_test_JL3xPcFktvsF10Ib", "payment_intent": "vpi_test_abc123", "amount": 500, "currency": "USD", "status": "succeeded", "reason": null } ``` | Field | Type | Description | |-------|------|-------------| | `id` | string | Refund record ID (`vpr_test_*` / `vpr_live_*`). | | `payment_intent` | string | The payment intent this refund applies to. | | `amount` | integer | Refunded amount, in minor units. | | `currency` | string | ISO 4217 (uppercase on response). | | `status` | string | `requested`, `succeeded`, `failed`, or `canceled`. | | `reason` | string \| null | The reason supplied on create, or `null`. | ## Status A refund record is created in `requested` 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`. | ## Void-after-capture To reverse a captured intent, check the [`void_after_capture`](capabilities.md#supported_operations) capability. When it is `rerouted_to_refund`, a void on a captured intent is handled through the refund pathway — call `refunds.create` rather than `paymentIntents.void`. ## Reconciliation Each refund fires a [`charge.refunded`](../integration/webhook-events.md#chargerefunded) webhook carrying `refund_id`, `amount` (this refund), `is_partial`, and `original_charge_amount`. To get the cumulative refunded total for a charge, sum `amount` across all `charge.refunded` events for the same `transaction_id`. ## 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` --- ## /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 ``` - **Test keys** (`vp_sk_test_xxx`) — sandbox only, no real charges - **Live keys** (`vp_sk_live_xxx`) — production, real payments Keep your API key secret. If compromised, contact Von Payments to rotate it. ### Key Rotation {#key-rotation} Secret keys can be rotated without downtime. When a key is rotated, the previous key enters a **24-hour grace window** 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. - **Plain deactivation** (`is_active=false` with no rotation metadata) returns `auth_invalid_key` - **Force-deactivation mid-rotation** (flipping `is_active=false` while grace/expiry metadata is set) returns `auth_key_expired` — the deactivation is treated as an accelerated rotation, not a plain revocation Rotate keys via `/dashboard/developers/api-keys`. The UI shows the new plaintext exactly once at creation — store it immediately. ## HMAC Return URL Signatures When a buyer completes payment and is redirected to your `successUrl`, the URL includes a signature: ``` ?session=vp_cs_live_xxx&status=succeeded&amount=1499¤cy=USD&transaction_id=txn_abc&sig=a1b2c3... ``` ### Algorithm ``` sig = HMAC-SHA256( key: VON_PAY_SESSION_SECRET, data: "{session}.{status}.{amount}.{currency}.{transaction_id}" ) ``` The signature is the lowercase-hex digest. When `transaction_id` is absent, an empty string is substituted in its place (the data string uses nullish coalescing — only `null`/`undefined` become `""`). ### Verification **Always verify server-side.** The return URL is visible to the buyer and can be modified. Use `crypto.timingSafeEqual` (or equivalent) to prevent timing attacks: ```typescript function verify(session, status, amount, currency, transactionId, sig, secret) { const data = `${session}.${status}.${amount}.${currency}.${transactionId ?? ""}`; const expected = crypto.createHmac("sha256", secret).update(data).digest("hex"); return crypto.timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex")); } ``` ## PCI Compliance Von Payments is **PCI SAQ-A** compliant: - Card data is entered in a secure iframe hosted by the payment processor - In the live hosted and embedded flows, card numbers, CVVs, and expiry dates **never touch** Von Payments servers or your servers - The checkout page's Content Security Policy prevents any script from reading the payment iframe **You do not need PCI certification** to use Von Payments. All three front-end integration paths (Hosted Checkout, Embedded Fields, and Elements) using the server-side Payment Intents engine keep real card data inside the iframe boundary; your PCI scope stays at SAQ-A (the simplest level). The only path that would pull you out of SAQ-A is sending raw card data through your own server — and our API does not accept real card data for processing. > **Sandbox note:** In test mode you can submit a published, synthetic **test card number** to the sandbox emulator to exercise the flow without real card data. This path is sandbox-only, never reaches a real processor, and does not collect CVV or expiry. Live keys cannot use it. ## 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) | | Return URL signatures | Deterministic HMAC-SHA256, keyed by the session signing secret | ## 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 API endpoints are rate-limited. **Per-IP is the default axis**: when a request carries no credential, the limiter keys on the client IP. For many endpoints the primary axis is instead keyed on the API key, the merchant, or a signed-URL token (with IP used only as a fallback when no credential is present). The session-create endpoint layers a **per-API-key** axis on top of its per-IP axis. A few internal routes (such as the shallow health check) are unmetered. See [Rate Limits](rate-limits.md) for buckets and handling. ## API Versioning The API uses date-based versioning via the `Von-Pay-Version` header: ``` Von-Pay-Version: 2026-04-14 ``` - If the header is omitted (or carries an unrecognized value), the **current/latest** platform-wide API version is used - Pin this header to a specific date to prevent breaking changes when the API evolves - New versions are announced in the changelog before becoming the default ## 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... ``` Verification rules: 1. Capture the raw request body **as bytes** before any JSON parsing 2. Form `signed_payload = t + "." + raw_body` 3. Compute `v1_expected = lowercase_hex(HMAC_SHA256(key: whsec_secret_as_utf8_bytes, message: signed_payload))` 4. Constant-time compare against each `v1=` value in the header (the header may carry two `v1=` entries during a rotation window; accept on any match, reject if there are more than two) 5. Reject if `now - t > 300` (older than 5 min) or `t - now > 30` (more than 30 sec in the future) Reference verifiers in Node, Python, Go, Ruby, and PHP are at [Webhook Signature Verification](../integration/webhook-verification.md). Use the published verifiers as-is rather than hand-rolling — the canonical implementation handles multi-secret rotation, length-safe constant-time compares, and asymmetric replay-window enforcement. > **Important:** Each webhook endpoint has its own `whsec_*` signing secret. It is NOT your API key (`vp_sk_*`) and NOT the session signing secret (`ss_*`). The webhook signing secret is used only for webhook signatures, and your API key is used only for outbound API auth. The session signing secret backs return URL signatures (and other server-side session integrity checks) — never use it as your webhook or API credential. See [Webhook Signing Secrets](../integration/webhook-secrets.md) for the create / rotate / revoke lifecycle. ## 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 | Yes | Current status: `pending`, `processing`, `succeeded`, `failed`, `expired` | | `mode` | string | Yes | Payment mode (`payment`) | | `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 | No | ISO 3166-1 alpha-2 country code (optional) | | `description` | string | No | Human-readable description of the payment (max 500 characters) | | `successUrl` | string | No | Redirect URL on success — echoes the value supplied at creation (`null` if none) | | `cancelUrl` | string | No | Redirect URL on cancel — echoes the value supplied at creation (`null` if none) | | `collectShipping` | boolean | No | Whether to collect shipping address on checkout page | | `shippingAddress` | object | No | Buyer's shipping address (only present when status is `succeeded`) | | `transactionId` | string | No | Payment provider's transaction ID (set on completion) | | `metadata` | object | 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 | :::note Buyer and reusable tokens Vaulting a reusable card-on-file token through the embedded charge-and-save flow requires a buyer on the session. With a buyer attached, `submit()` charges the card **and** returns a reusable `vp_pmt_*` token in one step. Guest / no-buyer sessions charge once and vault nothing — `submit()` resolves with a charge-only result (`charged: true` along with `last4` and `brand`) and no token. Set the buyer when you [create the session](../integration/create-session.md). ::: ### Payment routing (VORA) Processor selection happens server-side inside Von Payments. The merchant API does **not** expose which processor was used — neither `POST /v1/sessions` nor `GET /v1/sessions/:id` returns processor identifiers. Your integration is identical regardless of which processor fires the charge. See [VORA — Payment Routing](../concepts/vora.md) for context. ## 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. The reverse, however, is allowed: a `failed` session that the buyer retries successfully converges to `succeeded`. - A session can only be used **once** — no replays. - 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. --- ## /reference/test-cards Source: https://docs.vonpay.com/reference/test-cards # Test Cards Use these card numbers in test mode to simulate different payment outcomes. Any future expiry date and any 3-digit CVC will work (Amex takes a 4-digit CID). These are the canonical industry numbers supported across major card-acceptance sandboxes (Stripe, Adyen, Worldpay, etc.). The same numbers work whether your sandbox merchant routes through a real-processor sandbox OR the Vonpay-owned sandbox iframe served via `vora.js` for embedded checkout. For the **hosted-checkout** sandbox today (mock gateway), outcomes are chosen by session **amount** — see [Sandbox & Test Mode → Sandbox outcomes](../guides/sandbox.md#sandbox-outcomes--deterministic-by-amount). For the **embedded-checkout** sandbox (sandbox iframe via `vora.js`), outcomes are chosen by **card number** per the table below. The sandbox iframe **locks down to these numbers only** — any other input is rejected, both as a real-card safety lock and to keep outcomes deterministic. Embedded fields are collected and submitted through the canonical collection path — `const elements = vora.elements.create(); const card = elements.create("card", {}); card.mount("#card-element"); const result = await elements.submit();` — not a single-field `tokenize()` call. ## Card Numbers | Card | Brand | Outcome | Webhook event | Use for | |------|-------|---------|---------------|---------| | `4242 4242 4242 4242` | Visa | Success | `payment_intent.succeeded` | Happy path | | `5555 5555 5555 4444` | Mastercard | Success | `payment_intent.succeeded` | Mastercard testing | | `3782 822463 10005` | Amex | Success | `payment_intent.succeeded` | Amex testing | | `4000 0000 0000 0002` | Visa | Generic decline | `payment_intent.failed` (`failure_code: card_declined`) | Generic error handling | | `4000 0000 0000 9995` | Visa | Insufficient funds | `payment_intent.failed` (`failure_code: insufficient_funds`) | Buyer-fundable error path | | `4000 0000 0000 0069` | Visa | Expired card | `payment_intent.failed` (`failure_code: expired_card`) | Re-prompt UX | | `4000 0000 0000 0119` | Visa | Processing error | `payment_intent.failed` (`failure_code: processing_error`) | Transient-failure UX | | `4000 0027 6000 3184` | Visa | 3DS required → success | `payment_intent.succeeded` | Happy-path 3DS | | `4000 0084 0000 0029` | Visa | 3DS required → fail (fraud) | `payment_intent.failed` (`failure_code: fraudulent`) | 3DS challenge rejection | ## Synthetic token shape (embedded sandbox) When the embedded sandbox submits a test card and the charge succeeds, the result carries a final synthetic `vp_pmt_test_*` token (`tokenIsFinal: true`) with the outcome encoded in the middle segment. The sandbox iframe mints this synthetic token on every success branch — it does not emulate the production binder's guest charge-only path. Still read the submit result defensively as the 3-way `{ token } | { charged: true } | { error }` union, never `result.token` unconditionally, so the same handler works against a live binder: | Card outcome | Token shape | |---|---| | Success | `vp_pmt_test_success_` | | Decline | `vp_pmt_test_decline__` (e.g. `vp_pmt_test_decline_insufficient_funds_abc123`) | | 3DS required | `vp_pmt_test_3ds_success_` or `vp_pmt_test_3ds_fail_` | The encoded outcome means the server-side mock binder recognises the intended payment-intent result from the token prefix alone — no DB lookup, no card-number round-trip. The canonical map is in the `@vonpay/test-cards` workspace package; both the iframe and the server import from there so the list cannot drift. `vp_pmt_test_*` tokens are sandbox-only — a test token used with a live-mode key is rejected with HTTP `400` `payment_method_mode_mismatch` (the cross-mode guard). (Don't confuse this with `payment_method_inactive`, which is HTTP `422` and means the token was revoked.) ## Lifecycle ops with synthetic tokens Every existing payment-intent lifecycle op works against a synthetic success token. The same mock binder that handles hosted sandbox today extends to embedded via the token-prefix recognition: | Operation | Sandbox behavior with `vp_pmt_test_success_*` | Webhook fired | |---|---|---| | `POST /v1/payment_intents` (auto-capture) | → `succeeded` | `payment_intent.succeeded` **then** `charge.succeeded` | | `POST /v1/payment_intents` (`capture_method=manual`) | → `authorized` | `payment_intent.succeeded` (the authorization; capture is separate) | | `POST /v1/payment_intents/:id/capture` | → `succeeded` | `charge.succeeded` | | `POST /v1/payment_intents/:id/void` | → `voided` | `payment_intent.cancelled` | | `POST /v1/refunds` | → refund settled | `charge.refunded` | ## Access via MCP AI agents can retrieve test cards using the `vonpay_checkout_list_test_cards` tool. See [MCP Server](../sdks/mcp.md) for setup. --- ## /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_*`). Available in SDK 0.11.0. ## 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). | | `provider_reference` | string | — | 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 for live tokenization** (see below); ignored in sandbox. Max 255 chars. | | `card_last4` | string | — | Last four digits, for display. | | `card_brand` | string | — | Card network — one of `visa`, `mastercard`, `amex`, `discover`, `unionpay`, `jcb`, `diners`, `unknown`. | | `exp_month` | integer | — | 1–12. | | `exp_year` | integer | — | Four-digit year (≥ current year). | | `metadata` | object | — | Record with string keys and arbitrary JSON values (size-capped, scrubbed). | | `setup_for_future_use` | string | — | `off_session` or `on_session`. | **Sandbox vs. live:** - **Sandbox keys** auto-mint a mock card token when no card data is supplied — useful for SDK examples. `provider_reference` is ignored. - **Live keys** require a `provider_reference` (the payment method must already exist at the gateway). Without it, the request returns `400 validation_error`. ### 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` or `revoked`. | | `card.brand` | string | Card network (e.g. `visa`). | | `card.last4` | string | Last four digits. | | `card.exp_month` | integer | 1–12. | | `card.exp_year` | integer | Four-digit year. | ## 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) | A live key was used without `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 the `payment_method` when creating a payment intent. 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 Cards](test-cards.md) — sandbox token shapes --- ## /reference/versioning Source: https://docs.vonpay.com/reference/versioning # API Versioning Von Payments uses date-based API versioning to ensure backward compatibility while allowing the API to evolve. ## 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") ``` Pin your SDK to a specific version to avoid unexpected behavior when new versions are released. 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 Von Payments aims for a predictable deprecation window so integrations have time to migrate before anything breaks. ### How deprecation is signalled When a request uses a legacy field shape that has been superseded, the API still processes the request normally and returns its usual success status, but attaches [RFC 8594](https://www.rfc-editor.org/rfc/rfc8594) deprecation headers to the response so you can detect the legacy usage in production. Today the only surface that emits these headers is `POST /v1/sessions`, on the legacy Embedded Fields field path — requests that pass the older stringified `metadata.mirror` value instead of the current top-level `mirror` field. Such a request still succeeds with `201 Created` and 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` — an HTTP-date marking when the legacy shape may stop being accepted. For the legacy Embedded Fields field, that date is `Wed, 27 Aug 2026 00:00:00 GMT`. - `Link` — a documentation URL describing the replacement, with `rel="deprecation"`. There is no blanket `Deprecation` header on "every affected endpoint," and a deprecated field is **not** met with `410 Gone` — the legacy request continues to work until its sunset date. Watch for a `Deprecation` header on your responses in production so you find out about deprecated usage before its sunset arrives. ### Notice commitments - **No silent removals.** A breaking change ships in a dated version and is signalled in-band through RFC 8594 `Deprecation` / `Sunset` / `Link` response headers on the affected request shape, plus release notes in the changelog. - **Authentication changes** (key prefix, signing algorithm, header format) carry the longest notice window given their blast radius. *(Forward-looking policy intent; the only mechanism exercised in the API today is the RFC 8594 header signal above.)* ### Dated version support Currently there is exactly one supported API version, `2026-04-14`, and the server keeps a single-version allowlist. There is no previous-version cohort or "N-2 and older" tier to deprecate yet, because only one dated version has ever been issued. When additional dated versions are introduced, each breaking change will be announced through the channels above before the prior version is retired. Pin your SDK to a specific `apiVersion` (see [SDK Pinning](#sdk-pinning)) so a future default version doesn't change behavior for your integration until you explicitly bump. ## Changelog The full version changelog is maintained at [`openapi/CHANGELOG.md`](https://github.com/Von-Payments/vonpay/blob/master/openapi/CHANGELOG.md) in the repository. --- ## /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_*`) | | `--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 | The three `--mit-*` flags are all-or-nothing: supply all three to attach a merchant-initiated-transaction block, or none. ### `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. ```bash vonpay checkout refunds create --payment-intent vpi_test_abc123 ``` | Flag | Required | Description | |------|----------|-------------| | `--payment-intent` | Yes | Payment intent ID to refund (`vpi_*`) | | `--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 | ### `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 for live keys with iframe-vault providers) | | `--metadata` | Metadata `key=value` (repeat the flag for multiple pairs) | | `--idempotency-key` | Idempotency key for safe retries | | `--json` | Output raw JSON | With a test key, omitting `--provider-reference` auto-mints a mock card token. With a live key, an iframe-vault provider requires a browser-minted vault token, so the server returns a `422 validation_error` if neither `--buyer-id` nor `--provider-reference` is supplied. ### `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 session.succeeded --url http://localhost:3000/webhooks/vonpay ``` Supported events: `session.succeeded`, `session.failed`, `payment_intent.succeeded`, `payment_intent.failed`, `payment_intent.cancelled`, `charge.refunded`. Any other event name is rejected. 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 | | `--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`) | ### `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, and opt-in strict-mode `constructEventV2` / `construct_event_v2`. 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`, webhook signature verification, the signed-return-URL `verifyReturnSignature` helper (v2 with `expectedSuccessUrl` / `expectedKeyMode` / `maxAgeSeconds`), 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 Current published versions. All packages are pre-1.0 — pin to an exact version in production. | Package | Version | |---|---| | `@vonpay/checkout-node` | 0.11.0 | | `vonpay-checkout` (PyPI) | 0.11.0 | | `@vonpay/vora-js` (CDN only) | 1.9.0 | | `@vonpay/checkout-cli` | 0.5.1 | | `@vonpay/checkout-mcp` | 0.4.7 | ## Support matrix - **Node:** ≥ 20 (ESM only; no CJS export path) - **Python:** ≥ 3.9 (httpx 0.27+) ## Source The `vonpay` repository at [github.com/Von-Payments/vonpay](https://github.com/Von-Payments/vonpay) holds all six packages, the OpenAPI spec, sample integrations (Express, Flask, Next.js), and agent templates. --- ## /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 ### `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_*`) | ### `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` List all available test card numbers. No parameters. See [Test Cards](../reference/test-cards.md) for the full table. ### `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 Moves money — requires human approval on live keys **⚠ MOVES MONEY — REQUIRES HUMAN APPROVAL ⚠** With the default `captureMethod: "automatic"` this charges the buyer immediately (→ `succeeded`), and an `mit` block charges a saved credential with **no buyer present**. An MCP host **should require explicit human confirmation** before invoking with a **live** key (`vp_sk_live_*`) — especially for automatic-capture or MIT charges — since an unconfirmed (e.g. prompt-injected) call moves real money. Sandbox keys (`vp_sk_test_*`) are safe to call without confirmation. ::: | 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 | No | Idempotency key to make the charge safely retryable — same key returns the same intent rather than charging twice | ### `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 Moves money — requires human approval on live keys **⚠ DESTRUCTIVE — REQUIRES HUMAN APPROVAL ⚠** This call transfers funds from the buyer's account to the merchant. An MCP host **should require explicit human confirmation** before invoking. Live keys (`vp_sk_live_*`) move real money; sandbox (`vp_sk_test_*`) is safe to call without confirmation. ::: | 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 | No | Idempotency key for safe retries | ### `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 Irreversible — requires human approval on live keys **⚠ DESTRUCTIVE — REQUIRES HUMAN APPROVAL ⚠** Voiding releases the auth hold **irreversibly** — the order cannot be captured afterwards. An MCP host **should require explicit human confirmation** before invoking; an automated void leaves a real order unfulfilled with no recovery path short of re-prompting the buyer for payment. Live keys (`vp_sk_live_*`) affect real money flows; sandbox (`vp_sk_test_*`) is safe to call without confirmation. You cannot void a captured intent on most binders — read `supportedOperations.voidAfterCapture` first; when its policy is `rerouted_to_refund`, prefer `vonpay_checkout_create_refund` instead. ::: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `paymentIntentId` | string | Yes | The payment intent to void (`vpi_test_*` or `vpi_live_*`) | | `idempotencyKey` | string | No | Idempotency key for safe retries | ### `vonpay_checkout_create_refund` Refund a captured payment intent (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, and the returned `Refund` `status` is `pending`, `succeeded`, or `failed`; pending refunds settle asynchronously via the `charge.refunded` webhook. :::warning Irreversible once settled — requires human approval on live keys **⚠ DESTRUCTIVE — REQUIRES HUMAN APPROVAL ⚠** This returns funds from the merchant to the buyer; **once settled, a refund cannot be reversed** by either party. An MCP host **should require explicit human confirmation** before invoking. Live keys (`vp_sk_live_*`) move real money; sandbox (`vp_sk_test_*`) is safe to call without confirmation. ::: | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | `paymentIntent` | string | Yes | The payment intent to refund (`vpi_test_*` or `vpi_live_*`) | | `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 | No | Idempotency key for safe retries | ### `vonpay_checkout_create_token` Vault a card for later reuse (wraps `POST /v1/tokens`). Returns a `vp_pmt_(test|live)_*` payment-method token. :::warning Requires human approval for reusable vaulting With `setupForFutureUse: "on_session"` or `"off_session"` this mints a **reusable** credential that can be charged later with no buyer present. An MCP host **should require explicit human confirmation** before invoking it with either reusable scope on a **live** key — an unconfirmed (e.g. prompt-injected) call could vault a chargeable credential. Single-use vaulting (scope omitted) and sandbox keys (`vp_sk_test_*`) are low-risk. ::: | 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). **Omit for single-use** — subsequent CIT/MIT charges then return `payment_method_consent_missing` (422). | | `providerReference` | string | No | Iframe-minted vault handle from the browser-side card input (≤512 chars). **Required on live keys** for iframe-vault providers; optional in sandbox (a mock token is auto-minted if omitted). | | `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. | 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` (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, ESM-only, Node 20+. ## Install ```bash npm install @vonpay/checkout-node ``` Pinning to an exact version is recommended during the pre-1.0 window — minor bumps may add options or change defaults. ## 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_attempt_1", // 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" ``` `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 `session.succeeded` webhook before fulfilling. See [Charge and save](../embedded-fields/charge-and-save.md) 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`, `succeeded`, `failed`, and `expired`. The server can also send `processing` over the wire (a transient state during authorization); the SDK `SessionState` type doesn't include it, so handle it as a non-terminal/default case rather than in an exhaustive `switch` over `SessionState`. --- ## 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.transaction_id}`); break; case "charge.failed": console.log(`Failed: ${event.data.failure_reason}`); break; case "charge.refunded": console.log(`Refund: ${event.data.amount}`); 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. --- ## VonPayCheckout.verifyReturnSignature(params, secret, options?) Static method. Verify the HMAC signature on a return URL redirect after the buyer completes checkout. Auto-detects v1 (legacy) and v2 (current) signature formats. ```typescript const url = new URL(req.url, `https://${req.headers.host}`); const isValid = VonPayCheckout.verifyReturnSignature( { session: url.searchParams.get("session"), status: url.searchParams.get("status"), amount: url.searchParams.get("amount"), currency: url.searchParams.get("currency"), transaction_id: url.searchParams.get("transaction_id"), sig: url.searchParams.get("sig"), }, process.env.VON_PAY_SESSION_SECRET, // your session signing secret, NOT the API key { expectedSuccessUrl: "https://mystore.com/order/123/confirm", expectedKeyMode: "live", // "live" or "test" maxAgeSeconds: 600, // optional, default 600 }, ); ``` The secret is your session signing secret, **not** the API key. In the dashboard this secret is provisioned with an `ss_test_*` / `ss_live_*` prefix — copy it verbatim from `/dashboard/developers/api-keys`. Verification uses `crypto.timingSafeEqual` to prevent timing attacks. ### Options bag (v2 signatures) `expectedSuccessUrl` and `expectedKeyMode` are **required** when the incoming `sig` starts with `v2.`. Passing them for v1 signatures is harmless — they're ignored. | Option | Required for v2? | Default | Purpose | |---|---|---|---| | `expectedSuccessUrl` | Yes | — | The `successUrl` you passed to `sessions.create`. Normalised (trailing slash stripped, query sorted, fragment dropped). | | `expectedKeyMode` | Yes | — | `"test"` or `"live"`. Prevents test-mode sigs from being accepted as live. | | `maxAgeSeconds` | No | `600` | Maximum age of the signature in seconds. | | `rejectV1` | No | — | Refuse legacy v1 signatures outright. | See [Handle the Return](../integration/handle-return.md) for a full walkthrough of the v2 format and the rationale. --- ## vonpay.health() Check API health and latency (maps to `GET /api/health`). ```typescript const health = await vonpay.health(); // health.status => "ok" // "ok" | "degraded" | "down" // health.latencyMs => 42 ``` --- ## 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, opt-in SDK-telemetry 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. --- ## 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). --- ## 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. 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`, `amount`, `currency`, `card` | | `charge.failed` | `session_id`, `payment_intent_id`, `transaction_id`, `amount`, `currency`, `failure_reason`, `failure_code`, `network_decline_code`, `card` | | `charge.refunded` | `session_id`, `payment_intent_id`, `transaction_id`, `refund_id`, `amount`, `currency`, `reason`, `is_partial`, `original_charge_amount`, `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`, `network_decline_code` | | `payment_intent.cancelled` | `session_id`, `payment_intent_id`, `transaction_id`, `amount`, `currency`, `cancellation_reason` | 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`). ```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 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 ``` Pinning to an exact version is recommended during the pre-1.0 window — minor bumps may add options or change defaults. ## 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. ### Validate (dry run) ```python result = client.sessions.validate( amount=1499, currency="USD", ) # Validates parameters without creating a session ``` ## 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["transaction_id"]) # "vp_txn_9f2nd..." ``` ## 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="wh_sub_abc123", # 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("wh_sub_abc123") 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. ### Retrieve a stored webhook event ```python record = client.webhook_events.retrieve("evt_abc123") print(record.type) # "charge.succeeded" print(record.processed) # True | False print(record.retry_count) # int ``` Returns a `WebhookEventRecord` — the durable, queryable record of a stored event (distinct from the signed payload that `webhooks.construct_event` parses). Use it from your own code, e.g. an admin tool, retry trigger, or audit dashboard. Requires a secret key. ## Return URL Verification Verify the HMAC signature on the redirect back from checkout. This is a static method — no client instance needed. Auto-detects v1 (legacy) and v2 (current) signature formats. ```python params = { "session": request.args["session"], "status": request.args["status"], "amount": request.args["amount"], "currency": request.args["currency"], "transaction_id": request.args["transaction_id"], "sig": request.args["sig"], } is_valid = VonPayCheckout.verify_return_signature( params=params, secret=os.environ["VON_PAY_SESSION_SECRET"], # ss_test_* or ss_live_* expected_success_url="https://mystore.com/order/123/confirm", expected_key_mode="live", # "live" or "test" max_age_seconds=600, # optional, default 600 ) ``` The `secret` is the session secret (`ss_*` prefix), **not** the API key. ### Options (v2 signatures) `expected_success_url` and `expected_key_mode` are **required** when the `sig` starts with `v2.`. For v1 signatures they are ignored. | Option | Required for v2? | Default | Purpose | |---|---|---|---| | `expected_success_url` | Yes | — | The `success_url` you passed to `sessions.create`. Normalised (trailing slash stripped, query sorted, fragment dropped). | | `expected_key_mode` | Yes | — | `"test"` or `"live"`. Prevents test-mode sigs from being accepted as live. | | `max_age_seconds` | No | `600` | Maximum age of the signature in seconds. | See [Handle the Return](../integration/handle-return.md) for a full walkthrough of the v2 format. ## Health Check ```python health = client.health() print(health.status) # "ok" | "healthy" | "degraded" print(health.latency_ms) # float — measured dependency latency (ms) print(health.version) # server build/version string ``` Returns a `HealthStatus` dataclass with `status`, `latency_ms`, and `version`. ## 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#required-fields" ``` ## 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` / `construct_event_v2` verification failures (signature mismatch, stale timestamp, malformed v2 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. --- ## 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. --- ## 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 delivered event record | 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": "wsub_abc123", "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. ::: ### Selectable events The event types you can pass in `enabledEvents`: | Event | Fires when | |-------|-----------| | `charge.succeeded` | A charge is captured | | `charge.failed` | A charge attempt fails | | `charge.refunded` | A charge is refunded (full or partial) | | `payment_intent.succeeded` | A payment intent reaches `succeeded` | | `payment_intent.failed` | A payment intent fails | | `payment_intent.cancelled` | A payment intent is voided / cancelled | Other event types (`session.*`, `dispute.*`, `application.*`, `payout.*`, `merchant.ready_for_payments`) are delivered by Von Payments as platform-level events — they are **not** selectable via `enabledEvents` (you can't turn them on or off), but your endpoint may still receive them. The SDK's `WebhookEvent` type models them so your handler can switch on `type`. Always handle an unrecognized `type` gracefully. ### Update, rotate, delete ```bash # Pause without deleting (stops deliveries; keeps the config + secret) curl -X PATCH https://checkout.vonpay.com/v1/webhook_subscriptions/wsub_abc123 \ -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/wsub_abc123/rotate_signing_secret \ -H "Authorization: Bearer vp_sk_live_xxx" # Delete curl -X DELETE https://checkout.vonpay.com/v1/webhook_subscriptions/wsub_abc123 \ -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` | System-suspended (e.g. after sustained failures / auto-pause). Resume by patching to `active` once your endpoint is healthy. | ### Send a test event ```bash curl -X POST https://checkout.vonpay.com/v1/webhook_subscriptions/wsub_abc123/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. ### Refund event payload `charge.refunded` delivers the following under `data` (snake_case): | Field | Type | Notes | |-------|------|-------| | `transaction_id` | string | Settlement-ledger id of the original charge (`vp_tx_*`) | | `refund_id` | string \| null | Identifies which refund in a multi-partial sequence (`vpr_*`) | | `amount` | number | Refund amount in minor units — **not** the original charge total | | `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` | 10/min per IP, 30/min per API 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#required-fields", "selfHeal": { "retryable": false, "nextAction": "no_action", "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. --- ## /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", buyerName: "Jane Doe", buyerEmail: "jane@example.com", 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 | | `buyerId` | string | No | Your **stable, unique-per-user** account ID — same value every visit, never per-visit/random | | `buyerName` | string | No | Pre-fills billing form | | `buyerEmail` | string | No | Buyer's email | | `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. ## 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_*`). Publishable keys can only create checkout sessions — they cannot read session data, issue refunds, or perform any destructive operation. This is what makes it safe to ship in browser code. 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` you can branch on. This page is the **self-diagnose recipe** for the codes you'll hit most often. Each entry is structured so an AI agent can parse it directly: cause, ranked likely sources, the exact check to run, and when to escalate. > **Branch on the `code`, not just the HTTP status.** The remediation lives in the `code`. The clearest example is the pair with **opposite** fixes: `session_expired` (**410 Gone**, create a new session) vs `session_already_completed` (**409 Conflict**, you're done — creating a new session re-charges the already-paid buyer). Always read the `code`. ## How to read this page Every entry lists: - **What it means** — the contract this code expresses - **Likely causes** — ranked by frequency in real integrations - **Diagnose with** — the exact check that resolves the cause - **Next action** — one of the self-heal values: `retry` · `rotate_key` · `fix_request` · `wait_and_retry` · `contact_support` · `complete_onboarding` · `create_new_session` · `no_action` - **Retryable** — whether retrying the same call may succeed - **Escalate when** — the signal that says "this isn't a code-fix; ask support" These are the exact values the API returns in `selfHeal.nextAction` — see [For AI agents](#for-ai-agents). ## Quick reference | Code | HTTP | Next action | Retryable | |---|---|---|---| | [`auth_invalid_key`](#auth_invalid_key--http-401) | 401 | `rotate_key` | 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` | yes | | [`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). --- ## What to include when you contact support To let us correlate a transaction to a buyer and investigate fast (duplicate charges, "is this the same buyer?", disputes), include: - The **session ID** (`vp_cs_*`) or **transaction ID** (`vp_tx_*`). - The **`X-Request-Id`** header from the relevant API response. - The buyer's **`buyerId`** and **`buyerEmail`** — *as you sent them on the session*. The last point is the one integrators miss. If you send a **stable, per-user `buyerId`** plus **`buyerEmail`** on every session (see [Buyer identification](integration/create-session.md#buyer-identification)), we can link all of a buyer's transactions instantly. If `buyerId` changes per visit and no email is sent, a returning buyer can't be linked — which is exactly what makes duplicate-charge questions hard to answer. Pass clear buyer info up front and troubleshooting becomes a lookup. --- ## 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 / _timeout → buyer re-authenticates → re-run submit() │ ├─ frame_tokenization_failed → card rejected before charge → ask for a different card │ └─ 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 │ ├─ provider_unavailable (502) → transient → wait + retry (the SDK auto-retries) │ └─ 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 ``` **Rule of thumb:** - A **buyer** decline (the issuer said no — `provider_charge_failed`, `frame_payment_declined`, any `failure_code`) → surface a clear message and offer another method. Never silently retry the same card. - A **request** error (`validation_*`, `auth_*`, config) → fix the input or key, then retry. - A **transient** error (`provider_unavailable`) → the SDK already retried; wait and retry once more, then escalate with the `X-Request-Id`. The per-`failure_code` retry/message guidance is in [Decline reasons](reference/error-codes.md#decline-reasons-failure_code). --- # Auth & key errors ## `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 24h grace.** A previously-valid key was rotated and the grace window expired. The old key is permanently dead. 3. **Test/live mismatch.** A `vp_sk_test_*` key is hitting `checkout.vonpay.com` (live) or vice versa. Test keys only work in sandbox. **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_key_expired` — HTTP 401 **What it means:** A key was rotated and the previous key has passed its 24-hour grace window. **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 but you're still getting `auth_key_expired`. That implies a propagation issue with the auth-cache service. --- ## `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.** Either by ops (compliance / chargeback issues) or by the merchant themselves. 2. **Sandbox merchant in `pending_approval` state hitting live.** Test keys are scoped to sandbox merchants regardless of mode. 3. **`merchants.status` is `denied` or `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 an ops surface, 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 **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 just rotated and your handler hasn't picked up the new value** (the rare case — check this only after the three above). **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. That's a delivery-engine bug. --- # Onboarding & configuration ## `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 mock gateway.** "Activate VORA Sandbox" didn't run cleanly, or the merchant was issued test keys without atomic provisioning. 2. **Live merchant whose payment provider configuration was removed by ops.** **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 > **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 documented complete but live keys are still gated. That's an operational glitch. --- # Validation errors ## `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` for **payment** sessions. (Exception: **setup-mode** sessions — `mode: "setup"`, used to vault a card with no charge — may send `amount: 0` or omit it.) 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 **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 **What it means:** You exceeded a rate limit. Two distinct codes share the 429: - **`rate_limit_exceeded`** — the **per-IP** limit (e.g. `POST /v1/sessions` is **10 req / minute / IP**). - **`rate_limit_exceeded_per_key`** — the **per-API-key** limit (e.g. `POST /v1/sessions` is **30 / minute / key**; the payment-ops endpoints — `/v1/payment_intents`, `/v1/refunds`, `/v1/tokens` — are **100 / 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. **Diagnose with:** Read the `Retry-After` header and wait that long — 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 ## `provider_unavailable` — HTTP 502 **What it means:** The upstream payment provider is not responding. Transient. **Next action:** `wait_and_retry`  ·  **Retryable:** yes **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 **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 ## HTTP 410 — two opposite outcomes (branch on the `code`) A `410` can mean two things with **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 **already `succeeded`** — the buyer was charged exactly once and it's recorded. A distinct **409 Conflict** (vs `session_expired`'s **410 Gone**), with the **opposite** remediation. **Next action:** `no_action` (already done)  ·  **Retryable:** no **Likely causes:** A duplicate or late call after the payment already went through — a retry, a double-submit, or a buyer reloading the page after paying. **Diagnose with:** Do **not** retry and do **not** create a new session — either re-charges the buyer. Treat it as success: read the outcome from your `payment_intent.succeeded` / `charge.succeeded` webhook or `GET /v1/public/sessions/:id` (status `succeeded`). If your UI showed the buyer an error here, that's the bug to fix — surface a "payment complete" state instead. **Escalate when:** The buyer was charged but you have no `succeeded` record on retrieve or webhook after a few minutes (then it's a recording issue, not this). --- ## 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 **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:** This is the guest / no-buyer charge-only path under the charge-and-save flow. With no buyer attached to the session, the embed charges once and saves nothing — the result is `{ charged: true }`, not `{ token }`. There is no reusable `vp_pmt_*` to read. **Diagnose with:** Branch on the three-way result instead of assuming a token. The result is a discriminated union — `{ token } | { charged: true } | { error }`: ```typescript const result = await collection.submit(); if (result.error) { // tokenization / charge failed } else if (result.token) { // buyer attached — a reusable vp_pmt_* was vaulted } else if (result.charged) { // guest path — charged once, nothing vaulted; result.token is undefined by design } ``` **Fix:** Branch on `result.charged`. If you need a vaulted token, attach a buyer to the session so the submit returns `{ token }`. Do **not** treat the missing token as `frame_tokenization_failed` — the charge succeeded; this is the expected guest-path shape. **Escalate when:** Never. This is an integration fix. --- ## 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. ## 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` · `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. ## Option 2 — reproduce and verify with the MCP tools If you're running with `@vonpay/checkout-mcp` loaded, use the available tools to diagnose and verify a failure: - `vonpay_checkout_diagnose_error` — pass an error `code` (plus optional `status` / `requestId`) and get back structured self-heal data (`retryable`, `nextAction`, `llmHint`, `docs`, `agentInstructions`) as pure data — no API call. Use it first to choose retry vs. rotate-key vs. fix-input vs. contact-support, then reach for the reproduce/verify primitives below. - `vonpay_checkout_health` — API + provider reachability (rules in/out `provider_unavailable`, auth-service issues). - `vonpay_checkout_get_session` — inspect a session's **real** state (e.g. confirm `succeeded` for a `session_already_completed`, or `expired` for a `session_expired`). - `vonpay_checkout_simulate_payment` — drive a sandbox outcome to reproduce a decline path. - `vonpay_checkout_create_session` / `vonpay_checkout_list_test_cards` — set up a clean repro. See [the MCP reference](sdks/mcp.md) for the full tool list. ## Option 3 — verify the integrator's environment with the CLI Have the human run the real CLI (no secrets are printed): ```bash vonpay checkout health --json # API + provider health vonpay checkout sessions get # a session's true server-side state ``` Then confirm (by name only, never value) that the expected env var (`VON_PAY_SECRET_KEY`) is set and that its prefix mode (`vp_sk_test_` vs `vp_sk_live_`) matches the URL being hit. See [the CLI reference](sdks/cli.md). ## What you should NOT do - **Do not retry the same call** when `retryable: false`. The error is deterministic; the next call will fail identically. - **Don't "create a new session" on a terminal session without reading the `code`.** `session_already_completed` (**409**) means the buyer already paid — a new session re-charges them; only `session_expired` (**410**) warrants a new session. - **Do not surface raw API key values** to the human or in your context. The SDK + CLI both redact prefixes; preserve that. - **Do not invent error codes** that aren't in the [error-codes catalog](reference/error-codes.md). If you see a code you don't recognize, treat it as `contact_support` with the `requestId`. --- ## 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 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. Tickets with an `X-Request-Id` get a first-pass triage SLA; tickets without one fall back to the general queue. ## Related - [Error Codes catalog](reference/error-codes.md) — the full `ErrorCode` catalog + the rate-limit table - [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 ---