@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
buyerIdor abuyerEmail(withbuyerNameto enrich the record), is required to vault a reusablevp_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.createfor 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.succeededwebhook 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 (anelementssession 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.nullwhen it does not apply.last_payment_error:{ code, decline_code }for the most recent declined attempt.codeiscard_declinedorpayment_failed(open enum);decline_codeuses the payment intent'sfailure_codevalues, ornull.amount_refunded,remaining_refundableandrefundable: the refund totals,nulltogether when they could not be read or the session has no single payment.nullis 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:
avsResultCodeandcvvResultCode: the address and security-code results for the payment.nullwhen 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,remainingRefundableandrefundable: the refund totals. All three arenulltogether when the totals could not be read at that moment, andnullis not zero: read again or callrefunds.list().refundableisfalseuntil the charge settles.nextActionwhile the payment isrequires_action, withissuedAton the same object (besideredirectToUrl, 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; checkissuedAtfirst, 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 paymentThe 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;
400, not an empty pageIf 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.
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 like501); retry-exhaustion on a retryable status (429,500,502,503,504); and network/timeout errors after retry exhaustion webhooks.constructEventverification 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:
429is 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.createand 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.
}
}
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.
| Event | Typed data fields |
|---|---|
charge.succeeded | session_id, payment_intent_id, transaction_id, vp_tx_id, amount, currency, card |
charge.failed | session_id, payment_intent_id, transaction_id, vp_tx_id, amount, currency, failure_reason, failure_code, rule_code, network_decline_code, card, retry |
charge.refunded | session_id, payment_intent_id, transaction_id, vp_tx_id, refund_id, amount, refund_amount, amount_refunded_total, currency, reason, is_partial, original_charge_amount, card |
refund.failed | session_id, payment_intent_id, transaction_id, vp_tx_id, refund_id, amount, refund_amount, amount_refunded_total, currency, reason, reason_code, is_partial, original_charge_amount, retry_available, card |
payment_intent.succeeded | session_id, payment_intent_id, transaction_id, amount, currency |
payment_intent.failed | session_id, payment_intent_id, transaction_id, amount, currency, failure_reason, failure_code, rule_code, network_decline_code, retry |
payment_intent.cancelled | session_id, payment_intent_id, transaction_id, amount, currency, cancellation_reason |
Reconcile and refund on vp_tx_id, the Von transaction id, never transaction_id, which is not one id space. The charge.* types name it as of 2.7.1, and refund.failed as of 2.7.2. The wire carries more, unchanged on event.data: avs_result_code / cvv_result_code on charge.succeeded, and decline_code / action / decline_message on the failure family. The event reference 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:
checkout-nextjs: Next.js 15 / React 19 hosted checkout with signed return verification + webhook handlercheckout-express: Node + Express 5 server-only hosted checkoutcheckout-paybylink-nextjs: Pay-by-link operator + customer flowplatform-integrator-nextjs: Multi-tenant platform pattern: per-tenant credentials, multi-tenant webhook routing, idempotency keys (CRM, subscription-billing, and ISV connector shape; see also Integrate VORA as a Payment Gateway)
Each ships with .env.example, a per-sample README, and pinned to a known-working SDK version.