Skip to main content

@vonpay/checkout-node — Node.js SDK

Typed TypeScript/JavaScript client for the Von Payments Checkout API. Zero runtime dependencies, Node 20+.

Install​

npm install @vonpay/checkout-node

The package is an ES module. From 3.3.0, CommonJS code can load it with require("@vonpay/checkout-node") on Node 20.19+ or 22.12+, and gets the same module import returns. On older Node, use await import("@vonpay/checkout-node"). A TypeScript project that compiles to CommonJS needs "module": "nodenext" (TypeScript 5.8+), "module": "node20" (TypeScript 5.9+) or "moduleResolution": "bundler"; with "module": "node16" the type check reports TS1479 even though the code runs.

Pin the major you tested against: minor and patch releases are additive under semver, though a minor bump may add options or change defaults. A major bump is breaking and comes with an upgrade section: see Upgrading to 3.0.0.

Initialize​

import { VonPayCheckout, VonPayError } from "@vonpay/checkout-node";

// Simple — pass API key as a string
const vonpay = new VonPayCheckout("vp_sk_live_xxx");

// With options — pass a config object
const vonpay = new VonPayCheckout({
apiKey: "vp_sk_live_xxx",
apiVersion: "2026-04-14",
baseUrl: "https://checkout.vonpay.com", // default
maxRetries: 2, // default
timeout: 30_000, // ms, default
errorReporter: (err, ctx) => { // optional — see Error reporting below
Sentry.captureException(err, { extra: ctx });
},
});

The constructor validates the key prefix. A key must start with one of vp_sk_test_, vp_sk_live_, vp_pk_test_, or vp_pk_live_; passing a key that matches none of these throws immediately. (Most server-side calls require a secret vp_sk_* key; see sessions.get below.)


vonpay.sessions.create(params, options?)​

Create a checkout session and get a checkout URL.

const session = await vonpay.sessions.create({
amount: 1499, // in cents — required and >= 1 for mode "payment"
currency: "USD", // required, 3-letter code (uppercased)
successUrl: "https://mystore.com/order/123/confirm", // optional (HTTPS)
cancelUrl: "https://mystore.com/cart", // optional
country: "US", // optional, ISO 3166-1 alpha-2
mode: "payment", // optional, default "payment"
description: "Order #123", // optional
locale: "en", // optional
expiresIn: 1800, // optional, seconds (300–604800, max 7 days)
buyerId: "cust_abc", // optional — your STABLE per-user ID (not per-visit)
buyerName: "Jane Doe", // optional
buyerEmail: "jane@example.com", // optional
lineItems: [ // optional
{ name: "Widget", quantity: 1, unitAmount: 1499 },
],
metadata: { orderId: "order_123" }, // optional
}, {
idempotencyKey: "order_123_create", // optional request option
});

// session.id => "vp_cs_live_k7x9m2n4p3"
// session.checkoutUrl => "https://acme.vonpay.com/checkout?session=..."
// session.expiresAt => "2026-03-31T15:30:00.000Z"

From 3.5.0, buyerContact sends the buyer's phone, shippingAddress and billingAddress when you already have them, for example a sale your staff keys in. Every part is optional; addresses use the ShippingAddress shape (send state: "" where the country has none). Secret key only, never returned by sessions.get(), and personal data: send only what the buyer gave you and never log it. The session read also types nameCollection, emailCollection and channel.

amount is in minor units (cents): 1499 is $14.99. It is required and must be >= 1 for the default mode: "payment". currency is required and normalised to a 3-letter uppercase code. successUrl is optional; when present it must be a valid HTTPS URL. idempotencyKey is passed as the second-argument request option and sent as the Idempotency-Key header.

