Skip to main content

Adding Vora as a payment gateway

For the engineering team at a SaaS platform — CRM, cart, subscription billing, marketplace — adding Von Payments to the gateway list its merchants choose from. Integrate VORA as a Payment Gateway maps the API surface onto a gateway-adapter contract; this runbook is the sequence, the decisions, the settings screen your merchants touch, and the evidence we need before we list you. The samples are TypeScript on Node, matching the reference adapter, plus cURL for the first smoke test and PHP for webhook verification; the client written in step 2 is the client used in steps 3 through 7.

src/vonpay/
client.ts HTTP client — headers, idempotency, retries, typed errors [phase 2]
gateway.ts the adapter — authorize / capture / sale / void / refund [phase 2]
cards.ts card capture + saved-card charging [phase 3]
webhooks.ts signature verification, event routing, registration [phase 4]
connect.ts connection test, save, disconnect [phase 5]
copy.ts error code → merchant-facing message [phase 5]
src/settings/
GatewaySettings.tsx the settings screen [phase 5]
scripts/
certify.ts the certification suite [phase 6]

Contents

StepWhat happensWhoTypical elapsed
0Get a sandbox, prove the key worksYou1 hour
1Choose the integration shapeYou + us1 call
2Build the six adapter operationsYou3–10 days
3Card collection + saved cardsYou2–5 days
4WebhooksYou1–2 days
5The gateway settings pageYou2–4 days
6Certification in sandboxYou + us1 week
7Go live, stagedUs + you2 weeks

Step 0 — Get a sandbox

  1. Go to vonpay.com/developers and sign in with your work email (one-time code — no password to manage).
  2. Click Activate Evaluation Sandbox.
  3. Your keys appear at app.vonpay.com/dashboard/developers/api-keys.

You immediately have a self-serve sandbox merchant — it runs on Von Payments' test emulator — with a test secret key (vp_sk_test_…) and a test publishable key (vp_pk_test_…). No money moves.

Key anatomy

vp_ sk _ live _ a1b2c3d4e5…
─┬─ ─┬──
│ └── the only thing that selects the environment (live | test)
└──────── sk = secret, server only · pk = publishable, browser-safe

Both modes use the same host. There is no separate sandbox hostname for the self-serve sandbox.

The four-call smoke test

Run this before you write a line of adapter code. Every call is read-only or reversible.

export VP_KEY="vp_sk_test_…"
export VP_HOST="https://checkout.vonpay.com"

# 1 — Does the key authenticate, and what can this merchant actually do?
curl -sS "$VP_HOST/v1/capabilities" \
-H "Authorization: Bearer $VP_KEY" | jq

# 2 — Is it genuinely a SECRET key?
# Call 1 accepts publishable keys too, so on its own it cannot tell you.
# 200 = secret key. 403 auth_key_type_forbidden = you pasted the publishable one.
curl -sS -o /dev/null -w '%{http_code}\n' "$VP_HOST/v1/webhook_subscriptions" \
-H "Authorization: Bearer $VP_KEY"

# 3 — Validate a real request body without creating anything.
curl -sS -X POST "$VP_HOST/v1/sessions?dry_run=true" \
-H "Authorization: Bearer $VP_KEY" \
-H "Content-Type: application/json" \
-d '{"amount":1499,"currency":"USD","country":"US",
"successUrl":"https://example.com/thanks"}' | jq
# → { "valid": true, "warnings": ["This merchant is in sandbox mode — …"] }

# 4 — Create one for real. This is your first end-to-end proof.
# NOTE the key is a FIXED literal, not $(date +%s). Re-running this script
# therefore returns the SAME session rather than minting a new one — which is
# the behaviour you want to see. Do not copy a per-invocation key into a
# charging call: a key that changes per attempt is exactly what turns a
# timeout into a second charge.
curl -sS -X POST "$VP_HOST/v1/sessions" \
-H "Authorization: Bearer $VP_KEY" \
-H "Content-Type: application/json" \
-H "Von-Pay-Version: 2026-04-14" \
-H "Idempotency-Key: smoke-session-1" \
-d '{"amount":1499,"currency":"USD","country":"US",
"successUrl":"https://example.com/thanks"}' | jq
# → { "id": "vp_cs_test_…", "checkoutUrl": "https://…", "expiresAt": "…" }

Open the checkoutUrl in a browser and pay — on this self-serve sandbox any amount other than 200 approves and 200 declines (Test mode & test cards).

Machine-readable discovery, if your build wants to pin endpoints rather than hardcode them:

curl -sS "$VP_HOST/.well-known/vonpay.json" | jq '.api_version, .endpoints, .scripts'

Read these, in this order

PageWhy
Choose your integrationThe paths side by side, with a decision tree
Integrate VORA as a payment gatewayThe adapter-contract mapping
Sandbox & test modeWhat the sandbox does and does not simulate
OpenAPI specHand this to your engineers; source of truth for every shape
There are two kinds of sandbox and they live on different hosts

The self-serve sandbox above runs on the production host. If we instead board you onto a processor sandbox during a partnership, we will give you a different host. Use exactly the host you were given, and set it in both your server code and your browser code — it is never inferred from the key. Setting it server-side only is the most common onboarding failure: your server calls succeed, every browser call returns 401, and it looks like a bad key.


Step 1 — Choose the integration shape

We expose two ways to collect a card and one engine that settles them. Pick per product, not per merchant — your merchants should not have to understand this.

Payment intents is the server-side engine both front-end paths settle through, and the one you call directly for anything after the first charge.

Buyer seesConnector buildChoose it when
Hosted checkout (front end)Redirect to our page, then back~half a dayYou want the shortest path, and leaving your product briefly is acceptable
Embedded fields (front end)Card fields inside your page2–5 daysYour branding matters and the buyer must stay in your app
Payment intents (engine)(server-side; no UI)3–10 daysAlways — it is what capture, void, refund and every rebill go through

The build estimates are for a multi-tenant connector: per-merchant credential storage, a settings screen, webhook routing across merchants. A single merchant integrating their own checkout is much faster — see Choose your integration for those figures. These numbers are for your planning, not for a merchant-facing quote.

The three surfaces, in code

Hosted checkout — one server call, then a redirect.

const session = await vp.request<{ id: string; checkoutUrl: string; expiresAt: string }>(
"POST", "/v1/sessions",
{
body: {
amount: 1499, // minor units — $14.99
currency: "USD",
country: "US",
successUrl: `https://app.example.com/orders/${orderId}/thanks`,
cancelUrl: `https://app.example.com/orders/${orderId}`,
buyerId: customer.id, // YOUR customer reference
buyerEmail: customer.email,
lineItems: [{ name: "Pro plan — March", quantity: 1, unitAmount: 1499 }],
},
idempotencyKey: `acme_${orderId}_session`,
},
);
res.redirect(session.checkoutUrl);
// Outcome arrives on your webhook. Do not treat the redirect as authoritative.

Embedded fields — same server call with two extra fields, then the browser SDK.

// server
const session = await vp.request("POST", "/v1/sessions", {
body: {
amount: 1499, currency: "USD", country: "US",
successUrl: `https://app.example.com/orders/${orderId}/thanks`,
buyerId: customer.id,
integrationMode: "elements", // discrete fields you lay out yourself
chargeAtSubmit: true, // charge when the buyer submits
},
idempotencyKey: `acme_${orderId}_session`,
});
<!-- browser -->
<script src="https://js.vonpay.com/v1/vora.js"></script>
<div id="vp-card"></div>
<button id="pay">Pay $14.99</button>
<script>
const vora = new Vora({
publishableKey: "vp_pk_test_…",
apiBaseUrl: "https://checkout.vonpay.com", // MUST match the host your server used
});
await vora.sessions.retrieve(SESSION_ID);
const fields = vora.elements.create();
fields.create("card").mount("#vp-card");

document.getElementById("pay").onclick = async () => {
const result = await fields.submit();

// Branch in this order: error -> chargeStatus -> token -> charged.
// A result with no `error` is NOT the same as a completed payment.
if (result.error) {
showError(result.error.code); // your copy, keyed by code
return;
}

// The bank wants the cardholder to verify (3-D Secure). Nothing is charged
// until they do — without this redirect the buyer simply cannot pay.
if (result.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl;
return;
}

// Not decided yet. Wait for the `charge.*` webhook; do not record success.
if (result.chargeStatus === "pending") return;

// "succeeded" — but on a manual-capture session that means AUTHORISED, with
// `charged: false`. Read `charged` before treating the order as paid.
// On charge-and-save accounts the money has ALREADY moved here.
// Do NOT also call POST /v1/payment_intents for this payment.
if (result.charged === true) markOrderPaid(result.token ?? null);
};
</script>

Payment intents — no UI; charges a card you already hold.

const intent = await vp.request<PaymentIntent>("POST", "/v1/payment_intents", {
body: {
amount: 4999,
currency: "USD",
capture_method: "automatic", // or "manual" for authorize-only
payment_method: { id: "vp_pmt_live_…" },
buyer_id: customer.id,
return_url: "https://app.example.com/3ds/return",
},
idempotencyKey: `acme_${orderId}_charge`,
});
// intent.status → succeeded | authorized | requires_action | failed | voided | captured

Our recommendation for a CRM or subscription platform

Embedded fields for collecting the card, payment intents for every charge after that.

  • Payment intents is the only shape whose lifecycle matches a gateway-adapter contract. authorize / capture / void / refund are discrete calls that map 1:1 onto the methods your adapter interface almost certainly already has.
  • Recurring and card-on-file charges are server-initiated and need a saved card — so embedded fields (or hosted checkout) for the first interaction only.
  • Hosted checkout is the universal fallback. It works for every merchant regardless of which processor they are on. Embedded fields availability is per-merchant. Build the fallback even if you lead with embedded fields — see §5.5.

Step 2 — Build the adapter

2.1 The operation map

Your adapter methodOur callReaches statusWatch out
authorizePOST /v1/payment_intents with capture_method: "manual"authorized
capturePOST /v1/payment_intents/{id}/capturesucceededPartial via amount_to_capture; over-capture is refused
sale (auth + capture)POST /v1/payment_intents with capture_method: "automatic"succeededThe default
voidPOST /v1/payment_intents/{id}/voidvoidedOnly before capture. After capture, issue a refund instead
refundPOST /v1/refunds(see below)Four different 2xx shapes
store cardPOST /v1/public/tokens (browser) or POST /v1/tokens (server)Consent must be captured here — see Step 3
statusGET /v1/payment_intents/{id}Also delivered by webhook

2.2 The HTTP client

