# Von Payments Checkout API > Von Payments is a hosted checkout page. Merchants create a session via API, redirect the buyer to the checkout URL, the buyer pays, and is redirected back with a signed confirmation. ## Quick Start 1. Call `POST /v1/sessions` with your API key to create a checkout session 2. Redirect the buyer to the returned `checkoutUrl` 3. Buyer pays on the Von Payments hosted page (cards, Apple Pay, Google Pay, Klarna, 130+ methods) 4. Buyer is redirected to your `successUrl` with signed query params 5. Verify the HMAC signature server-side ## Authentication All merchant API calls require a Bearer token: ``` Authorization: Bearer vp_sk_live_xxx ``` ### Key Types - **Publishable keys** (`vp_pk_*`) — safe for browser code, can only create sessions - **Secret keys** (`vp_sk_*`) — server-only, full API access - **Legacy keys** (`vp_key_*`) — treated as secret, deprecated ## API Versioning Include `Von-Pay-Version: 2026-04-14` header to pin a version. If omitted, the current version is used. ## Self-Healing Errors All error responses include structured fields for programmatic handling: ```json { "error": "Human-readable message", "code": "auth_missing_bearer", "fix": "Include an Authorization: Bearer header", "docs": "https://docs.vonpay.com/reference/security#authentication" } ``` ## Key Rotation Secret keys can be rotated without downtime. When a key is rotated, the previous key enters a grace window (default 24h) during which both keys authenticate. After grace 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. If a publisher force-deactivates a key mid-rotation (`is_active=false` while grace/expiry metadata is set), that also returns `auth_key_expired` — the deactivation is treated as an accelerated rotation endpoint, not a plain revocation. Plain deactivation (`is_active=false` with no rotation metadata) returns `auth_invalid_key`. See https://docs.vonpay.com/reference/security#key-rotation for the full rotation flow. ## Dry-Run Validation Validate a session request without creating it: ``` POST /v1/sessions?dry_run=true → 200 { "valid": true, "warnings": [] } → 400 { "valid": false, "errors": [...] } ``` ## Machine Discovery ``` GET /.well-known/vonpay.json ``` Returns API version, endpoints, docs URLs, SDK package names. ## Create a Checkout Session ``` POST /v1/sessions Authorization: Bearer vp_sk_live_xxx Content-Type: application/json Idempotency-Key: optional-dedup-key { "amount": 1499, "currency": "USD", "country": "US", "successUrl": "https://mystore.com/order/123/confirm", "cancelUrl": "https://mystore.com/cart", "description": "Order #123", "locale": "en", "mode": "payment", "buyerId": "cust_123", "buyerName": "Jane Doe", "buyerEmail": "jane@example.com", "lineItems": [ { "name": "Premium Widget", "quantity": 1, "unitAmount": 1499 } ], "metadata": { "orderId": "order_123" }, "expiresIn": 1800 } ``` Response (201): ```json { "id": "vp_cs_live_k7x9m2n4p3abcdef", "checkoutUrl": "https://checkout.vonpay.com/checkout?session=vp_cs_live_k7x9m2n4p3abcdef", "expiresAt": "2026-03-31T15:30:00.000Z" } ``` ### Required fields - `amount` (integer) — amount in minor units (cents). 1499 = $14.99 - `currency` (string) — ISO 4217, 3 chars (USD, EUR, GBP) ### Optional fields - `country` (string) — ISO 3166-1 alpha-2, 2 chars. Auto-detected if omitted. - `successUrl` (string) — HTTPS redirect URL after payment success - `cancelUrl` (string) — HTTPS redirect URL on cancel - `description` (string) — payment description for bank statements - `locale` (string) — checkout page language (en, fr, de, etc.) - `mode` (string) — "payment" (default). Future: "setup" for card-on-file. - `buyerId` (string) — your external customer ID (enables saved payment methods) - `buyerName` (string) — pre-fills billing form, encrypted at rest - `buyerEmail` (string) — encrypted at rest - `lineItems` (array) — items displayed on checkout page. Each: { name, quantity, unitAmount, imageUrl? } - `metadata` (object) — key-value string pairs, passed through to webhooks - `expiresIn` (integer) — session TTL in seconds (300-3600, default 1800) ### Notes - `merchantId` is derived from your API key — you don't pass it - `amount` is the source of truth for charging. `lineItems` are display-only. - Sessions expire after 30 minutes by default - Idempotency: pass `Idempotency-Key` header to prevent duplicate sessions on retries - **Sandbox vs live is decided at session creation** from the authenticating key's merchant config (internal `is_sandbox` flag). It is never read from the request body, and it is frozen for the lifetime of the session — subsequent reads and webhook reconciliation honor the same mode. There is no `sandbox` or `mode=test|live` field on the request body. ## Get Session Status Requires a secret key (`vp_sk_*`). Publishable keys receive 403. ``` GET /v1/sessions/vp_cs_live_k7x9m2n4p3abcdef Authorization: Bearer vp_sk_live_xxx ``` Response: ```json { "id": "vp_cs_live_k7x9m2n4p3abcdef", "status": "succeeded", "mode": "payment", "merchantId": "default", "amount": 1499, "currency": "USD", "country": "US", "description": "Order #123", "transactionId": "txn_abc123", "metadata": { "orderId": "order_123" }, "createdAt": "2026-03-31T15:00:00.000Z", "updatedAt": "2026-03-31T15:05:00.000Z", "expiresAt": "2026-03-31T15:30:00.000Z" } ``` Session statuses: `pending` → `processing` → `succeeded` | `failed` | `expired` ## Verify Return URL Signature After payment, the buyer is redirected to your `successUrl` with signed params: ``` https://mystore.com/confirm?session=vp_cs_live_xxx&status=succeeded&amount=1499¤cy=USD&transaction_id=txn_abc&sig=hexstring ``` Verify the HMAC-SHA256 signature server-side: ``` sig = HMAC-SHA256( key: VON_PAY_SESSION_SECRET, data: "{session}.{status}.{amount}.{currency}.{transaction_id}" ) ``` Example data string: `vp_cs_live_k7x9m2n4p3abcdef.succeeded.1499.USD.txn_abc123` ### Node.js verification ```javascript import crypto from "crypto"; function verifyReturnSignature(params, secret) { const data = `${params.session}.${params.status}.${params.amount}.${params.currency}.${params.transaction_id || ""}`; const expected = crypto.createHmac("sha256", secret).update(data).digest("hex"); return crypto.timingSafeEqual(Buffer.from(params.sig, "hex"), Buffer.from(expected, "hex")); } ``` ### Python verification ```python import hmac, hashlib def verify(session, status, amount, currency, transaction_id, sig, secret): data = f"{session}.{status}.{amount}.{currency}.{transaction_id or ''}" expected = hmac.new(secret.encode(), data.encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(sig, expected) ``` ## Verify Outbound Webhook Signature When Von Payments POSTs a webhook event to your registered endpoint, the request carries an `x-vonpay-signature` header — **a separate HMAC scheme** from the return URL signature above. Use it to verify the payload actually came from us. Header shape: ``` x-vonpay-signature: t=,v1= ``` During a signing-secret rotation, multiple `v1=` entries may appear (at most 2). Accept if any match. HMAC input: `t + "." + raw_request_body`. Key: the raw `whsec_*` signing secret you got when registering the endpoint (used as UTF-8 bytes; do not base64-decode). ```javascript // Node.js — full verifier, timing-safe, rotation-aware import crypto from "crypto"; function verifyWebhook(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); const now = Math.floor(Date.now() / 1000); if (now - t > 300 || t - now > 30) return false; // 5 min past, 30 sec future const v1s = parts.filter(p => p.startsWith("v1=")); if (v1s.length === 0 || v1s.length > 2) return false; const expected = Buffer.from(crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"), "utf8"); for (const part of v1s) { const cand = Buffer.from(part.slice(3), "utf8"); try { if (crypto.timingSafeEqual(cand, expected)) return true; } catch {} } return false; } ``` Reject with 400 on stale/future timestamp. Reject with 401 on signature mismatch or malformed header. See https://docs.vonpay.com/reference/webhooks#verification (or `docs/webhook-signature-v1.md` in source) for the full spec including rotation semantics. ## Health Check ``` GET /api/health ``` No auth required. Returns `200` when healthy, `503` when degraded. ## Capabilities The capability matrix tells your integration which discrete-lifecycle operations the merchant's payment provider supports — deferred capture, partial refund, MIT, network tokens, 3DS2, ACH, payouts API. Read it once on SDK init or when conditional UX needs gating ("Save card" only if `mit: true`). ``` GET /v1/capabilities Authorization: Bearer vp_sk_live_xxx ``` Response (200): ```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": true, "ach": false, "payouts_api": true }, "settlement_currencies": ["USD", "EUR", "GBP", "CAD", "AUD"], "rate_limits": { "payment_intents_per_minute": 30 } } ``` Both publishable (`vp_pk_*`) and secret (`vp_sk_*`) keys are accepted — capabilities are read-only metadata. Sandbox merchants always receive the sandbox capability matrix. Browser-cached for 60 seconds. The response intentionally omits any vendor name. Write generic code against the matrix; switching providers does not change your integration code. ## Payment Intents Vora exposes a unified payment-intent shape across every backing provider. The merchant writes one piece of code; the same request shape works regardless of which provider processes the charge underneath. Each provider activates independently — sandbox merchants always work, and live providers come online via per-provider activations announced in the changelog. ### Create a payment intent ``` POST /v1/payment_intents Authorization: Bearer vp_sk_test_xxx Content-Type: application/json Idempotency-Key: { "amount": 1499, "currency": "USD", "capture_method": "automatic", "metadata": { "merchant_ref": "ord_42" } } ``` Response (201, sandbox merchant): ```json { "id": "vpi_test_xxxxxxxxxxxxxxxx", "status": "succeeded", "amount": 1499, "currency": "USD", "capture_method": "automatic", "next_action": null, "decline_code": null, "card": null, "created_at": "2026-05-04T10:00:00Z", "metadata": { "merchant_ref": "ord_42" } } ``` Sandbox is deterministic: `amount: 200` always returns `status: failed, decline_code: card_declined`; any other amount returns `status: succeeded`. **Live merchants (real payment providers):** depends on whether the merchant's provider has been activated for the discrete-lifecycle API. Activated providers return the full response; un-activated providers return `501 endpoint_not_implemented`. Use a sandbox API key (`vp_sk_test_*`) to exercise the contract regardless of provider state. ### Idempotency Pass `Idempotency-Key` to safely retry the same request: - Same key + same body → original response with HTTP 200 (vs 201 on first create) - Same key + different `amount` / `currency` / `capture_method` → 422 `idempotency_replay_incompatible` (use a fresh key or send the original body) - Key constraints: ≤ 255 chars, printable ASCII only ### Vendor independence The response shape is provider-agnostic. The body never carries the underlying provider's name, gateway type, or any vendor-specific identifier. The same `decline_code` allowlist (`card_declined`, `insufficient_funds`, etc.) is produced regardless of which provider declined the card. This is the load-bearing contract — write integration code once; switch providers without touching merchant code. ## SDK Telemetry (Phase 3 — opt-in) Optional integrator-side error reporting. Vonpay never receives data unless an SDK constructor explicitly opts in (`telemetry: { enabled: true }`, default `false`). ``` POST /v1/sdk-telemetry Authorization: Bearer vp_sk_live_xxx Content-Type: application/json { "sdk_name": "checkout-node", "sdk_version": "0.3.1", "runtime": "node-20.10.0", "error_code": "auth_invalid_key", "operation": "sessions.create", "request_id_hash": "", "occurred_at": "2026-04-25T21:00:00Z", "context": { "duration_ms": 250, "retry_count": 0, "http_status": 401 } } ``` Response: `204 No Content` on success. Same `apiError` envelope on rejects: `400 validation_error`, `401 auth_*`, `403 auth_key_type_forbidden` (publishable rejected), `429 rate_limit_exceeded_per_key`, `503 provider_unavailable`. What's accepted is intentionally narrow — `sdk_name` is a closed enum, `operation` is a closed enum, `runtime` matches `/^[a-z][a-z0-9._+-]{0,62}$/i`, `request_id_hash` is 64-char SHA-256 hex (raw `request_id` is never accepted). Schema `.strict()` so any unknown field fails-closed. Server-side blocklist regex on string fields rejects `vp_sk_*` / `sk_live_*` / `whsec_*` / email shapes. See [`docs/_design/phase-3-sdk-telemetry.md`](../docs/_design/phase-3-sdk-telemetry.md) for the full design + privacy posture. ## Internal Routes (not for merchant integration) These exist only so the hosted checkout page can call back into the checkout service. They are not part of the merchant API — merchants should not call them directly. - `POST /api/checkout/init` — origin-validated; initializes payment embed for a session - `POST /api/checkout/complete` — origin-validated; finalizes session after provider success - `POST /api/checkout/charge` — origin-validated; initiates server-side charge step for applicable gateway types - `GET /api/checkout/session` — origin-validated; loads buyer-facing session projection (origin-bound per session-fixation defense) - `POST /api/checkout/client-error` — origin-validated; receives client-side error reports from the hosted page. Body capped at 4 KB — larger bodies return `413 { ok: false, error: "Payload too large" }`. - `POST /api/csp-report` — unauthenticated; receives browser-emitted Content-Security-Policy violation reports. Browsers send `application/csp-report`; no auth envelope exists for these reports so the route is origin-checked and rate-limited via the shared `clientError` bucket. Oversized or malformed bodies are rejected. Service-to-service routes (called by `vonpay-merchant` with `INTERNAL_CHECKOUT_SERVICE_KEY` bearer): `/api/internal/merchant-gateway-credentials`, `/api/internal/webhook-subscriptions/{id}/signing-secret` (POST + DELETE). See ARCHITECTURE.md §2.2 for the contract. Admin / cron routes (called by Railway cron services with admin key): `/api/cron/retention`, `/api/cron/webhook-stall-check`, `/api/cron/webhook-merchant-5xx-report`, `/api/webhooks/retry` (QStash signed). See `docs/runbook-cron-jobs.md` for wiring. ## Rate Limits | Endpoint | Limit | Keyed on | |----------|-------|----------| | `POST /v1/sessions` | 30/min | API key hash (sessionsPerKey) | | `GET /v1/sessions/:id` | 60/min | IP | | `POST /v1/sdk-telemetry` | 30/min | API key hash (sdkTelemetry) | | `POST /api/checkout/init` / `/complete` | 60/min | IP | | `POST /api/checkout/client-error` / `/api/csp-report` | 10/min | IP (clientError) | | `POST /api/webhooks/*` | 100/min | IP | Rate-limited responses return `429` with `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` headers and code `rate_limit_exceeded` or `rate_limit_exceeded_per_key`. ## Error Format All errors return the self-healing envelope: ```json { "error": "Human-readable message", "code": "machine_readable_code", "fix": "What the SDK should do to recover", "docs": "https://docs.vonpay.com/...", "selfHeal": { "retryable": false, "nextAction": "rotate_key", "llmHint": "The API key is malformed or does not exist...", "actions": [ { "type": "verify_env_var", "name": "VON_PAY_SECRET_KEY" }, { "type": "check_format", "field": "apiKey", "expected_prefix": ["vp_sk_test_", "vp_sk_live_"] }, { "type": "regenerate_key", "url": "https://app.vonpay.com/dashboard/developers/api-keys" } ] } } ``` The `selfHeal` block (Phase 2.5b — bridge 2026-04-25 21:21Z) mirrors the SDK's `VonPayError.selfHeal` surface so curl / PHP / Ruby integrators get the same structured remediation that the Node SDK exposes. Zero PII; deterministic from `code`. - `retryable`: whether the same request can succeed on retry without changes - `nextAction`: high-level next step — one of `retry`, `rotate_key`, `fix_request`, `wait_and_retry`, `contact_support`, `complete_onboarding`, `create_new_session`, `no_action` - `llmHint`: 1-3 sentence guidance for an LLM agent debugging the failure - `actions` (optional): structured remediation steps with discriminated `type` — `verify_env_var`, `check_format`, `regenerate_key`, `wait_and_retry`, `contact_support` Every response includes `X-Request-Id` header. Full code catalog at `docs/reference/error-codes.md`. | Code | Meaning | |------|---------| | 400 | Invalid request body | | 401 | Authentication failed | | 403 | Forbidden — secret key required | | 404 | Session not found | | 409 | Session in wrong state | | 410 | Session expired | | 429 | Rate limited | | 500 | Server error | | 503 | Auth service unavailable (`code: auth_service_unavailable`) — retry with exponential backoff. | ## SDKs ### Node.js (@vonpay/checkout-node) ```bash npm install @vonpay/checkout-node ``` ```typescript import { VonPayCheckout } from "@vonpay/checkout-node"; const vonpay = new VonPayCheckout("vp_sk_live_xxx"); // Create session const session = await vonpay.sessions.create({ amount: 1499, currency: "USD", successUrl: "https://mystore.com/confirm", lineItems: [{ name: "Widget", quantity: 1, unitAmount: 1499 }], }); // Redirect buyer to session.checkoutUrl // Check status (secret key only) const status = await vonpay.sessions.get("vp_cs_live_xxx"); // Verify return signature (v2 format; auto-detects v1 legacy) 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, { expectedSuccessUrl: "https://mystore.com/confirm", expectedKeyMode: "live" }, ); ``` ### Browser (vonpay.js) ```html ``` ## Security - PCI SAQ-A: card data never touches your servers or ours (secure iframe) - PII (buyer name, email) encrypted with AES-256-GCM at rest - HMAC-SHA256 signed return URLs (includes amount + currency) - All URLs must be HTTPS (localhost exempt in sandbox) - Session tokens are 16-char cryptographically random strings ## Links - Docs: https://docs.vonpay.com - OpenAPI spec: https://checkout.vonpay.com/openapi.yaml - Status: https://checkout.vonpay.com/api/health