The live checkout URL is hosted on your merchant subdomain (for example https://acme.vonpay.com/checkout?session=...). expiresAt is an ISO-8601 timestamp string.

See Create a Session for the full parameter reference.

Embedded charge-and-save​

If the buyer completes payment through the embedded checkout (Embedded Fields) rather than the hosted page, the buyer fields above are what wire the session to vaulting:

  • A buyer reference on the session, a buyerId or a buyerEmail (with buyerName to enrich the record), is required to vault a reusable vp_pmt_* token via the embed. A guest session with no buyer reference at all charges once and saves nothing.
  • Under charge-and-save the embed charges on submit, so the server must not also call paymentIntents.create for that same session, or the buyer is charged twice.
  • The embed's client-side result is a UX signal only. Confirm settlement from the charge.succeeded webhook before fulfilling.

See Charge and save for the full embedded flow and the { token } | { charged: true } | { error } result shape.


vonpay.sessions.get(sessionId)​

Retrieve the full status of a session. Requires a secret key (vp_sk_*); a publishable key is rejected with HTTP 403 (auth_key_type_forbidden).

const status = await vonpay.sessions.get("vp_cs_live_k7x9m2n4p3");

// status.id => "vp_cs_live_k7x9m2n4p3"
// status.status => "succeeded"
// status.transactionId => "txn_abc123"
// status.amount => 1499
// status.currency => "USD"

Returns a full SessionStatus object including payment details and metadata. status.status is typed SessionState in the SDK, whose members are pending, processing, succeeded, failed, and expired. processing is a transient state during authorization, not a result: treat it as non-terminal and keep polling or wait for the webhook rather than branching on it as an outcome. On an expired session, expiredFrom: "processing" can later change to "failed" once the provider confirms an abandoned bank check authorized nothing, so a processing you read once is not final. The rules are on The session object.

From 3.6.0 the read also types these fields, in wire spelling like vp_tx_id:

  • retry: { allowed, attempts_remaining }, whether the session accepts another card after a decline (an elements session charging at submit, on an account with retry enabled). It is the only place to learn this when the decline came after the bank's check. null when it does not apply.
  • last_payment_error: { code, decline_code } for the most recent declined attempt. code is card_declined or payment_failed (open enum); decline_code uses the payment intent's failure_code values, or null.
  • amount_refunded, remaining_refundable and refundable: the refund totals, null together when they could not be read or the session has no single payment. null is not zero.

Each reads undefined from a server that does not send it, never a default.


vonpay.sessions.update(sessionId, params, options?)​

Change the total of an open, unpaid elements session, for example after the buyer picks a shipping option in the Apple Pay or Google Pay sheet. Requires a secret key. Added in 2.11.0. The rules are on Change an open session's total.

import { SessionNotModifiableError } from "@vonpay/checkout-node";

try {
const session = await vonpay.sessions.update("vp_cs_live_k7x9m2n4p3", { amount: 5498 });
// session.amount => 5498
} catch (err) {
if (err instanceof SessionNotModifiableError) {
// Nothing was changed. err.reason is not_open | integration_mode |
// setup_session | store_order | in_page_bank_check; err.session has its state.
}
throw err;
}

params takes amount (required, minor units), currency (optional; omit it to keep the session's currency) and lineItems (optional; replaces the displayed line items). It resolves with the session carrying the new amount. Setting a total is idempotent on its own, so idempotencyKey is optional.


vonpay.sessions.expire(sessionId, options?)​

Close an open, unpaid session so no payment can start on it afterwards. Call it before taking payment for the same order another way. Requires a secret key. Resolves with the closed session; any other state throws SessionNotExpirableError with the session's state, and nothing changes. The rules, including the one exception on embed sessions, are on Close a session so you can charge another way.

import { SessionNotExpirableError } from "@vonpay/checkout-node";

try {
await vonpay.sessions.expire("vp_cs_live_k7x9m2n4p3");
// Closed: now take payment another way.
} catch (err) {
if (err instanceof SessionNotExpirableError && err.reason === "recent_activity") {
// A declined session that changed recently: call expire() again after
// err.retryAfterSeconds and it closes. The SDK never repeats the call itself.
} else if (err instanceof SessionNotExpirableError) {
// Nothing changed, and a payment may exist on it. Do not charge another way.
}
throw err;
}

From 3.5.0 the error carries reason: "recent_activity" (call again after retryAfterSeconds), "payment_may_be_in_flight", or "unknown" for a value the SDK does not recognise, which you handle like "payment_may_be_in_flight". It is undefined when the API sent none. rawReason keeps the value as sent. A closed session's expiredFrom can be "failed" (a declined session closed this way), and like "pending" nothing can start on it afterwards.


vonpay.paymentIntents.retrieve(paymentIntentId)​

Read a payment intent's stored state (maps to GET /v1/payment_intents/{id}). Added in 2.7.0. Requires a secret key; a publishable key is rejected 403.

const intent = await vonpay.paymentIntents.retrieve("vpi_live_9f2nd4kq7x1m");

// intent.status => "succeeded"
// intent.amount => 1499

This is how you confirm a payment on a 3-D Secure return page. Pass the id you stored when you created the intent, never one read out of the return URL, which anyone can forge. Fulfil only on status === "succeeded": authorized is a hold, and captured is still pending at the processor.

From 3.6.0 the read also types:

  • avsResultCode and cvvResultCode: the address and security-code results for the payment. null when nothing was checked or no result came back. "unavailable" and "not_supported" (address) or "not_provided" and "unavailable" (security code) mean the check did not run, not that it failed. This server read is where a payment taken in the browser gets its result, because the browser never receives it. Keep the values on your server.
  • amountRefunded, remainingRefundable and refundable: the refund totals. All three are null together when the totals could not be read at that moment, and null is not zero: read again or call refunds.list(). refundable is false until the charge settles.
  • nextAction while the payment is requires_action, with issuedAt on the same object (beside redirectToUrl, not inside it) saying when the challenge link was issued. If your server lost the create response, send the buyer to the link from here; check issuedAt first, since the payment provider decides how long a link stays usable. See 3-D Secure.

Decline details and the card summary exist on the create response and are not re-read here.

const pi = await vonpay.paymentIntents.retrieve(storedIntentId);
if (pi.cvvResultCode === "no_match") {
// a real mismatch
}

vonpay.refunds.list(target, page?)​

The refunds of one payment, plus its refund totals (maps to GET /v1/refunds). Added in 3.6.0. Requires a secret key. It never moves money and is retried like any other read.

const page = await vonpay.refunds.list(
{ transaction: session.vp_tx_id! }, // or { paymentIntent: "vpi_..." }, exactly one
{ limit: 25, startingAfter: cursor },
);

page.data; // newest first, every status
page.amountRefunded; // succeeded + still in progress, across all pages
page.remainingRefundable; // what a refund with no amount would refund now
page.refundable; // false until the charge settles, and when disputed or fully refunded
page.nextCursor; // pass back as startingAfter while hasMore; null on the last page

Name the payment with exactly one of paymentIntent or transaction: both or neither is a compile error, and a TypeError before any request. Refunds made through either id are listed whichever one you ask with. Check each refund's own status, since a requested refund is still in progress and can still fail. Each listed refund carries idempotencyKey (the key you sent, or null) and createdAt. There is no auto-paging helper: loop on nextCursor. A duplicate-record transaction throws 422 refund_target_is_duplicate with refundInstead, as on refunds.create(). The fields are on List a payment's refunds.

A refunds.create() answer now types idempotent: true when it replays an earlier request with the same idempotencyKey, so no new refund was issued.


vonpay.sessions.validate(params)​

Dry-run validation of session parameters without creating a session (maps to POST /v1/sessions?dry_run=true). Returns validation results.

const result = await vonpay.sessions.validate({
amount: 1499,
currency: "USD",
successUrl: "https://mystore.com/confirm",
});

// result.valid => true
// result.warnings => ["cancelUrl is recommended for production"]

vonpay.webhooks.verifySignature(payload, signature, secret)​

Verify an incoming webhook's HMAC-SHA256 signature. Uses crypto.timingSafeEqual to prevent timing attacks. Returns boolean; never throws. Prefer constructEvent for typed event parsing.

const isValid = vonpay.webhooks.verifySignature(
req.body, // raw request body (Buffer or string)
req.headers["x-vonpay-signature"] as string, // signature header (t=…,v1=…)
process.env.VON_PAY_WEBHOOK_SECRET, // whsec_* — per-endpoint secret
);

vonpay.webhooks.constructEvent(payload, signature, secret)​

Verify the signature, enforce the asymmetric replay window (5 min past / 30 sec future), and parse the webhook payload into a typed event. Takes 3 arguments. On a malformed header, a stale timestamp, or an HMAC mismatch it throws a VonPayError with code webhook_invalid_signature and HTTP status 401.

import express from "express";

const endpointSecret = process.env.VON_PAY_WEBHOOK_SECRET; // whsec_*

app.post("/webhooks/vonpay", express.raw({ type: "application/json" }), (req, res) => {
try {
const event = vonpay.webhooks.constructEvent(
req.body, // raw body (Buffer)
req.headers["x-vonpay-signature"] as string, // signature header (t=…,v1=…)
endpointSecret, // whsec_* — per-endpoint secret
);

switch (event.type) {
case "charge.succeeded":
console.log(`Paid: ${event.data.payment_intent_id}`);
break;
case "charge.failed":
console.log(`Failed: ${event.data.failure_reason}`);
break;
case "charge.refunded":
// null means the connection could not derive the total: say so, never "0".
console.log(`Refunded so far: ${event.data.amount_refunded_total ?? "unknown, review"}`);
break;
default:
// Unknown event types: log and fall through. constructEvent still
// returns a well-formed envelope for an event type added server-side
// after this SDK version, so new types may arrive without an SDK bump.
console.log(`Unhandled event: ${event.type}`);
}

// Your handler returns the HTTP 200 ack — constructEvent only parses the
// event; it does not send an HTTP response.
res.status(200).json({ received: true });
} catch (err) {
res.status(400).json({ error: err.message });
}
});

The webhook secret is per-endpoint (whsec_*), minted when you register the endpoint at /dashboard/developers/webhooks. The signature header is named x-vonpay-signature with the format t=<unix-seconds>,v1=<hex-hmac>. See Webhook Signing Secrets for the create / rotate / revoke lifecycle.


Confirming a return redirect​

When the buyer is redirected back to your successUrl, read the outcome from the API: the redirect itself only tells you they came back.

const client = new VonPayCheckout(process.env.VON_PAY_SECRET_KEY);

const params = Object.fromEntries(
new URL(req.url, `https://${req.headers.host}`).searchParams,
);

const outcome = await client.sessions.confirmReturn(params);

if (outcome.paid) {
// show the confirmation page
} else if (outcome.reason === "still_pending") {
// IN FLIGHT, not declined — show "confirming your payment", never a failure
} else {
// not succeeded
}

sessions.confirmReturn(params) verifies the return and reads the server-side status in one call, returning { paid, signatureValid, status, reason }. Branch on paid: it is decided by the authenticated session read, never by the redirect URL.

⚠️ Do not pass the signing secret. The secret parameter is optional and returns are signed platform-wide, so a per-merchant ss_* can never match. Leaving it out is the normal path: signatureValid then reads null, which means "not checked", a healthy value, not a failure. false would mean checked and it failed.

⚠️ paid: true is not "safe to fulfil". The status keeps reading succeeded on every replay of the same URL, so record which session IDs you have already fulfilled; and on a captureMethod: "manual" session succeeded means authorised and held, not paid. Fulfil the order from the charge.succeeded webhook rather than here: a buyer who pays and closes the tab never loads this page. See Handle the Return.

VonPayCheckout.verifyReturnSignature is not how you confirm a payment

The SDK still exports this static method, but a signature check answers "was this message altered?", not "did the buyer pay?" A declined payment carries an equally valid signature, so it must never gate a confirmation page or an order. Use confirmReturn above.

Signature verification belongs on webhooks, where each endpoint has its own whsec_* secret; see vonpay.webhooks.verifySignature.


vonpay.webhookSubscriptions.list(params?)​

List your registered webhook endpoints (maps to GET /v1/webhook_subscriptions). Newest first, ordered by creation time.

const page = await vonpay.webhookSubscriptions.list({ limit: 20 });

// page.object => "list"
// page.hasMore => true
// page.data[0] => { id, url, enabledEvents, status, lastSuccessAt, … }

limit is 1–100 and defaults to 10. To page, pass the last item's id as startingAfter:

const first = await vonpay.webhookSubscriptions.list({ limit: 20 });
const next = first.hasMore
? await vonpay.webhookSubscriptions.list({
limit: 20,
startingAfter: first.data[first.data.length - 1].id,
})
: null;
A cursor you do not own is a 400, not an empty page

If startingAfter does not reference one of your own subscriptions, the request fails with 400 validation_error. Pagination deliberately does not silently restart from the top: restarting would let an id be probed across merchants, and a paging loop that mistakes it for "no more pages" would silently re-read page one forever.

Each entry carries status, typed as "active" | "paused" | "disabled", plus lastDeliveryAt, lastSuccessAt and lastErrorAt. Those three are the ones worth surfacing in your own dashboard: a subscription can read active while every recent delivery failed.


vonpay.webhookSubscriptions.retrieve(webhookSubscriptionId)​

Look up a single endpoint by id (maps to GET /v1/webhook_subscriptions/{id}).

const sub = await vonpay.webhookSubscriptions.retrieve("vp_whsub_live_k7x9m2n4p3");

// sub.url => "https://mystore.com/webhooks/vonpay"
// sub.enabledEvents => ["charge.succeeded", "charge.refunded"]
// sub.status => "active"

Ownership failures are a 404, never a 403. An id belonging to another merchant and an id that has been deleted both return the same 404 webhook_subscription_not_found as an id that never existed: one body for all three, so ids cannot be probed across merchants.

The one 403 this call can return is unrelated to ownership: like the rest of this resource it is secret-key only, so a publishable key is rejected with 403 auth_key_type_forbidden before the lookup happens.


vonpay.webhookSubscriptions.sendTestEvent(id, params)​

Send one signed test event to an endpoint through the real delivery path and get the outcome in the same call (maps to POST /v1/webhook_subscriptions/{id}/send_test_event). Added in 3.6.0. Requires a secret key. Use it to prove your endpoint verifies and processes notifications.

const outcome = await vonpay.webhookSubscriptions.sendTestEvent(sub.id, {
eventType: "charge.succeeded",
sessionId: "vp_cs_test_k7x9m2n4p3", // optional
});

// outcome.delivered => true when the endpoint answered 2xx
// outcome.responseStatus => the endpoint's HTTP status, or null if nothing answered
// outcome.deliveryAttemptId => audit handle for the attempt
// outcome.signaturePreview => first 12 characters of the signature header sent
// outcome.error => why it was not delivered; null when delivered

delivered: false with a responseStatus is a successful test of an endpoint that answered non-2xx. If the endpoint could not be reached or timed out, the call throws 502 webhook_test_delivery_failed with nextAction: "fix_input", and the SDK does not repeat it: check the URL and send again. A 429 or 503 is retried as usual.

The delivered event carries test_event: true, the only marker that it is synthetic. Your handler must check it before touching an order: with sessionId, the payload carries that session's real ids and will match a real order. See Webhook Event Types.


Answering "did my endpoint actually receive that event?"

Read lastDeliveryAt, lastSuccessAt and lastErrorAt from webhookSubscriptions.list() above. Those describe deliveries to your endpoint.

webhookEvents.retrieve(id) is a different thing: it reads our stored record of an event we received from the processor. Its id is the one shown in the dashboard's Events view, not the vp_evt_* id on a payload delivered to you.


vonpay.health()​

Check API health and latency (maps to GET /api/health).

const health = await vonpay.health();
// health.status => "ok" // the only value this call can return, but not
// // for the reason you might expect. "healthy" and
// // "degraded" belong to the DEEP check (?deep=true),
// // which this call does not request — "healthy" is a
// // 200, not a 503 — and health() has no parameter
// // to request the deep check. "error" IS a 503, and the
// // SDK throws on any non-2xx. Wrap in try/catch, not a switch.
// health.version => "" // the endpoint sends no version field; the client
// // defaults it. Do not monitor deploys with this.
// health.latencyMs => 42

vonpay.buyers — stored shopper profiles​

New in 2.0.0. A buyer profile is a stored shopper record you can attach payments to and look up later.

const buyer = await vonpay.buyers.upsert({ externalId: "cust_4471", email: "ada@example.com" });
await vonpay.buyers.retrieve(buyer.id);
await vonpay.buyers.find({ email: "ada@example.com" }); // null when not found
await vonpay.buyers.update(buyer.id, { phone: "+15551234567" });

Five methods: upsert, retrieve, find, update, eraseEmail. find returns null rather than throwing when there is no match.

Erasing an email is its own method, on purpose​

⛔ This is the one part of the buyers API where the SDK deliberately refuses to do what the endpoint allows. PATCH /v1/buyers/{id} treats email: null as a permanent erase. Several common JSON libraries serialise an absent optional field as null by default, so a routine "update this shopper's phone number" built from a half-populated struct can wipe their address, return 200, and look exactly like a success.

So update() cannot express null (it is a compile error) and eraseEmail() is the only path that sends it:

await vonpay.buyers.eraseEmail(buyer.id); // the only way to erase

⛔ eraseEmail is not a complete deletion and must never be described to a shopper as one. It clears the address on the profile. Copies elsewhere keep their own retention rules and this call does not touch them.

⚠ And a success does not confirm the provider's copy is gone. We ask your payment provider to drop theirs, but that request is dispatched after we answer you and reports no failure back. For a buyer you only ever identified by email, the retraction cannot be performed at all: the key it needs was derived from the address just erased.

Buyers is the full model, which copies survive, why that list is examples rather than an inventory, and what to do when you are answering a formal erasure request. Read it before you build on this.

Upgrading to 3.6.0​

Additive, apart from one type correction. Saved-card details can be null. Token.card.brand, .last4, .expMonth and .expYear are now typed string | null / number | null, matching what the API already sends when the payment provider's details for a saved card cannot be read (the card is still saved and usable). At runtime, a field the response omits now reads null instead of undefined, and a response with no card now gives a card whose four fields are null instead of no card at all. A response that carries every field is unchanged. TypeScript may now ask for a null check where you read them:

const label = token.card.last4 ?? "unknown";

If you switch exhaustively on resolvedDescriptor.reason, add "invalid_for_provider": the charge was sent without your configured descriptor because it breaks a provider limit. See Statement descriptors.

Upgrading to 3.0.0​

Two breaking changes. Nothing was removed or renamed.

Pass idempotencyKey to void() to keep automatic retries. A void now follows the same rule as capture and refunds: after a timeout or a 500/502/503/504 it is retried only when you passed a key. Without one, the first error is thrown with retryWithheld: true. A void releases the hold for good, and an unclear failure does not mean it was not applied.

await vonpay.paymentIntents.void(paymentIntentId, { idempotencyKey: `order_${orderId}_void` });

A redirect from the API is now an error. The API never redirects, so a 3xx means something else answered: a proxy, a mistyped baseUrl, or a hijacked connection. The SDK no longer follows it and throws unexpected_redirect with retryable: false and a new nextAction value, check_configuration. Do not retry: check that baseUrl is https://checkout.vonpay.com with no path, then any proxy and the network. If you switch exhaustively on ErrorCode or nextAction, add both values.

3.0.0 also exports formatMinorUnits(amount, currency) (display only: do not parse or store its output), minorUnitDigits(currency) and isLiveKey(key).

Upgrading to 2.0.0​

One breaking change, and it is a type change rather than a behaviour change.

rejectReason dropped five values it could never receive. not_authorized, already_captured, already_voided, already_refunded and amount_exceeds_remaining are gone; the server never sent any of them, so every comparison against one was already dead code. Those comparisons now fail to compile. Delete them.

⭐ The value worth adding a branch for is concurrent_update: another capture or void is in flight for the same payment right now. Before 2.0.0 the SDK replaced any value it did not recognise with nothing at all, so a branch checking for a race never fired and a payment still in flight was read as terminal. That is how a live payment gets cancelled or refunded. Treat it as "unknown outcome, read again", never as an end state.

The full list and what each value means is on Error codes.

Error reporting​

The SDK accepts an optional errorReporter callback in the constructor so integrators can pipe SDK failures into their own observability stack (Sentry, Datadog, custom logger). Your reporter is invoked synchronously, fire-and-forget; if it throws, the SDK swallows the throw with a console.warn and continues.

A separate SDK-telemetry channel can POST anonymised error events back to Von Payments to improve the SDK. It is enabled by default for test keys (vp_sk_test_*) and off for live keys unless you explicitly enable it; it never carries request bodies or PII. The errorReporter callback above is independent of this and runs whether or not telemetry is enabled.

When it fires​

  • API request failures: a non-retryable response (any status not in the retry set: 4xx including 401/403/404/422, plus non-retryable 5xx like 501); retry-exhaustion on a retryable status (429, 500, 502, 503, 504); and network/timeout errors after retry exhaustion
  • webhooks.constructEvent verification failures (signature mismatch, stale timestamp, malformed header)

It does not fire on:

  • verifySignature / verifyReturnSignature: these return boolean, never throw
  • The constructor's invalid-key-prefix throw: that's a dev-time error before the reporter is wired

Callback shape​

import type { ErrorReporter, ErrorReporterContext } from "@vonpay/checkout-node";

const reporter: ErrorReporter = (err, ctx) => {
// err is VonPayError | Error
// ctx is ErrorReporterContext:
// method: string // e.g. "sessions.create", "sessions.get",
// // "sessions.validate", "webhooks.constructEvent",
// // or a "GET /api/health"-style fallback
// sdkVersion: string
// url?: string // origin + path, no query string (no PII via params)
// status?: number // HTTP status if from API response
// requestId?: string // X-Request-Id for correlation
// code?: string // server error code (auth_invalid_key, etc.)
// attempt?: number // 0-indexed retry attempt
};

ErrorReporterContext.method is typed as a plain string: the SDK passes a method label such as "sessions.create" or "webhooks.constructEvent" where one is available, and otherwise falls back to a "<HTTP-METHOD> <path>" string (for example "GET /api/health").

Sentry example​

import * as Sentry from "@sentry/node";
import { VonPayCheckout } from "@vonpay/checkout-node";

const vonpay = new VonPayCheckout({
apiKey: process.env.VON_PAY_SECRET_KEY!,
errorReporter: (err, ctx) => {
Sentry.captureException(err, {
tags: { sdk: "vonpay-node", method: ctx.method, code: ctx.code },
contexts: { vonpay: ctx },
});
},
});

Datadog example​

import { VonPayCheckout } from "@vonpay/checkout-node";

const vonpay = new VonPayCheckout({
apiKey: process.env.VON_PAY_SECRET_KEY!,
errorReporter: (err, ctx) => {
logger.error("vonpay sdk error", { err, ...ctx });
},
});

errorReporter is opt-in and additive: if you don't configure it, errors still propagate via throw and nothing else changes.


Charges without an idempotency key​

From the 28 October 2026 cut-off the API can refuse a charge that carries no Idempotency-Key with 400 idempotency_key_required, before anything is charged (Idempotency). From 3.2.0 the SDK prints one [vonpay] warning per process the first time paymentIntents.create() is called without idempotencyKey. The charge is still sent. An empty string counts as no key. VONPAY_QUIET=1 silences the warning.

Pass the same key on every retry of the same charge:

await vonpay.paymentIntents.create(params, { idempotencyKey: `order_${orderId}_create` });

Auto-Retry​

The SDK automatically retries on 429 (rate-limited) and 5xx (server error) responses with exponential backoff. It reads the Retry-After header when present, capped at 60 seconds. Configure with maxRetries in the constructor (default: 2).

Money-moving calls without an idempotency key are not retried​

Four calls move money: paymentIntents.create, paymentIntents.capture, paymentIntents.void (from 3.0.0) and refunds.create.

When one of them ends ambiguously (a 500, 502, 503, 504, or a network timeout), the SDK cannot tell whether the charge was applied before the connection broke. If you sent no idempotency key, retrying could take the buyer's money a second time, so the SDK stops and raises the first error instead.

Send an idempotency key and the retries come back. The same key rides every attempt, so the repeat is collapsed server-side and is safe. One exception, from 3.3.0: when the 5xx reply to a money call (paymentIntents.create, .capture, .void, refunds.create) cannot be read, because it was cut off or replaced by a proxy's HTML error page, the SDK stops after the first attempt even with a key. The error carries retryWithheld: true; read the payment intent back before repeating anything.

await vonpay.paymentIntents.create(
{ amount: 1499, currency: "USD", paymentMethod: { id: "vp_pmt_live_..." }, returnUrl: `https://mystore.com/checkout/return?order=${orderId}` },
{ idempotencyKey: `order_${orderId}_create` }, // ← retries stay enabled
);

Two boundaries, so this doesn't read as broader than it is:

  • 429 is still retried without a key. A rate-limited request is rejected before it reaches the handler, so nothing was charged and repeating it is safe.
  • Non-money writes are unchanged. sessions.create, tokens.create and the webhook-subscription writes retry exactly as before.

On the HTTP path the raised VonPayError carries retryWithheld: true so you can tell this case apart from an ordinary failure:

catch (err) {
if (err instanceof VonPayError && err.retryWithheld) {
// Ambiguous outcome, no key sent — the charge MAY have gone through.
// Do not blindly re-issue; look up the payment intent before retrying.
}
}
A timeout is withheld too, but arrives without the flag

The same protection applies when the request times out or the network drops: that outcome is always ambiguous. But a network failure raises the underlying error rather than a VonPayError, so retryWithheld is not present on it.

Do not treat "no retryWithheld" as "safe to retry". After any failed money-moving call sent without an idempotency key, confirm the actual state before re-issuing.

The cleanest fix for all of this is to send an idempotency key on every money-moving call; then none of the above applies. See Idempotency.

payment_outcome_unknown is never retried​

In 2.11.0 and later, the SDK never repeats a call that answers 502 payment_outcome_unknown, with or without an idempotency key, on a capture or a void alike: the provider accepted the operation and only the record of it failed, so a repeat could capture twice. The error carries nextAction: "reconcile" and captureOutcome or voidOutcome set to "unknown". Read the payment intent back with paymentIntents.retrieve() before doing anything else.


Error Handling​

All methods throw VonPayError on non-2xx responses. Errors include structured fields for programmatic handling.

import { VonPayCheckout, VonPayError } from "@vonpay/checkout-node";

try {
await vonpay.sessions.create({ ... });
} catch (err) {
if (err instanceof VonPayError) {
console.error(err.message); // "Invalid API key"
console.error(err.status); // 401
console.error(err.code); // "auth_invalid_key"
console.error(err.fix); // "Check that your API key is correctly formatted and active"
console.error(err.docs); // "https://docs.vonpay.com/reference/security#key-types"
console.error(err.requestId); // "req_abc123"
console.error(err.rateLimit); // { limit: 100, remaining: 0, reset: 1710000000, retryAfter: 30 }
}
}

rateLimit is { limit, remaining, reset, retryAfter? }; retryAfter (seconds) is populated from the Retry-After header when the server sends one.

ErrorCode union type​

The code field is a string-literal union, enabling exhaustive switch statements:

import type { ErrorCode } from "@vonpay/checkout-node";

function handleError(code: ErrorCode) {
switch (code) {
case "auth_missing_bearer":
case "auth_invalid_key":
case "auth_key_expired":
case "auth_key_type_forbidden":
case "auth_merchant_inactive":
case "auth_service_unavailable":
// authentication errors
break;
case "validation_error":
case "validation_missing_field":
case "validation_invalid_amount":
// validation errors
break;
case "rate_limit_exceeded":
case "rate_limit_exceeded_per_key":
// back off
break;
case "session_not_found":
case "session_expired":
case "session_wrong_state":
case "session_integrity_error":
// session errors
break;
// ... exhaustive handling
}
}

auth_invalid_key maps to HTTP 401, auth_key_type_forbidden to 403, and both rate_limit_exceeded / rate_limit_exceeded_per_key to 429.


Webhook Event Types​

WebhookEvent is a discriminated union on the type field. The envelope carries id, type, created, livemode, merchant_id, and a typed data payload that varies by event type, plus test_event?: true on a Send test event delivery only: if (event.test_event === true) return 200 and do nothing else. See Webhook Event Reference for the full per-event data shapes.

EventTyped data fields
charge.succeededsession_id, payment_intent_id, transaction_id, vp_tx_id, amount, currency, card
charge.failedsession_id, payment_intent_id, transaction_id, vp_tx_id, amount, currency, failure_reason, failure_code, rule_code, network_decline_code, card, retry
charge.refundedsession_id, payment_intent_id, transaction_id, vp_tx_id, refund_id, amount, refund_amount, amount_refunded_total, currency, reason, is_partial, original_charge_amount, card
refund.failedsession_id, payment_intent_id, transaction_id, vp_tx_id, refund_id, amount, refund_amount, amount_refunded_total, currency, reason, reason_code, is_partial, original_charge_amount, retry_available, card
payment_intent.succeededsession_id, payment_intent_id, transaction_id, amount, currency
payment_intent.failedsession_id, payment_intent_id, transaction_id, amount, currency, failure_reason, failure_code, rule_code, network_decline_code, retry
payment_intent.cancelledsession_id, payment_intent_id, transaction_id, amount, currency, cancellation_reason

Reconcile and refund on vp_tx_id, the Von transaction id, never transaction_id, which is not one id space. The charge.* types name it as of 2.7.1, and refund.failed as of 2.7.2. The wire carries more, unchanged on event.data: avs_result_code / cvv_result_code on charge.succeeded, and decline_code / action / decline_message on the failure family. The event reference documents each. Every top-level data key is nullable: the SDK types each as T | null, so always null-check before use. card is { brand, last4 } (its inner fields are non-null once card itself is present) and is populated only when card enrichment is active for the merchant; null otherwise. failure_code is the normalized decline code (e.g. card_declined); network_decline_code is the raw ISO-8583 code (e.g. 05).

The table lists the most-used events, not every member of the union. From 3.6.0, charge.failed, payment_intent.failed and session.failed type retry, the same { allowed, attempts_remaining } object as on sessions.get(). A failure event is sent for every declined attempt, so one is not final on its own: allowed: true means the session was re-opened for another card (keep the order open); allowed: false with attempts_remaining: 0 is final for this checkout; null means retry does not apply. An event without the field is unknown, never final. See A failed charge is not always the end of the checkout.

import type { WebhookEvent } from "@vonpay/checkout-node";

function handle(event: WebhookEvent) {
switch (event.type) {
case "charge.succeeded":
// event.data.transaction_id is typed here
break;
case "charge.failed":
// event.data.failure_reason is typed here
break;
case "charge.refunded":
// event.data.amount_refunded_total is typed here
break;
}
}

TypeScript​

All types are exported:

import type {
VonPayCheckoutConfig,
CreateSessionParams,
CheckoutSession,
SessionStatus,
LineItem,
HealthStatus,
VonPayError,
ErrorCode,
WebhookEvent,
} from "@vonpay/checkout-node";

Sample apps​

Clone-and-run reference integrations using this SDK live in Von-Payments/vonpay-samples:

Each ships with .env.example, a per-sample README, and pinned to a known-working SDK version.