One place that owns headers, idempotency, retries and error shape. Everything else in this runbook calls it.

// src/vonpay/client.ts
export type VonPayMode = "test" | "live";

export interface VonPayCredentials {
secretKey: string; // vp_sk_test_… | vp_sk_live_…
publishableKey?: string; // vp_pk_… — browser only, never sent from here
webhookSigningSecret?: string; // whsec_…
webhookSubscriptionId?: string;
}

export class VonPayError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly fix: string | null,
readonly requestId: string | null,
) {
super(message);
this.name = "VonPayError";
}
}

const API_VERSION = "2026-04-14";

// Safe to replay because we always send the same Idempotency-Key.
const RETRYABLE_STATUS = new Set([429, 502, 503]);
// Codes that explicitly mean "nothing happened, send it again".
//
// ⛔ 409 `charge_in_progress` is deliberately NOT here. It means a charge for this
// (merchant, Idempotency-Key) is ALREADY RUNNING or has already finished, on a route
// that keeps no record of its own. Retrying it — and above all reissuing with a fresh
// key — is exactly how a buyer gets charged twice on a connection where nothing can
// merge the two afterwards. Check the original payment's status instead.
const RETRYABLE_CODES = new Set([
"service_unavailable", // 503 — the pre-charge record failed, nothing was sent
"auth_service_unavailable", // 503 — auth replica blipped
]);

export class VonPayClient {
constructor(
private readonly creds: VonPayCredentials,
readonly baseUrl = "https://checkout.vonpay.com",
) {}

async request<T>(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
opts: { body?: unknown; idempotencyKey?: string; maxAttempts?: number } = {},
): Promise<T> {
const maxAttempts = opts.maxAttempts ?? 4;
let last: VonPayError | undefined;

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
const res = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
Authorization: `Bearer ${this.creds.secretKey}`,
"Content-Type": "application/json",
// Pin the version. Omitting it tracks latest, which can change behind you.
"Von-Pay-Version": API_VERSION,
// The SAME key on every attempt. This is what makes the retry safe.
...(opts.idempotencyKey ? { "Idempotency-Key": opts.idempotencyKey } : {}),
},
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
});

const requestId = res.headers.get("x-request-id");
const text = await res.text();
const payload = text ? JSON.parse(text) : {};

// 200, 201 and 202 are all successes. A 202 on a refund is NOT a failure.
if (res.ok) return payload as T;

const err = new VonPayError(
res.status,
payload.code ?? "unknown_error",
payload.error ?? res.statusText,
payload.fix ?? null,
requestId,
);

// GET/DELETE are idempotent by definition. A POST/PATCH is only safe to
// replay if we sent a key the server can collapse it on — WITHOUT one, a
// retried 502 is a second subscription, a second rotation, a second charge.
// The comment above says "safe because we always send the same key"; this
// is what makes that true instead of merely intended.
const replayable = method === "GET" || method === "DELETE" || !!opts.idempotencyKey;
const retry =
replayable && (RETRYABLE_STATUS.has(res.status) || RETRYABLE_CODES.has(err.code));
if (!retry || attempt === maxAttempts) throw err;

const retryAfter = Number(res.headers.get("retry-after"));
const waitMs =
Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: Math.min(2 ** attempt * 250, 8_000) + Math.random() * 250;
await new Promise((r) => setTimeout(r, waitMs));
last = err;
}
throw last!;
}
}

2.3 Idempotency — the rule that prevents double charges

Use one stable key per logical operation and reuse it on every retry:

// src/vonpay/gateway.ts
export const PLATFORM = "acme"; // your platform's slug — prefixes every idempotency key

/**
* `{platform}_{your order id}_{operation}`
*
* The last segment names WHICH operation on that order this is — charge, capture,
* refund, refund-2 for a deliberate second refund. It must NOT carry a retry or
* attempt counter: retrying is the exact case the key exists to collapse, so a
* counter there defeats it and turns a network timeout into a second real charge.
*/
export const idem = (orderId: string, operation: string) =>
`${PLATFORM}_${orderId}_${operation}`;
SituationWhat we returnWhat you do
Same key, same body200 with the original responseTreat as success
Same key, different body422 idempotency_replay_incompatibleSend the original body, or use a fresh key for a genuinely different operation
Two requests race on one key409 charge_in_progressCheck the original payment's status — see the box below
The pre-charge record could not be written503 service_unavailableNothing was sent. Retry with the same key — no claim was written, so an identical request cannot duplicate
A fresh key after 409 charge_in_progress charges the buyer twice

A charge for this key is already running, or has already finished, on a route that keeps no record of its own. Do not retry and do not reissue with a new key — on a connection where nothing can merge the two afterwards, a second key for the same payment is exactly how a buyer gets charged twice. Read the original payment's status instead.

On refunds there is one connection type where the key is required — a keyless refund is refused with 400 idempotency_key_required, because there our record is the only record a repeat call could be checked against. Send the header on every connection and you never meet the distinction.

2.4 The adapter

// src/vonpay/gateway.ts (continued)
import { VonPayClient, VonPayError } from "./client";

export type IntentStatus =
| "requires_action" | "authorized" | "captured"
| "succeeded" | "voided" | "failed";

export interface PaymentIntent {
id: string; // vpi_live_…
status: IntentStatus;
amount: number;
currency: string;
capture_method: "automatic" | "manual";
next_action?: { type: "redirect_to_url"; redirect_to_url: { url: string } } | null;
decline_code?: string | null;
decline_message?: string | null;
avs_result_code?: string | null;
cvv_result_code?: string | null;
card?: { brand: string; last4: string } | null;
created_at?: string;
}

export interface ChargeInput {
orderId: string; // YOUR order id
amountMinor: number;
currency: string;
paymentMethodId: string; // vp_pmt_…
buyerId: string; // YOUR customer id — must own the card
returnUrl: string; // where the buyer lands after a 3-D Secure challenge
billingAddress?: {
address_line1: string; address_line2?: string;
city?: string; state?: string;
postal_code: string; country: string; // ISO-3166 alpha-2, uppercase
};
metadata?: Record<string, string>;
}

export class VonPayGateway {
constructor(private readonly vp: VonPayClient) {}

/** Authorize only — money is held, not taken. */
authorize(input: ChargeInput) {
return this.createIntent(input, "manual", "authorize");
}

/** Authorize and capture in one step. The common case. */
sale(input: ChargeInput) {
return this.createIntent(input, "automatic", "charge");
}

private createIntent(
input: ChargeInput,
capture_method: "automatic" | "manual",
op: string,
) {
return this.vp.request<PaymentIntent>("POST", "/v1/payment_intents", {
body: {
amount: input.amountMinor,
currency: input.currency,
capture_method,
payment_method: { id: input.paymentMethodId },
buyer_id: input.buyerId,
return_url: input.returnUrl,
...(input.billingAddress ? { billing_address: input.billingAddress } : {}),
...(input.metadata ? { metadata: input.metadata } : {}),
},
idempotencyKey: idem(input.orderId, op),
});
}

/** Capture an authorization. Omit amountMinor to capture in full. */
capture(orderId: string, intentId: string, amountMinor?: number) {
return this.vp.request<PaymentIntent>(
"POST", `/v1/payment_intents/${intentId}/capture`,
{
body: amountMinor == null ? {} : { amount_to_capture: amountMinor },
idempotencyKey: idem(orderId, "capture"),
},
);
}

/** Release an authorization. Only valid BEFORE capture. */
void(orderId: string, intentId: string) {
return this.vp.request<PaymentIntent>(
"POST", `/v1/payment_intents/${intentId}/void`,
{ body: {}, idempotencyKey: idem(orderId, "void") },
);
}

retrieve(intentId: string) {
return this.vp.request<PaymentIntent>("GET", `/v1/payment_intents/${intentId}`);
}
}

2.5 Refunds — four 2xx shapes, only one moved money

Branch on status, never on the HTTP code.

ResponseMeaning
201 + status: "succeeded"Money went back
202 + status: "requested"Accepted, still in flight. Not a failure. Retrying refunds the buyer twice
200 + status: "canceled"Voided before it left — the buyer was not repaid
200 + idempotent: trueA replay of a refund you already issued
// src/vonpay/gateway.ts (continued)
export interface RefundResponse {
id: string; // vpr_live_…
payment_intent: string | null;
amount: number;
currency: string;
status: "requested" | "succeeded" | "failed" | "canceled";
reason?: string | null;
idempotent?: boolean;
}

export type RefundOutcome =
| { kind: "refunded"; refundId: string; amountMinor: number }
| { kind: "pending"; refundId: string } // settle it on the webhook, do NOT retry
| { kind: "canceled"; refundId: string } // buyer was NOT repaid
| { kind: "declined"; code: string; message: string } // provider refused; NO row to retry
| { kind: "replay"; refundId: string; status: RefundResponse["status"] }
// A replay of a refund that already FAILED. Critically NOT the same as "replay":
// the money never moved and never will on this key.
| { kind: "replay_of_failed"; refundId: string };

export class VonPayGateway {
// …

/**
* @param refundRef "refund" for the first refund of an order, "refund-2" for a
* deliberate second one. NEVER a retry counter.
*/
async refund(
orderId: string,
intentId: string,
opts: {
amountMinor?: number; // omit for a full refund
reason?: "duplicate" | "fraudulent" | "requested_by_customer"
| "expired_uncaptured_charge";
refundRef?: string;
} = {},
): Promise<RefundOutcome> {
let r: RefundResponse;
try {
r = await this.vp.request<RefundResponse>("POST", "/v1/refunds", {
body: {
payment_intent: intentId,
...(opts.amountMinor != null ? { amount: opts.amountMinor } : {}),
...(opts.reason ? { reason: opts.reason } : {}),
},
// Required on some connections, harmless on all of them. Always send it.
idempotencyKey: idem(orderId, opts.refundRef ?? "refund"),
});
} catch (e) {
// ⛔ A provider DECLINE is not a 2xx with status:"failed" — it is a non-2xx
// throw (`refund_declined`, `refund_before_settlement`, …). Without this
// catch it propagates as an unhandled exception and nobody is told, which
// is the outcome that most needs a human.
if (e instanceof VonPayError && e.status === 422) {
return { kind: "declined", code: e.code, message: e.message };
}
throw e;
}

// ⚠ Order matters. A replay can carry status:"failed" — the first attempt hit
// a transient provider error, the row was written `failed`, and every retry on
// that key now returns 200 + idempotent:true + failed forever. Collapsing that
// to a plain "replay" reports a refund that never happened as already handled,
// and the buyer is never repaid. Check the status BEFORE the idempotent flag.
if (r.idempotent) {
return r.status === "failed"
? { kind: "replay_of_failed", refundId: r.id }
: { kind: "replay", refundId: r.id, status: r.status };
}

switch (r.status) {
case "succeeded": return { kind: "refunded", refundId: r.id, amountMinor: r.amount };
case "requested": return { kind: "pending", refundId: r.id };
case "canceled": return { kind: "canceled", refundId: r.id };
// Reachable only defensively — the live 2xx shapes are the four above.
case "failed": return { kind: "replay_of_failed", refundId: r.id };
}
}
}

Wiring it into your own refund flow — note that pending stays open in your ledger:

const outcome = await gateway.refund(order.id, order.vonpayIntentId, {
amountMinor: 500,
reason: "requested_by_customer",
});

switch (outcome.kind) {
case "refunded": await markRefunded(order, outcome.amountMinor); break;
case "replay": /* already handled on a previous attempt */ break;
case "pending": await markRefundInFlight(order, outcome.refundId); break;
case "canceled": await notifyOps(order, "refund canceled — buyer NOT repaid"); break;

// The provider refused. No refund row exists to retry against; fix the cause
// (settlement not reached, closed card, gateway balance) and issue a NEW one.
case "declined":
await notifyOps(order, `refund declined (${outcome.code}) — buyer NOT repaid: ${outcome.message}`);
break;

// ⛔ The dangerous one. This key is permanently poisoned: the row is `failed`
// and every retry on it returns the same dead result with idempotent:true.
// Retrying this key can NEVER repay the buyer. A human must issue a fresh
// refund under a NEW key (e.g. refundRef "refund-2") once the cause is fixed.
case "replay_of_failed":
await notifyOps(order, "refund key is spent on a FAILED attempt — buyer NOT repaid; reissue under a new key");
break;
}

2.6 Field casing differs by surface — and unknown fields are refused

This is the most common integration bug, and it fails loudly, which is the point.

SurfaceCasingExample
POST /v1/sessionscamelCasesuccessUrl, lineItems, buyerId
POST /v1/payment_intents, /v1/refunds, /v1/tokenssnake_casecapture_method, payment_method, buyer_id

Every request body is strict. A field we do not recognise returns 400 validation_unknown_field naming the offending field, with a suggestions map when the cause is casing. We refuse rather than ignore deliberately: silently dropping a field means your request looks accepted while the value it carried is lost.

2.7 Errors

Every error carries the same envelope plus an X-Request-Id header:

{
"error": "human-readable message",
"code": "machine_readable_code",
"fix": "actionable fix",
"docs": "https://docs.vonpay.com/reference/error-codes#…",
"selfHeal": { "retryable": false, "nextAction": "fix_request", "llmHint": "…" }
}

Branch on code, surface fix, log X-Request-Id. An adapter that handles the auth_*, merchant_*, validation_* and provider_* families idiomatically is structurally complete. Full catalogue: Error codes.

// A decline is not an exception in your domain — it is a business outcome.
// Keep the two apart at the boundary.
try {
const intent = await gateway.sale(input);

if (intent.status === "requires_action") {
// 3-D Secure. Send the buyer to the challenge as a TOP-LEVEL navigation —
// banks block it inside an iframe. The outcome arrives on your webhook.
return { kind: "challenge", url: intent.next_action!.redirect_to_url.url };
}
if (intent.status === "failed") {
return { kind: "declined", code: intent.decline_code, message: intent.decline_message };
}
return { kind: "paid", intentId: intent.id };

} catch (e) {
if (e instanceof VonPayError) {
log.warn({ code: e.code, requestId: e.requestId }, "vonpay call failed");
// 501 endpoint_not_implemented is a PROVISIONING state, not a crash — the
// merchant's connection is not activated for discrete charges yet.
if (e.code === "endpoint_not_implemented") return { kind: "not_provisioned" };
return { kind: "error", merchantMessage: merchantCopy(e.code) }; // phase 5
}
throw e;
}

Step 3 — Card collection and saved cards

3.1 Never let a card number reach your servers

Card fields render inside an iframe we host, so a card number never reaches your servers through this integration (SAQ-A for this path). Load the browser SDK from the CDN — vora.js is not on npm:

<script src="https://js.vonpay.com/v1/vora.js"></script> <!-- embedded fields -->
<script src="https://js.vonpay.com/v1/vora-hosted.js"></script> <!-- hosted redirect -->

The script URL is the same in every environment. The environment comes from the apiBaseUrl option you pass to the constructor, and it defaults to production. Pass the same host your server used to create the session.

3.2 The ordering dependency — read this before you design anything

Permission to charge a card again is recorded when the card is saved, and no endpoint adds it later

If you save a card without it, the first charge succeeds and every renewal after that fails with 422 payment_method_consent_missing. The only fix is to make the customer enter the card again. For a subscription platform that is a churn event, discovered weeks later.

So, when the card is saved, send all three of these:

FieldValuesWhat it means
setup_for_future_use"off_session" | "on_session"Permission to charge the card later. off_session is required for any server-initiated charge
allow_redisplay"always" | "limited" | "unspecified"Permission to show the card back to the buyer. Only always lists it in their saved cards
storedCredentialUse (session) / stored_credential_use (token)"recurring" | "installment" | "unscheduled"The declaration to the card networks about what the arrangement is

storedCredentialUse is sent at session create and is secret-key only — a publishable key gets 403 auth_key_type_forbidden. Omitting it declares nothing; nothing is inferred from your product's behaviour.

Whatever you send is recorded as the consent, and nothing checks that anyone ticked anything. Show a real checkbox and capture the buyer's permission genuinely.

3.3 Collecting the card

Pick ONE of these two, per order — doing both charges the buyer twice

The sample below uses chargeAtSubmit: true, which means the card is charged when the buyer submits, before any server call of yours. If you use it, that order is already paid: do not also call POST /v1/payment_intents for it. Confirm the outcome on the webhook.

If you would rather follow §1 literally — payment intents for every charge, including the first — set chargeAtSubmit: false. Submit then only vaults the card, and your server charge is the single charge.

Nothing stops you from doing both. The server-side duplicate guard only engages when you pass session_id on the charge, and the adapter types in §2.4 deliberately have no such field — so if you mix the two patterns, the two charges are keyed differently and nothing merges them.

// src/vonpay/cards.ts
import { VonPayClient } from "./client";
import { idem } from "./gateway";

export type BillingArrangement = "recurring" | "installment" | "unscheduled";

export interface CardCaptureSession {
id: string; // vp_cs_live_…
checkoutUrl: string; // the hosted fallback — always usable
expiresAt: string;
integrationMode: "elements" | "embed";
chargeAtSubmit: boolean;
storedCredentialUse: BillingArrangement | null; // echoed back — assert on it
}

/**
* One session that charges the first payment AND saves the card for later billing.
*
* `storedCredentialUse` cannot be added to a card after the fact, so it is set
* here or never. The response echoes it back — assert on that echo rather than
* assuming the field took effect.
*/
export async function createCardCaptureSession(
vp: VonPayClient,
input: {
orderId: string;
amountMinor: number;
currency: string;
country: string;
buyerId: string; // required — a saved card needs an owner
buyerEmail?: string;
returnUrl: string;
arrangement: BillingArrangement;
},
): Promise<CardCaptureSession> {
const session = await vp.request<CardCaptureSession>("POST", "/v1/sessions", {
body: {
amount: input.amountMinor,
currency: input.currency,
country: input.country,
successUrl: input.returnUrl,
buyerId: input.buyerId,
...(input.buyerEmail ? { buyerEmail: input.buyerEmail } : {}),
integrationMode: "elements",
chargeAtSubmit: true,
storedCredentialUse: input.arrangement,
},
idempotencyKey: idem(input.orderId, "session"),
});

if (session.storedCredentialUse !== input.arrangement) {
// Availability is per-merchant. Fail loudly here rather than discovering it
// when the first renewal declines, weeks from now.
throw new Error(
`arrangement not recorded: asked for ${input.arrangement}, ` +
`got ${session.storedCredentialUse ?? "null"}`,
);
}
return session;
}

Browser side:

<script src="https://js.vonpay.com/v1/vora.js"></script>

<div id="vp-card"></div>
<label>
<input type="checkbox" id="vp-consent">
Save this card and charge it for future renewals
</label>
<button id="pay">Pay $14.99</button>
<p id="vp-error" role="alert"></p>

<script type="module">
const FRAME_ERRORS = {
frame_binder_load_failed:
"We couldn't load the payment form. Check your connection and try again.",
frame_tokenization_failed:
"That card was declined. Try another card.",
frame_session_not_found:
"This checkout expired. Refresh the page to start again.",
frame_3ds_challenge_failed:
"Your bank didn't complete verification. Try again or use another card.",
frame_method_not_supported_for_session:
"This payment method isn't available for this order.",
};

const vora = new Vora({
publishableKey: window.APP.vonpayPublishableKey,
// MUST be the same host your server called. It defaults to production and is
// NOT inferred from the key — mismatch it and every browser call 401s while
// your server calls keep succeeding.
apiBaseUrl: window.APP.vonpayApiBaseUrl,
});

await vora.sessions.retrieve(window.APP.sessionId);
const fields = vora.elements.create();
fields.create("card").mount("#vp-card");

document.getElementById("pay").addEventListener("click", async () => {
const result = await fields.submit();

// Branch in this order: error -> chargeStatus -> token -> charged.
//
// Reading `token` or `charged` before `chargeStatus` is the mistake to avoid:
// a result with no `error` is NOT the same as a completed payment. On a
// charge-at-submit session, submit() also resolves for a card that still needs
// bank verification, and for a capture the provider has not finished. Treating
// either as paid records an order against money that has not moved.

if (result.error) {
document.getElementById("vp-error").textContent =
FRAME_ERRORS[result.error.code] ?? "Something went wrong. Please try again.";
return;
}

// 1. The bank wants the cardholder to verify (3-D Secure). NOTHING has been
// charged yet. Send the buyer; the payment settles on the `charge.*`
// webhook after they return, never on this call.
if (result.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl;
return;
}

// 2. The provider has not decided yet. Do NOT record success here — the
// `charge.*` webhook is the authoritative outcome. Recording it now is how
// an order ships against a payment that later declines.
if (result.chargeStatus === "pending") {
document.getElementById("vp-error").textContent =
"Your payment is processing. We'll confirm by email shortly.";
return;
}

// 3. chargeStatus === "succeeded".
//
// ⚠ "succeeded" does NOT always mean the money moved. On a manual-capture
// session the card is AUTHORISED and `charged` is false: the funds are held
// and only move when you capture the payment intent. Read `charged` — never
// `chargeStatus` on its own — before you treat an order as paid.
//
// On charge-and-save accounts the money has ALREADY moved on this call.
// Do NOT call POST /v1/payment_intents for this payment — that charges twice.
// result.token → "vp_pmt_…", the reusable card, when one was saved
// result.charged → true only when money actually moved
await fetch(`/api/orders/${window.APP.orderId}/card-saved`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
paymentMethodId: result.token ?? null,
// Let your server decide whether to fulfil. `false` means authorised and
// held, not paid.
charged: result.charged === true,
}),
});
window.location.href = window.APP.returnUrl;
});
</script>
On the consent checkbox

The SDK also exposes a mountable consent element that reports setup_for_future_use on submit. That is the path we recommend, because it keeps the recorded consent and the thing the buyer saw in one place. It needs a session that already carries a buyerId. Confirm the element name and required session shape against the Elements reference — and with us — before you build recurring on top of it.

3.4 Charging the saved card later

// src/vonpay/cards.ts (continued)
export interface MitInput {
orderId: string; // YOUR renewal/order id
amountMinor: number;
currency: string;
paymentMethodId: string; // vp_pmt_… saved earlier
buyerId: string; // must match the card's owner, or 404
// Should match what you declared at save time. ⚠ Nothing here enforces that:
// a mismatch is logged server-side and the call still succeeds. It is the CARD
// NETWORK that refuses, and it surfaces to you as a bare issuer decline with no
// code pointing at the cause. So this is on you to get right, not on our 4xx.
arrangement: BillingArrangement;
originalIntentId: string; // vpi_… the charge where the buyer was present
}

export async function chargeSavedCard(vp: VonPayClient, input: MitInput) {
return vp.request<PaymentIntent>("POST", "/v1/payment_intents", {
body: {
amount: input.amountMinor,
currency: input.currency,
capture_method: "automatic",
payment_method: { id: input.paymentMethodId },
buyer_id: input.buyerId,
mit: {
initiator: "merchant", // nobody is at the keyboard
reason: input.arrangement, // recurring | installment | unscheduled
original_transaction_id: input.originalIntentId,
},
// No return_url — an off-session charge has no buyer to send anywhere.
// setup_for_future_use is NOT a field here; sending it returns 400 on purpose.
},
idempotencyKey: idem(input.orderId, "renewal"),
});
}

Gate the whole feature on the merchant's account supporting it:

const caps = await vp.request<Capabilities>("GET", "/v1/capabilities");
if (!caps.supported_operations.mit) {
// Don't mint tokens this account can never charge. Hide the feature instead.
throw new FeatureUnavailable("saved cards");
}

Handling the renewal outcome — payment_method_consent_missing is the one that needs its own branch, because no retry will ever fix it:

try {
const intent = await chargeSavedCard(vp, input);
if (intent.status === "succeeded") return markRenewed(subscription, intent.id);
if (intent.status === "failed") return markRenewalDeclined(subscription, intent.decline_code);
} catch (e) {
if (e instanceof VonPayError && e.code === "payment_method_consent_missing") {
// Unrecoverable by retry. The card was saved without off-session permission
// and there is no endpoint that adds it. The customer must re-enter the card.
return startCardUpdateFlow(subscription, {
reason: "We need you to confirm this card for automatic renewals.",
});
}
throw e;
}
Card-on-file at irregular intervals may not work on every connection

On some processor connections a fixed-schedule subscription (recurringrecurring) is approved while every other arrangement — including a correctly matched unscheduledunscheduled pair — is declined. If your merchants keep a card and charge it whenever the customer next orders, raise it with us before you build.

Never tell a merchant to declare recurring to work around this. It is a statement to the card networks about an arrangement they do not have.

3.5 Recovering a token you never received

If a card is saved during a flow that completes asynchronously — after a 3-D Secure challenge, say — the token may not be in the response you got. Read it back:

// src/vonpay/cards.ts (continued)
export interface SavedCard {
id: string; // vp_pmt_…
status: "active";
card: { brand: string; last4: string; exp_month: number; exp_year: number };
setup_for_future_use: string | null;
allow_redisplay: string | null;
stored_credential_use: BillingArrangement | null;
created_at: string;
}

export async function listSavedCards(vp: VonPayClient, buyerId: string) {
const page = await vp.request<{ data: SavedCard[]; next_cursor: string | null; has_more: boolean }>(
"GET", `/v1/payment_methods?buyer_id=${encodeURIComponent(buyerId)}&limit=25`,
);
return page.data;
}

/** Only these can be charged off-session. Filter before you show a renewal option. */
export const rebillable = (cards: SavedCard[]) =>
cards.filter((c) => c.setup_for_future_use === "off_session");

Full guide: Recurring and saved cards.


Step 4 — Webhooks

Webhooks are how you learn the outcome of anything that finishes after your request returns. Build them; do not rely on the redirect or the synchronous response alone.

4.1 Events a gateway adapter maps

EventFires whenMaps to
charge.succeededA payment succeeded (hosted or direct)sale / capture success
charge.failedA payment attempt failedsale / capture decline
charge.refundedA refund settled (once per refund)refund success
refund.failedA refund did not complete — no money went backrefund failure
payment_intent.succeededIntent reached succeededauth + capture settle
payment_intent.failedIntent reached failedauth decline
payment_intent.cancelledIntent voided before capturevoid

Envelope: { id, type, created, livemode, merchant_id, data }. Full catalogue and per-event payloads: Webhook events.

4.2 Verifying the signature

Over the raw body, before any parsing. Re-serialised JSON will not match.

// src/vonpay/webhooks.ts
import crypto from "node:crypto";

const MAX_AGE_S = 300; // reject anything older than 5 minutes
const MAX_SKEW_S = 30; // …or more than 30 seconds in the future

/**
* Header: `x-vonpay-signature: t=<unix seconds>,v1=<lowercase hex hmac-sha256>`
* Message: `${t}.${rawBody}`
* Key: the endpoint's whsec_… secret as raw UTF-8 — do NOT strip the prefix,
* do NOT base64-decode it.
*
* Accepts if ANY v1 entry matches. Today the header carries exactly one, and
* rotation replaces the signing secret with no dual-signing grace period: once the
* switch-over completes, the previous secret stops verifying.
*
* ⭐ ROTATING THE SECRET: during the switch-over, verify against the OLD secret
* and the NEW one, and accept the delivery if EITHER matches. Drop the old one
* once the window has passed.
*
* ⚠ This function checks ONE secret — the loop below iterates the `v1=` entries
* in the header, not your secrets, and today the header carries exactly one.
* So "accept both" means CALLING THIS TWICE during rotation, once per secret:
*
* const ok = verifySignature(raw, header, newSecret)
* || verifySignature(raw, header, oldSecret);
*
* ⚠ Why both, and not a swap at a single moment: rotation MINTS the new secret,
* so there is nothing to swap until the rotate call returns, and the new secret
* is pushed to the signer in deferred work after that response. There is
* therefore a brief window in which EITHER secret may sign a delivery. A 4xx
* returned in that window is NOT retried — reject a genuine delivery there and
* it is gone for good.
*
* Full procedure: docs.vonpay.com/integration/webhook-secrets#3-rotate
*/
export function verifySignature(
rawBody: string,
header: string | null,
signingSecret: string,
): boolean {
if (!header) return false;

let t: number | null = null;
const candidates: string[] = [];
for (const part of header.split(",")) {
const idx = part.indexOf("=");
if (idx === -1) continue;
const k = part.slice(0, idx).trim();
const v = part.slice(idx + 1).trim();
if (k === "t") t = Number(v);
else if (k === "v1" && v) candidates.push(v);
}
if (t === null || !Number.isFinite(t) || candidates.length === 0) return false;

const now = Math.floor(Date.now() / 1000);
if (now - t > MAX_AGE_S) return false;
if (t - now > MAX_SKEW_S) return false;

const expected = crypto
.createHmac("sha256", signingSecret)
.update(`${t}.${rawBody}`, "utf8")
.digest();

return candidates.some((hex) => {
let given: Buffer;
try { given = Buffer.from(hex, "hex"); } catch { return false; }
// timingSafeEqual throws on a length mismatch — check first.
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}

The same verifier in PHP, since many platforms are:

<?php
function vonpay_verify_signature(string $rawBody, ?string $header, string $secret): bool {
if ($header === null) return false;

$t = null; $candidates = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) continue;
if ($kv[0] === 't') $t = (int) $kv[1];
if ($kv[0] === 'v1') $candidates[] = $kv[1];
}
if ($t === null || !$candidates) return false;

$now = time();
if ($now - $t > 300) return false; // too old
if ($t - $now > 30) return false; // too far in the future

// Raw UTF-8 secret, prefix included. Message is "<t>.<raw body>".
$expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret);

foreach ($candidates as $given) {
if (hash_equals($expected, $given)) return true; // constant-time
}
return false;
}

The header carries one v1= entry, no grace window: after a rotation every delivery is signed with the new secret only and a 4xx you return is not retried — accept both secrets while you rotate, then drop the old one (how).

4.3 The receiver

// src/vonpay/webhooks.ts (continued)
import express from "express";

export const webhookRouter = express.Router();

webhookRouter.post(
"/hooks/vonpay/:tenantId",
// RAW body. This route must see the body BEFORE any JSON parser does — a
// parsed-then-re-serialised body will never match the signature.
//
// ⚠ Ordering inside THIS file is not enough. If your app mounts a global
// `app.use(express.json())` at startup — the common pattern — it has already
// consumed the body by the time this runs, `req.body` is an object, and every
// real webhook then fails verification with a 401 that has no obvious cause.
// Mount this router before that parser, or exclude this path from it.
express.raw({ type: "application/json", limit: "1mb" }),
async (req, res) => {
const raw = (req.body as Buffer).toString("utf8");
const creds = await loadCredentials(req.params.tenantId);
if (!creds?.webhookSigningSecret) return res.status(404).end();

if (!verifySignature(raw, req.header("x-vonpay-signature"), creds.webhookSigningSecret)) {
return res.status(401).end();
}

const event = JSON.parse(raw) as VonPayEvent;

// At-least-once delivery, so dedup BEFORE the work. `claimEvent` must be a
// durable, atomic insert — a unique index on (tenant_id, event_id) is what
// actually enforces it, because two deliveries can arrive concurrently.
// Returning false means "already claimed": we have seen this event.
if (!(await claimEvent(req.params.tenantId, event.id))) {
return res.status(200).end();
}

// ⛔ PROCESS FIRST, ACK LAST. Do NOT send 200 before handleEvent resolves.
//
// A 200 tells us the event was delivered, and we will never send it again.
// If you ack first and the handler then throws — a DB blip, a downstream
// timeout, a bug in one branch — that charge.succeeded or charge.refunded is
// gone permanently, with no error anywhere and no retry to recover it. A
// crash between the ack and the handler is worse: the event body only ever
// existed in memory, so it dies with the process.
//
// Acking last is safe here precisely BECAUSE of claimEvent above: if we are
// slow and the delivery is retried, the retry hits the claim and no-ops. You
// trade a duplicate delivery (harmless) for never losing one (not harmless).
try {
await handleEvent(req.params.tenantId, event);
res.status(200).end();
} catch (err) {
// Release the claim AND return non-2xx, so the retry actually re-delivers.
// Releasing without a non-2xx frees the dedup slot for a redelivery that
// is never coming.
await releaseEvent(req.params.tenantId, event.id);
log.error({ err, eventId: event.id }, "vonpay webhook handler failed");
res.status(500).end();
}
},
);

// If your handler is genuinely slow (seconds), don't ack-first to hide it —
// persist the WHOLE event body durably, ack, and drain it from a worker with a
// reaper for rows left in flight. Persisting a claim flag alone is not that:
// the claim records that you saw an id, not what the event said.

4.4 Routing the event

// src/vonpay/webhooks.ts (continued)
export interface VonPayEvent {
id: string; // vp_evt_… — dedup on this
type: string;
created: number; // unix seconds
livemode: boolean; // ONE endpoint receives both test and live
merchant_id: string;
data: Record<string, unknown>; // narrow per event type — never `any`
}

export async function handleEvent(tenantId: string, event: VonPayEvent) {
// Correlate on payment_intent_id — NOT session_id, which one payment can reuse
// across two events.
const intentId = event.data.payment_intent_id as string | null;

switch (event.type) {
case "charge.succeeded":
case "payment_intent.succeeded":
await markPaid(tenantId, intentId, event.data.amount, event.livemode);
break;

case "charge.failed":
case "payment_intent.failed":
await markFailed(tenantId, intentId, {
code: event.data.failure_code,
networkCode: event.data.network_decline_code,
});
break;

case "charge.refunded":
// Read amount_refunded_total — it is cumulative and unambiguous.
// Do NOT sum `amount` yourself: on some connections it is ALREADY a running
// total, so summing marks an order fully refunded after the first partial.
await syncRefundTotal(tenantId, intentId, event.data.amount_refunded_total);
break;

case "refund.failed":
// No money went back. Keep it open in your ledger.
await flagRefundForReview(tenantId, intentId, event.data.refund_id);
break;

case "payment_intent.cancelled":
await markVoided(tenantId, intentId);
break;

default:
log.info({ type: event.type }, "unhandled vonpay event");
}

// Health signal for phase 7. Record VERIFIED deliveries, not arrivals.
await recordVerifiedDelivery(tenantId, event.id, event.created);
}

Order is not guaranteed. Drive state off type + data.status, never arrival order.

4.5 Register the endpoint for the merchant

A merchant can register a webhook endpoint by hand, copy the signing secret, and paste it into your settings page. Every step there is a step they can get wrong, and a wrong one is silent: the connection looks healthy and your platform simply never hears about refunds.

// src/vonpay/webhooks.ts (continued)
import { PLATFORM } from "./gateway";

export const CONNECTOR_EVENTS = [
"charge.succeeded",
"charge.failed",
"charge.refunded",
"refund.failed",
"payment_intent.succeeded",
"payment_intent.failed",
"payment_intent.cancelled",
] as const;

export interface WebhookSubscription {
id: string;
object: "webhook_subscription";
url: string;
enabledEvents: string[];
status: "active" | "paused" | "disabled";
lastSuccessAt: string | null;
createdAt: string;
}

export const webhookUrl = (tenantId: string) =>
`${process.env.PUBLIC_BASE_URL}/hooks/vonpay/${tenantId}`;

/**
* Idempotent: safe to call on every reconnect.
*
* Note the request body is camelCase — the webhooks surface follows the session
* surface, not the snake_case payment surface.
*
* `signingSecret` comes back ONCE, on create and on rotate, and never again. If a
* subscription already exists we cannot read its secret back, so we rotate to get
* a fresh one rather than leaving the tenant with a secret nobody holds.
*/
export async function ensureWebhook(
vp: VonPayClient,
tenantId: string,
existing: WebhookSubscription[] = [],
): Promise<{ id: string; signingSecret: string }> {
const url = webhookUrl(tenantId);
const match = existing.find((s) => s.url === url);

if (match) {
const rotated = await vp.request<{ id: string; signingSecret: string }>(
"POST", `/v1/webhook_subscriptions/${match.id}/rotate_signing_secret`,
// Without a key a retried 502 rotates TWICE, and the secret we store is
// then the one from the losing attempt — every delivery fails to verify.
{ body: {}, idempotencyKey: `${PLATFORM}_${tenantId}_hook-rotate` },
);
return { id: match.id, signingSecret: rotated.signingSecret };
}

try {
const created = await vp.request<WebhookSubscription & { signingSecret: string }>(
"POST", "/v1/webhook_subscriptions",
{
body: {
url,
enabledEvents: [...CONNECTOR_EVENTS],
description: `${PLATFORM} connector`,
},
// Keyed on the tenant, so a retry converges instead of creating a second
// subscription that then double-delivers every event to us.
idempotencyKey: `${PLATFORM}_${tenantId}_hook-create`,
},
);
return { id: created.id, signingSecret: created.signingSecret };
} catch (e) {
// Someone created it between our list and our create. Re-read and rotate.
if (e instanceof VonPayError && e.code === "webhook_subscription_conflict") {
const page = await vp.request<{ data: WebhookSubscription[] }>(
"GET", "/v1/webhook_subscriptions?limit=100",
);
return ensureWebhook(vp, tenantId, page.data);
}
throw e;
}
}

/** Powers the "Send test event" button in your settings page. Synchronous. */
export async function sendTestEvent(vp: VonPayClient, subscriptionId: string) {
return vp.request<{
delivered: boolean;
response_status: number | null;
delivery_attempt_id: string | null;
error: string | null;
}>(
"POST", `/v1/webhook_subscriptions/${subscriptionId}/send_test_event`,
{ body: { eventType: "charge.succeeded" } },
);
}

One subscription per merchant — not one per mode. Subscriptions are mode-agnostic: one endpoint receives both test and live events, and each event carries livemode. Branch on that field, not on two endpoints.

Register the subscription for the merchant rather than asking them to paste a secret — a connector that is connected but has silently never registered one processes payments and hears nothing, so refunds raised on one side never reverse the card on the other. Keep a manual paste-the-secret path so a merchant is never stuck if registration fails, and build a "have we heard from this merchant lately?" check keyed on verified deliveries, not arrivals.


Step 5 — The gateway settings page

This is the screen your merchant fills in. It is where most gateway integrations lose merchants, and almost all of the loss is avoidable.

The governing idea: ask for the fewest things that cannot be derived, then prove they work before saying "Connected".

5.1 Fields to collect

#FieldRequiredFormatInputNotes
1Secret keyAlwaysvp_sk_live_… / vp_sk_test_…Password (masked)The only credential needed to charge. Store encrypted; never render it back
2Publishable keyOnly if you render card fields in your pagevp_pk_live_… / vp_pk_test_…TextSafe in the browser. Must be the same merchant and same mode as #1
3Webhook signing secretOnly if you do not auto-register (§4.5)whsec_…Password (masked)Shown once, at creation, in our dashboard

That is the whole list. Three fields, and two of them are conditional.

Where the merchant gets them: app.vonpay.com/dashboard/developers/api-keys. Deep-link it from the field label — do not make them search.

// src/vonpay/connect.ts
export interface GatewaySettingsForm {
mode: "test" | "live";
secretKey: string;
publishableKey?: string;
// Only present when you offer the manual webhook path as a fallback.
webhookSigningSecret?: string;
}

/** Both modes use the same host. This is a constant, never a merchant input. */
export const VONPAY_HOST = "https://checkout.vonpay.com";

5.2 Fields to leave off — and why

Each of these appears on other gateways' settings pages. On ours, every one is either harmful or dead weight.

Do not addWhy
Merchant ID / account IDDerived from the key. No endpoint accepts a merchant id in the body. A field for it can only ever be wrong or ignored
Environment / mode dropdown (as a thing we're told)We never read a mode from your request. The key determines it. See §5.3 for the one legitimate use of a mode control
API base URL / endpointhttps://checkout.vonpay.com for both test and live. If we boarded you on a processor sandbox we gave you a specific host — hardcode it per environment in your build, do not ask the merchant
Session signing secret (ss_…)Merchants see one in their dashboard and will try to paste it. It cannot verify anything you receive — the return-URL signature uses a platform-wide key, so a merchant's ss_… can never match it. Confirm payment status by calling us back with the API key you already hold
Statement descriptorConfigured per merchant on our side, not per call. Display it read-only from GET /v1/descriptor_config and link the merchant to us to change it. A writable field here would silently do nothing
CurrencyComes from the charge. Show the merchant's settlement currencies read-only from GET /v1/capabilities
"Enable 3-D Secure" toggleNot yours to switch. It is decided by the issuer and the merchant's account configuration. What you do need is a return_url — see §5.6

5.3 Test and live

Mode is carried entirely by the key. Your merchant never tells us which mode they want, and we never read a mode field from the request body.

The pattern that works: store two independent credential sets, and give the merchant one Mode: Test / Live control whose only job is choosing which stored set your platform sends.

// src/vonpay/connect.ts (continued)

/** Two independent credential rows per tenant. Switching mode never overwrites. */
export async function loadCredentials(tenantId: string): Promise<VonPayCredentials | null> {
const row = await db.gatewayCredentials.findFirst({
where: { tenantId, provider: "vonpay", active: true },
});
if (!row) return null;
return {
secretKey: decrypt(row.secretKeyEnc),
publishableKey: row.publishableKey ?? undefined,
webhookSigningSecret: row.webhookSigningSecretEnc
? decrypt(row.webhookSigningSecretEnc)
: undefined,
webhookSubscriptionId: row.webhookSubscriptionId ?? undefined,
};
}

/**
* Validate the prefix against the SELECTED mode, in the browser, before any
* network call. A vp_sk_test_ pasted into the live slot is a mistake you can
* catch in 2ms instead of letting a merchant lose a day to it.
*/
export type PrefixVerdict = "ok" | "pasted_publishable_key" | "wrong_mode_key" | "not_a_key";

export function checkSecretKeyPrefix(key: string, mode: "test" | "live"): PrefixVerdict {
const trimmed = key.trim();
if (trimmed.startsWith("vp_pk_")) return "pasted_publishable_key";
if (!trimmed.startsWith("vp_sk_")) return "not_a_key";
const expected = mode === "live" ? "vp_sk_live_" : "vp_sk_test_";
return trimmed.startsWith(expected) ? "ok" : "wrong_mode_key";
}

Show the mode as a persistent badge on every payment screen, not just in settings. A merchant who forgets they are in test mode will report missing money.

5.4 The "Test connection" button

Do not save credentials you have not proven. Run this sequence on save and on demand — two cheap reads, no writes, no money.

// src/vonpay/connect.ts (continued)
import { VonPayClient, VonPayError } from "./client";
import type { WebhookSubscription } from "./webhooks";

export interface Capabilities {
supported_operations: {
auth_capture_separation: boolean;
partial_capture: boolean;
partial_refund: boolean;
unreferenced_refund: boolean;
void_after_capture: string; // e.g. "not_supported"
mit: boolean;
network_tokens: boolean;
three_d_secure_2: boolean;
ach: boolean;
payouts_api: boolean;
dispute_reporting: "tracked" | "not_tracked";
};
settlement_currencies: string[];
rate_limits: { payment_intents_per_minute: number };
}

export type ConnectionProbe =
| { ok: true; capabilities: Capabilities; subscriptions: WebhookSubscription[] }
| { ok: false; code: string; requestId?: string | null };

export async function testConnection(
form: GatewaySettingsForm,
): Promise<ConnectionProbe> {
// ── Step 1 ── format, before any network call.
const verdict = checkSecretKeyPrefix(form.secretKey, form.mode);
if (verdict !== "ok") return { ok: false, code: verdict };

const vp = new VonPayClient({ secretKey: form.secretKey.trim() }, VONPAY_HOST);

// ── Step 2 ── the key authenticates AND the merchant has a working configuration.
// 404 merchant_not_configured means their account isn't finished
// on our side — that is a different message from a bad key.
let capabilities: Capabilities;
try {
capabilities = await vp.request<Capabilities>("GET", "/v1/capabilities");
} catch (e) {
if (e instanceof VonPayError) return { ok: false, code: e.code, requestId: e.requestId };
throw e;
}

// ── Step 3 ── ...and it is genuinely a SECRET key.
//
// NOT OPTIONAL. GET /v1/capabilities accepts EITHER key type, so a merchant who
// pastes their publishable key into the secret field passes step 2 cleanly — and
// then every charge fails in production. This endpoint is secret-key-only and
// returns 403 auth_key_type_forbidden on a publishable key, so it is what
// actually proves you hold the right one. It also returns the existing
// subscriptions we need in order to reconcile the webhook.
let subscriptions: WebhookSubscription[];
try {
const page = await vp.request<{ data: WebhookSubscription[] }>(
"GET", "/v1/webhook_subscriptions?limit=100",
);
subscriptions = page.data;
} catch (e) {
if (e instanceof VonPayError) return { ok: false, code: e.code, requestId: e.requestId };
throw e;
}

return { ok: true, capabilities, subscriptions };
}
Step 3 is the non-obvious part

GET /v1/capabilities accepts either key type. A connection test built on it alone passes when a merchant pastes their publishable key into the secret field — they see a green tick and discover the problem on their first live charge. The second, secret-key-only call is not optional.

Two rules to design against: a credential can pass a connection test and still fail at money time, so check capabilities and not just authentication; and a stored probe result nobody reads is not a check — read it before you show a green badge.

5.5 Save, and let the account drive the UI

Persist only after the probe passes, then register the webhook in the same transaction of work, so a merchant can never end up connected-but-deaf.

// src/vonpay/connect.ts (continued)
import { ensureWebhook } from "./webhooks";
import { MERCHANT_COPY } from "./copy";

export type SaveResult =
| { ok: true; capabilities: Capabilities; webhookVerified: boolean }
| { ok: false; message: string; requestId?: string | null };

export async function saveGatewaySettings(
tenantId: string,
form: GatewaySettingsForm,
): Promise<SaveResult> {
const probe = await testConnection(form);
if (!probe.ok) {
return {
ok: false,
message: MERCHANT_COPY[probe.code] ?? MERCHANT_COPY.default,
requestId: probe.requestId,
};
}

const vp = new VonPayClient({ secretKey: form.secretKey.trim() }, VONPAY_HOST);

// Register (or rotate) the webhook BEFORE we mark the connection healthy.
const hook = form.webhookSigningSecret
? { id: null, signingSecret: form.webhookSigningSecret } // manual fallback
: await ensureWebhook(vp, tenantId, probe.subscriptions);

await db.gatewayCredentials.upsert({
where: { tenantId_provider_mode: { tenantId, provider: "vonpay", mode: form.mode } },
update: {
secretKeyEnc: encrypt(form.secretKey.trim()),
publishableKey: form.publishableKey?.trim() ?? null,
webhookSubscriptionId: hook.id,
webhookSigningSecretEnc: encrypt(hook.signingSecret),
capabilities: probe.capabilities, // cached for the UI; re-read on open
active: true,
},
create: { /* …same fields… */ },
});

// Make the other mode inactive without deleting it.
await db.gatewayCredentials.updateMany({
where: { tenantId, provider: "vonpay", mode: { not: form.mode } },
data: { active: false },
});

// Prove the round trip end to end, so "Connected" means something.
let webhookVerified = false;
if (hook.id) {
const probeResult = await sendTestEvent(vp, hook.id);
webhookVerified = probeResult.delivered && probeResult.response_status === 200;
}

return { ok: true, capabilities: probe.capabilities, webhookVerified };
}

export async function disconnect(tenantId: string) {
const creds = await loadCredentials(tenantId);
if (creds?.webhookSubscriptionId) {
const vp = new VonPayClient(creds, VONPAY_HOST);
try {
await vp.request("DELETE", `/v1/webhook_subscriptions/${creds.webhookSubscriptionId}`);
} catch (e) {
// Already gone is a success for our purposes.
if (!(e instanceof VonPayError && e.code === "webhook_subscription_not_found")) throw e;
}
}
await db.gatewayCredentials.deleteMany({ where: { tenantId, provider: "vonpay" } });
}

GET /v1/capabilities returns exactly what this merchant's connection can do. Use it to decide what to render — never a config file, never a hardcoded list.

{
"supported_operations": {
"auth_capture_separation": true,
"partial_capture": true,
"partial_refund": true,
"unreferenced_refund": false,
"void_after_capture": "not_supported",
"mit": true,
"network_tokens": true,
"three_d_secure_2": true,
"ach": false,
"payouts_api": true,
"dispute_reporting": "not_tracked"
},
"settlement_currencies": ["USD", "EUR", "GBP", "CAD", "AUD"],
"rate_limits": { "payment_intents_per_minute": 100 }
}
CapabilityWhat your settings page should do
auth_capture_separation: falseHide or disable the "Authorize only" capture option entirely
mit: falseHide "Save cards for future billing". Offering it would create tokens that can never be charged
partial_refund: falseRefund UI becomes full-amount only
void_after_capture: "not_supported"After capture, offer Refund — never Void
ach: falseDo not offer bank debit
dispute_reporting: "not_tracked"Show a warning banner. On this connection a chargeback never reaches us, so your record of the payment keeps reading "succeeded" no matter what happens at the card network. The merchant must watch their processor's own dashboard and respond there. Merchants have lost disputes by default because nobody told them
settlement_currenciesPopulate the currency display; do not hardcode a list

Also read GET /v1/descriptor_config and render a statement-descriptor preview — "Your customers will see ACME* ORDER-1234 on their statement." It is read-only, it costs you one call, and it heads off a whole class of chargebacks caused by an unrecognised descriptor.

export async function loadDescriptorPreview(vp: VonPayClient) {
const { descriptor_config } = await vp.request<{
descriptor_config: { name: string; dynamic_suffix?: { source: string } } | null;
}>("GET", "/v1/descriptor_config");

if (!descriptor_config) return null; // connector default applies
const tail = descriptor_config.dynamic_suffix?.source === "order_id" ? "ORDER-1234" : "";
// The whole thing — brand, separator and tail — is capped at 22 characters.
return `${descriptor_config.name}${tail ? `* ${tail}` : ""}`.slice(0, 22);
}

5.6 Behaviour settings

Keep the list short. Every toggle is a support ticket.

SettingDefaultMaps to
CaptureAuthorize and capture / Authorize onlyAuthorize and capturecapture_method: "automatic" / "manual". Only show if auth_capture_separation is true
Save cards for future billingOffTurns on setup_for_future_use: "off_session" + your consent checkbox. Only show if mit is true
Billing arrangement (appears when saving is on)storedCredentialUse: Subscriptionrecurring, Fixed instalmentsinstallment, Charge when they orderunscheduled. Explain in the merchant's words; the value is a declaration to the card networks and it cannot be changed for cards already saved
Collect billing addressOnPopulates billing_address, which lets the processor run address verification. Improves approval rates
3-D Secure return URL(you set this, not the merchant)return_url on the charge. Without it a challenge-required payment is rejected outright
Webhook endpoint(you set this)Show it read-only with a Send test event button and the timestamp of the last verified delivery
// src/vonpay/connect.ts (continued)
export interface GatewayBehaviour {
captureMode: "automatic" | "manual";
saveCards: boolean;
arrangement: BillingArrangement;
collectBillingAddress: boolean;
}

export const DEFAULT_BEHAVIOUR: GatewayBehaviour = {
captureMode: "automatic",
saveCards: false,
arrangement: "unscheduled",
collectBillingAddress: true,
};

/**
* Never persist a setting the account cannot honour — a stored `manual` on an
* account without auth/capture separation fails at money time, not at save time.
*/
export function clampToCapabilities(
b: GatewayBehaviour,
caps: Capabilities,
): GatewayBehaviour {
return {
...b,
captureMode: caps.supported_operations.auth_capture_separation ? b.captureMode : "automatic",
saveCards: caps.supported_operations.mit ? b.saveCards : false,
};
}

5.7 Error messages the merchant can act on

Map our code to copy that names the fix. Never show them a raw JSON body.

// src/vonpay/copy.ts
export const MERCHANT_COPY: Record<string, string> = {
// Local prefix checks — caught before any network call.
pasted_publishable_key:
"That looks like your publishable key. Paste the secret key — it starts vp_sk_.",
wrong_mode_key:
"That key is for a different environment. Live mode needs a key starting vp_sk_live_.",
not_a_key:
"That doesn't look like a Von Payments key. Secret keys start vp_sk_.",

// Auth
auth_invalid_key:
"We didn't recognise that key. Copy the whole key from your Von Payments dashboard — it should start vp_sk_.",
auth_key_type_forbidden:
"That looks like your publishable key. Paste the secret key, which starts vp_sk_.",
auth_key_expired:
"This key has been rotated or has expired. Create a new one in your Von Payments dashboard.",
auth_merchant_inactive:
"This Von Payments account is currently disabled. Contact Von Payments support.",

// Account state
merchant_not_onboarded:
"This account hasn't finished onboarding with Von Payments, so live keys aren't available yet.",
merchant_not_configured:
"Payment routing isn't set up on this Von Payments account yet. Contact Von Payments.",

// Runtime
payment_method_mode_mismatch:
"This saved card belongs to your other environment. Switch modes, or save the card again in this one.",
payment_method_consent_missing:
"This card wasn't saved with permission for automatic charges. Ask the customer to re-enter it.",
provider_unavailable:
"The payment network is temporarily unavailable. We'll retry automatically.",
endpoint_not_implemented:
"This Von Payments account isn't activated for this operation yet. Contact Von Payments.",

default:
"We couldn't reach Von Payments with that key. Check the key and try again.",
};

export const merchantCopy = (code: string) => MERCHANT_COPY[code] ?? MERCHANT_COPY.default;

rate_limit_exceeded is deliberately absent — never show it. Back off per the Retry-After header and retry (the client in §2.2 already does).

Log X-Request-Id against the merchant on every failure and surface it in your own support view. It is the fastest way for us to answer a question about their account.

5.8 The screen

// src/settings/GatewaySettings.tsx
import { useState } from "react";
import { checkSecretKeyPrefix } from "../vonpay/connect";
import { MERCHANT_COPY } from "../vonpay/copy";

export function VonPayGatewaySettings({ tenant }: { tenant: Tenant }) {
const saved = tenant.vonpay;
const [mode, setMode] = useState<"test" | "live">(saved?.mode ?? "test");
const [secretKey, setSecretKey] = useState("");
const [publishableKey, setPublishableKey] = useState(saved?.publishableKey ?? "");
const [status, setStatus] = useState<"idle" | "testing" | "saving">("idle");
const [error, setError] = useState<string | null>(null);
const [caps, setCaps] = useState(saved?.capabilities ?? null);

const ops = caps?.supported_operations;
// Capability-driven: never render a control the account cannot honour.
const canAuthorizeOnly = ops?.auth_capture_separation === true;
const canSaveCards = ops?.mit === true;
const disputesUntracked = ops?.dispute_reporting === "not_tracked";

// The secret key is never re-rendered. An empty field on an already-connected
// account means "keep the stored key"; typing replaces it.
const secretPlaceholder = saved ? "•".repeat(24) : "vp_sk_live_…";

async function submit(action: "test" | "save") {
setError(null);
if (secretKey) {
const verdict = checkSecretKeyPrefix(secretKey, mode);
if (verdict !== "ok") return setError(MERCHANT_COPY[verdict]);
}
setStatus(action === "test" ? "testing" : "saving");
const res = await fetch(`/api/tenants/${tenant.id}/gateways/vonpay`, {
method: action === "test" ? "PUT" : "POST", // PUT = probe only, POST = persist
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode, secretKey: secretKey || undefined, publishableKey }),
}).then((r) => r.json());
setStatus("idle");
if (!res.ok) return setError(res.message);
setCaps(res.capabilities);
setSecretKey(""); // never keep it in component state
}

return (
<section aria-labelledby="vp-h">
<header>
<h2 id="vp-h">Von Payments</h2>
{saved && (
<span data-status={saved.connected ? "connected" : "error"}>
{saved.connected ? "Connected" : "Not connected"} · {mode === "live" ? "Live" : "Test"}
</span>
)}
</header>

<fieldset>
<legend>Mode</legend>
<label><input type="radio" checked={mode === "test"} onChange={() => setMode("test")} /> Test</label>
<label><input type="radio" checked={mode === "live"} onChange={() => setMode("live")} /> Live</label>
<p className="help">Test mode uses your test keys. No real money moves.</p>
</fieldset>

<label htmlFor="vp-sk">Secret key</label>
<input
id="vp-sk" type="password" autoComplete="off" spellCheck={false}
placeholder={secretPlaceholder}
value={secretKey} onChange={(e) => setSecretKey(e.target.value)}
/>
<p className="help">
Starts <code>vp_sk_</code>. Server-side only.{" "}
<a href="https://app.vonpay.com/dashboard/developers/api-keys"
target="_blank" rel="noreferrer">Get your keys ↗</a>
</p>

<label htmlFor="vp-pk">Publishable key</label>
<input
id="vp-pk" type="text" spellCheck={false} placeholder="vp_pk_live_…"
value={publishableKey} onChange={(e) => setPublishableKey(e.target.value)}
/>
<p className="help">Starts <code>vp_pk_</code>. Safe in the browser.</p>

{error && <p role="alert" className="error">{error}</p>}

<div className="actions">
<button type="button" onClick={() => submit("test")} disabled={status !== "idle"}>
{status === "testing" ? "Testing…" : "Test connection"}
</button>
<button type="button" onClick={() => submit("save")} disabled={status !== "idle"}>
{status === "saving" ? "Saving…" : "Save"}
</button>
</div>

{caps && (
<div className="status-strip">
<p>✓ Key verified — secret key, {mode} mode</p>
<p>
✓ Webhook registered
{saved?.lastVerifiedDeliveryAt && ` · last verified delivery ${timeAgo(saved.lastVerifiedDeliveryAt)}`}
</p>
<p>Currencies <b>{caps.settlement_currencies.join(" ")}</b></p>
{saved?.descriptorPreview && <p>Statement <b>{saved.descriptorPreview}</b></p>}
{disputesUntracked && (
<p role="status" className="warn">
⚠ Chargebacks on this connection are not reported back to Von Payments.
Monitor disputes in your processor dashboard.
</p>
)}
</div>
)}

{caps && (
<fieldset>
<legend>Payment behaviour</legend>

{/* Hidden entirely when the account can't separate auth from capture —
showing a control that always fails is worse than not offering it. */}
{canAuthorizeOnly && (
<>
<label><input type="radio" name="cap" defaultChecked /> Authorize and capture</label>
<label><input type="radio" name="cap" /> Authorize only</label>
</>
)}

{canSaveCards && (
<>
<label>
<input type="checkbox" defaultChecked={saved?.behaviour.saveCards} />
{" "}Let customers save cards for future orders
</label>
<fieldset className="indent">
<legend>Billing arrangement</legend>
<label><input type="radio" name="arr" /> Subscription</label>
<label><input type="radio" name="arr" defaultChecked /> Charge when they order</label>
<p className="help">Can’t be changed for cards already saved.</p>
</fieldset>
</>
)}

<label>
<input type="checkbox" defaultChecked={saved?.behaviour.collectBillingAddress ?? true} />
{" "}Collect billing address at checkout (improves approvals)
</label>
</fieldset>
)}
</section>
);
}

Rendered, that is:

┌─ Von Payments ──────────────────────────── ● Connected · Live ─┐
│ │
│ Mode ( ) Test (•) Live │
│ Test mode uses test keys. No real money moves. │
│ │
│ Secret key [ •••••••••••••••••••••••• ] ↗ Get keys │
│ Publishable key [ vp_pk_live_a1b2c3d4e5 ] │
│ │
│ [ Test connection ] [ Save ] │
├────────────────────────────────────────────────────────────────┤
│ ✓ Key verified ✓ Webhook registered · verified 4m ago│
│ Currencies USD EUR GBP CAD AUD │
│ Statement ACME* ORDER-1234 │
│ │
│ ⚠ Chargebacks on this connection are not reported back to │
│ Von Payments. Monitor disputes in your processor dashboard. │
├────────────────────────────────────────────────────────────────┤
│ Payment behaviour │
│ Capture (•) Authorize and capture ( ) Authorize only │
│ Save cards [x] Let customers save cards for future orders│
│ Arrangement ( ) Subscription (•) Charge when they order │
│ Billing address [x] Collect at checkout (improves approvals) │
└────────────────────────────────────────────────────────────────┘

The status strip under the credentials is the part people skip. It is what turns "I saved it, I think it worked" into "I can see it worked."

5.9 Anti-pattern checklist

  • The secret key is never re-displayed after save — masked, replaceable, never readable
  • Test and live credentials are stored separately; switching mode does not overwrite
  • No merchant-ID field, no base-URL field, no ss_… field
  • Saving without a successful connection test is not possible
  • The connection test calls a secret-key-only endpoint, not just capabilities
  • Exactly one webhook subscription per merchant — not one per mode
  • Retries reuse the original Idempotency-Key; nothing appends an attempt counter
  • Refund handling branches on status, and a 202 is not treated as a failure
  • Refund totals read amount_refunded_total; nothing sums amount
  • setup_for_future_use is sent when the card is saved, never on the charge
  • Toggles the merchant's account cannot support are hidden, not shown-and-failing
  • dispute_reporting: "not_tracked" surfaces a visible warning
  • Disconnecting deletes the webhook subscription and wipes stored credentials

Step 6 — Certification

Before we list you, show us these run with the test keys from Step 0. A screen recording or a written log with request ids is fine. On that self-serve sandbox, amount 200 declines and any other amount approves — the declined-sale case relies on it.

// scripts/certify.ts — run with a vp_sk_test_ key. No real money is involved.
import assert from "node:assert/strict";
import { VonPayClient } from "../src/vonpay/client";
import { VonPayGateway, idem } from "../src/vonpay/gateway";
import { testConnection, checkSecretKeyPrefix } from "../src/vonpay/connect";

const vp = new VonPayClient({ secretKey: process.env.VP_TEST_KEY! });
const gw = new VonPayGateway(vp);
const uniq = () => `cert-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;

/** Shared charge fields. Swap in a vp_pmt_ token vaulted in your own sandbox. */
const base = () => ({
currency: "USD",
paymentMethodId: process.env.VP_TEST_PAYMENT_METHOD!, // vp_pmt_test_…
buyerId: "cert-buyer-1",
returnUrl: "https://example.com/3ds/return",
});

const checks: Array<[string, () => Promise<void>]> = [

// ── Credentials ───────────────────────────────────────────────
["01 valid secret key connects", async () => {
const p = await testConnection({ mode: "test", secretKey: process.env.VP_TEST_KEY! });
assert.equal(p.ok, true);
}],

["02 publishable key in the secret field is rejected", async () => {
assert.equal(
checkSecretKeyPrefix(process.env.VP_TEST_PUBLISHABLE_KEY!, "test"),
"pasted_publishable_key",
);
// …and it is rejected on the wire too, not just locally.
const p = await testConnection({
mode: "test", secretKey: process.env.VP_TEST_PUBLISHABLE_KEY!,
});
assert.equal(p.ok, false);
}],

["03 a live-mode key is rejected in test mode", async () => {
assert.equal(checkSecretKeyPrefix("vp_sk_live_xxxxxxxx", "test"), "wrong_mode_key");
}],

// ── Payments ──────────────────────────────────────────────────
["05 sale approved", async () => {
const i = await gw.sale({ ...base(), orderId: uniq(), amountMinor: 1499 });
assert.equal(i.status, "succeeded");
}],

["06 sale declined surfaces a decline, not an exception", async () => {
const i = await gw.sale({ ...base(), orderId: uniq(), amountMinor: 200 });
assert.equal(i.status, "failed");
assert.ok(i.decline_code, "a declined charge must carry a decline_code");
}],

["07 authorize then capture in full", async () => {
const order = uniq();
const auth = await gw.authorize({ ...base(), orderId: order, amountMinor: 5000 });
assert.equal(auth.status, "authorized");
const cap = await gw.capture(order, auth.id);
assert.equal(cap.status, "succeeded");
}],

["08 authorize then capture partially", async () => {
const order = uniq();
const auth = await gw.authorize({ ...base(), orderId: order, amountMinor: 5000 });
const cap = await gw.capture(order, auth.id, 2500);
assert.equal(cap.status, "succeeded");
}],

["09 authorize then void", async () => {
const order = uniq();
const auth = await gw.authorize({ ...base(), orderId: order, amountMinor: 5000 });
const v = await gw.void(order, auth.id);
assert.equal(v.status, "voided");
}],

["10 full refund, then a partial second refund does not double-count", async () => {
const order = uniq();
const sale = await gw.sale({ ...base(), orderId: order, amountMinor: 5000 });
const first = await gw.refund(order, sale.id, { amountMinor: 2000 });
assert.ok(["refunded", "pending"].includes(first.kind));
const second = await gw.refund(order, sale.id, { amountMinor: 1000, refundRef: "refund-2" });
assert.ok(["refunded", "pending"].includes(second.kind));
assert.notEqual(first.refundId, second.refundId, "a distinct key must mint a distinct refund");
}],

["11 a retried charge with the same key produces exactly one charge", async () => {
const order = uniq();
const input = { ...base(), orderId: order, amountMinor: 1499 };
// Sequential, because this is what a timeout retry actually looks like.
// (Two TRULY concurrent sends can return 409 charge_in_progress to the loser.
// That is NOT retryable — the original may already have charged — so don't
// assert on the concurrent shape here. Assert on the outcome that matters,
// which is that only one intent exists.)
const first = await gw.sale(input);
const replay = await gw.sale(input);
assert.equal(first.id, replay.id, "same Idempotency-Key must return the original intent");
}],

["11b a refund replayed on the same key is reported as a replay", async () => {
const order = uniq();
const sale = await gw.sale({ ...base(), orderId: order, amountMinor: 3000 });
await gw.refund(order, sale.id, { amountMinor: 1000 });
const again = await gw.refund(order, sale.id, { amountMinor: 1000 });
assert.equal(again.kind, "replay", "an identical refund must NOT move money twice");
}],

// ── Saved cards (only if you offer recurring) ─────────────────
["13 a saved card charges off-session with the mit block", async () => {
/* requires a vp_pmt_ token vaulted with setup_for_future_use: "off_session" */
}],

["14 a card saved WITHOUT consent surfaces an actionable message", async () => {
/* expect VonPayError code === "payment_method_consent_missing" */
}],
];

for (const [name, fn] of checks) {
try { await fn(); console.log(` PASS ${name}`); }
catch (e) { console.error(` FAIL ${name}\n ${(e as Error).message}`); process.exitCode = 1; }
}

The webhook checks run against your own receiver rather than ours:

// 16 — signature verification rejects a tampered body
const raw = JSON.stringify({ id: "vp_evt_test_1", type: "charge.succeeded", data: {} });
const t = Math.floor(Date.now() / 1000);
const good = crypto.createHmac("sha256", SECRET).update(`${t}.${raw}`).digest("hex");

assert.equal(verifySignature(raw, `t=${t},v1=${good}`, SECRET), true);
assert.equal(verifySignature(raw + " ", `t=${t},v1=${good}`, SECRET), false); // body changed
assert.equal(verifySignature(raw, `t=${t - 400},v1=${good}`, SECRET), false); // too old
assert.equal(verifySignature(raw, `t=${t},v1=deadbeef`, SECRET), false); // bad hmac

// 17 — a duplicate event id is processed exactly once
await postToOwnReceiver(raw, t, good);
await postToOwnReceiver(raw, t, good);
assert.equal(await countSideEffects("vp_evt_test_1"), 1);

The full list we want evidence for

#Check
1–4Valid key connects · publishable key rejected with a fix message · wrong-mode key rejected before save · disconnect removes credentials and the webhook subscription
5–11Sale approved · sale declined · auth→capture full · auth→capture partial · auth→void · refund full and partial without double-counting · retry with the same key yields one charge
12–14Card saved with consent returns setup_for_future_use · saved card charges with the mit block · a card saved without consent surfaces payment_method_consent_missing as an actionable message
15–18Subscription auto-registered on connect · signature verification rejects a tampered body · duplicate id processed once · Send test event reports the real delivery outcome
19–20A 429 backs off and retries rather than failing the payment · a 501 surfaces as a provisioning message, not a crash

Step 7 — Go live

  1. The merchant gets live keys from us, not from you. Live keys require an approved application — KYC and contract. Requesting them early returns 403 merchant_not_onboarded. Your settings page should link to onboarding when it sees that code.
  2. Roll out in stages. First merchant, then a small cohort, then general availability. Do not enable the gateway option for your whole merchant base in one step.
  3. Monitor. Two checks earn their keep:
// Liveness — unauthenticated, cheap, safe to poll.
// Any non-200 means "do not route traffic". Both failure shapes are 503 and only
// one carries `reason`, so branch on the status code, not the body.
const res = await fetch("https://checkout.vonpay.com/api/health");
if (!res.ok) await pauseVonPayRouting();
-- Webhook silence, per merchant. THIS is the check that would have caught the
-- incident in §4.5. Note it keys on VERIFIED deliveries: a receiver that returns
-- 200 to everything cannot tell you whether what arrived was real.
SELECT t.id, t.name, max(d.verified_at) AS last_verified
FROM tenants t
JOIN vonpay_charges c ON c.tenant_id = t.id
AND c.created_at > now() - interval '24 hours'
LEFT JOIN vonpay_webhook_deliveries d
ON d.tenant_id = t.id AND d.verified
GROUP BY t.id, t.name
HAVING max(d.verified_at) IS NULL
OR max(d.verified_at) < now() - interval '6 hours';

Also alert on a run of 501 or 422 merchant_not_configured for one merchant — that is provisioning drift on our side, and we would rather hear it from you than from them.

  1. Support. Send us the X-Request-Id, the merchant, and the timestamp. That is enough for us to answer almost anything.
  2. Getting into your gateway dropdown is a commercial conversation, not a technical one. There is no developer-portal review or app-store gate on our side. Talk to your Von Payments contact.

Checklist: Go live.


Appendix A — Endpoint quick reference

PurposeCallAuth
Capability matrixGET /v1/capabilitiesAny key
Prove a secret keyGET /v1/webhook_subscriptionsSecret
Statement descriptorGET /v1/descriptor_configSecret
Validate without creatingPOST /v1/sessions?dry_run=trueSecret or publishable
Create hosted sessionPOST /v1/sessionsSecret or publishable
Session statusGET /v1/sessions/{id}Secret
Charge / authorizePOST /v1/payment_intentsSecret
Retrieve a chargeGET /v1/payment_intents/{id}Secret
CapturePOST /v1/payment_intents/{id}/captureSecret
VoidPOST /v1/payment_intents/{id}/voidSecret
RefundPOST /v1/refundsSecret
Vault a card (browser)POST /v1/public/tokensPublishable
Vault a card (server)POST /v1/tokensSecret
List a customer's cardsGET /v1/payment_methods?buyer_id=Secret
Revoke a cardDELETE /v1/tokens/{id}Secret
Register webhookPOST /v1/webhook_subscriptionsSecret
Test a webhookPOST /v1/webhook_subscriptions/{id}/send_test_eventSecret
HealthGET /api/healthNone
Machine discoveryGET /.well-known/vonpay.jsonNone

Rate limits (sliding 60-second windows; ask us to lift them per merchant): payments and refunds 100/min per key; session creates 10/min per IP and 30/min per key; webhook reads 100/min per key, writes 30/min per key. Full detail: Rate limits.


Appendix B — Questions to raise with us before you build

  1. Which integration shape are our merchants' accounts provisioned for? Embedded fields availability is per-merchant. Hosted checkout works everywhere.
  2. Is the discrete payment-intents lifecycle activated for the connections our merchants are on? If not, they get 501 and we need to sequence activation with your rollout.
  3. Does card-on-file at irregular intervals work on these connections? See §3.4. If your merchants need it, we need to confirm it before you promise it.
  4. Does dispute_reporting read tracked for these merchants? If not, agree who tells the merchant and how.
  5. Should the connector auto-register webhooks? We recommend yes; confirm it works end to end against your sandbox account first.
  6. What is the exact consent-element API for embedded fields? See the note in §3.3 — confirm it before building recurring on top of it.
  7. Which host? Production for the self-serve sandbox; a separate host if we board you onto a processor sandbox.

Our Node and Python SDKs cover sessions, payment intents (create, capture, void), refunds, tokens and buyers, and list/retrieve webhook subscriptions; registering a subscription, rotating its secret, sending a test event and reading a payment intent back are REST calls — which is why §4.5 above registers with vp.request. The samples above hand-roll the client from the OpenAPI spec; the reference adapter uses @vonpay/checkout-node directly.