Payment Intents
This is the server-driven path. The alternatives are Checkout (hosted redirect) and Embedded Fields (in-page iframes). Use Payment Intents when your server needs to drive auth, capture, void, and refund as discrete steps — typically for delayed capture, fraud-check-before-capture, subscriptions, or platform-integrator flows.
New to Payment Intents? Start with the quickstart — a 5-step walkthrough from tokenize to capture/refund. This page is the deep reference.
When to use Payment Intents vs Sessions
Sessions are the right choice when a hosted checkout page is acceptable: Von Payments handles the card form, 3DS, and redirect-back, and your integration stays out of PCI scope. Payment Intents are for the cases Sessions can't cover — delayed capture (auth on order, capture on ship), fraud-check-before-capture, platform integrators that need to drive the state machine themselves, or any flow where the server is the source of truth and there is no buyer-facing redirect. Payment Intents are higher-effort and (for live keys) require either an iframe-vault provider reference or PCI-compliant card handling on your end. If hosted-redirect is fine, use Sessions instead.
Pairing with VORA Mirror. When your front-end uses VORA Mirror, the
vp_pmt_*token returned bytokenize()/submit()is thepayment_methodyou pass toPOST /v1/payment_intentshere. The two flows compose: VORA Mirror handles the iframe PCI side; Payment Intents handles the server-side lifecycle (auth, capture, void, refund, MIT). See the VORA Mirror quickstart for the full server + browser handshake.
Lifecycle
A payment intent is a discrete state machine. Each transition is one-way and terminal — once an intent is succeeded, voided, or failed, it does not move again.
capture_method=automatic
┌────────────────────────────────────────────────────────┐
│ ▼
create ──► requires_action ──► authorized ─── capture ──► succeeded
│ │ │
│ │ │
│ └─── void ──► voided │
│ │
│ /v1/refunds
▼ │
failed ▼
(refunded)
requires_action— the intent needs an integrator-side step (typically 3DS) before it can advance. Thenext_actionfield on the response tells you what.authorized— funds reserved on the buyer's card, not yet captured. Reachable only whencapture_method: "manual".succeeded— funds captured. Terminal for the auth/capture leg; refunds may still occur via/v1/refunds.voided— authorization released without capture. Terminal.failed— auth or capture rejected. Terminal.decline_codeon the response carries a generic reason.
capture_method: "automatic" (the default) collapses auth + capture into a single call and the intent goes straight to succeeded. capture_method: "manual" stops at authorized and waits for an explicit POST /v1/payment_intents/{id}/capture.
See API Reference — Payment intent statuses for the canonical status list.
Wire format
The Payment Intents wire format is snake_case. The Node SDK accepts camelCase parameter names and converts to snake_case on the wire; the Python SDK uses snake_case parameter names that match the wire format directly (no transformation). When you call the API directly with curl, use snake_case.
All amounts are integers in minor units — 1499 is $14.99 USD, 1000 is 10.00 EUR, 100000 is 100,000 JPY (JPY has no minor unit). Currencies are ISO 4217 codes; responses normalize them to uppercase.
Create a payment intent
A payment intent represents the lifecycle of one charge against a card. Two operating modes:
- Sale (also called auth+capture, purchase) — set
capture_method: "automatic"(default). Authorization and capture happen in one API call; funds settle immediately. Intent goes straight tosucceededon success. - Auth-only (also called authorize) — set
capture_method: "manual". Authorization holds funds on the card; you settle later viaPOST /v1/payment_intents/{id}/capture. Intent stops atauthorizedand waits.
If you're coming from another gateway, here's how the lifecycle operations map to VORA:
| Industry term | VORA equivalent |
|---|---|
| Sale / Purchase / Auth+Capture | capture_method: "automatic" on POST /v1/payment_intents |
| Authorize / Auth / Auth-only | capture_method: "manual" on POST /v1/payment_intents |
| Capture / Settle | POST /v1/payment_intents/{id}/capture |
| Void / Cancel | POST /v1/payment_intents/{id}/void |
| Refund / Credit | POST /v1/refunds |
The concepts are the same; the API surface is unified under the payment-intent lifecycle. There is no separate "charge" object — the payment intent IS the charge, with its status field representing what other gateways call charge state.
POST /v1/payment_intents.
Before you charge a card you need a
vp_pmt_*token fromPOST /v1/tokens. That token represents the vaulted card. The examples below pass it viapayment_method.id. If you don't yet have a token, see Capturing the card below for the upstream tokenization flow.
Sale — capture_method: "automatic" (auth + capture in one call)
Node
import { VonPayCheckout } from "@vonpay/checkout-node";
const apiKey = process.env.VON_PAY_SECRET_KEY;
if (!apiKey) throw new Error("VON_PAY_SECRET_KEY is required");
const vonpay = new VonPayCheckout(apiKey);
const intent = await vonpay.paymentIntents.create(
{
amount: 1499,
currency: "USD",
captureMethod: "automatic",
paymentMethod: { id: "vp_pmt_test_QAqnXEJF_TCum1jg" }, // vp_pmt_* from POST /v1/tokens
metadata: { orderId: "ord_42" },
},
{ idempotencyKey: "ord_42_create_attempt_1" },
);
if (intent.status === "succeeded") {
// funds captured
} else if (intent.status === "requires_action") {
// present intent.nextAction to the buyer (typically 3DS)
} else if (intent.status === "failed") {
// intent.declineCode carries a generic reason
}
Python
import os
from vonpay.checkout import VonPayCheckout
vonpay = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"])
intent = vonpay.payment_intents.create(
amount=1499,
currency="USD",
capture_method="automatic",
payment_method={"id": "vp_pmt_test_QAqnXEJF_TCum1jg"}, # vp_pmt_* from POST /v1/tokens
metadata={"order_id": "ord_42"},
idempotency_key="ord_42_create_attempt_1",
)
if intent.status == "succeeded":
pass # funds captured
elif intent.status == "requires_action":
pass # present intent.next_action to the buyer
elif intent.status == "failed":
pass # intent.decline_code carries a generic reason
Raw HTTP
curl -X POST https://checkout.vonpay.com/v1/payment_intents \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_42_create_attempt_1" \
-d '{
"amount": 1499,
"currency": "USD",
"capture_method": "automatic",
"payment_method": { "id": "vp_pmt_test_QAqnXEJF_TCum1jg" },
"metadata": { "order_id": "ord_42" }
}'
Response:
{
"id": "vpi_test_abc123",
"status": "succeeded",
"amount": 1499,
"currency": "USD",
"capture_method": "automatic",
"next_action": null,
"decline_code": null,
"created_at": "2026-05-04T20:30:07.713Z",
"metadata": { "order_id": "ord_42" }
}
Auth-only — capture_method: "manual" (authorize now, capture later)
Use this when you need to run a fraud check, wait for inventory confirmation, or defer settlement until shipment.
Node
const intent = await vonpay.paymentIntents.create(
{
amount: 1499,
currency: "USD",
captureMethod: "manual",
paymentMethod: { id: "vp_pmt_test_QAqnXEJF_TCum1jg" }, // vp_pmt_* from POST /v1/tokens
metadata: { orderId: "ord_42" },
},
{ idempotencyKey: "ord_42_authorize_attempt_1" },
);
// intent.status === "authorized" on success
// capture later with vonpay.paymentIntents.capture(intent.id)
Python
intent = vonpay.payment_intents.create(
amount=1499,
currency="USD",
capture_method="manual",
payment_method={"id": "vp_pmt_test_QAqnXEJF_TCum1jg"}, # vp_pmt_* from POST /v1/tokens
metadata={"order_id": "ord_42"},
idempotency_key="ord_42_authorize_attempt_1",
)
# intent.status == "authorized" on success
Raw HTTP
curl -X POST https://checkout.vonpay.com/v1/payment_intents \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_42_authorize_attempt_1" \
-d '{
"amount": 1499,
"currency": "USD",
"capture_method": "manual",
"payment_method": { "id": "vp_pmt_test_QAqnXEJF_TCum1jg" },
"metadata": { "order_id": "ord_42" }
}'
next_action and decline_code
next_actionis non-null whenstatus === "requires_action". Always a structured object — see Authentication challenges (3DS) for the full handling.decline_codeis non-null whenstatus === "failed". The codes are generic (e.g.card_declined,insufficient_funds) — provider-specific codes are intentionally not exposed. For the test card numbers that produce each decline code, see Test Cards.
decline_code is distinct from the API-level error codes returned in the code field on a 4xx response (those are documented in Error Codes). A failed intent is a successful API call that reports a payment-level decline; an API error is a request that never reached the processor.
Capturing the card (where vp_pmt_* tokens come from)
The Payment Intents request body has no card_number / exp / cvv fields — and that's deliberate. VORA is PCI-out: raw card data never touches our infrastructure. Every Payment Intent that charges a card uses a payment_method token (vp_pmt_(test|live)_*) that references a card vaulted at an iframe-vault provider. Both you and VORA stay out of PCI scope.
The flow
┌─────────────────────────────────────────────────────────────┐
│ Buyer's browser │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Iframe-vault provider's SDK │ │
│ │ [Card #][Exp][CVV][Billing address] (PCI-isolated) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ tokenize │
│ ▼ │
│ provider_reference handle │
└───────────────────────┼─────────────────────────────────────┘
│
▼ (sent to your server)
POST /v1/tokens
{ provider_reference: "..." }
│
▼
vp_pmt_test_QAqnXEJF...
│
▼
POST /v1/payment_intents
{ amount, currency,
payment_method: { id: "vp_pmt_..." } }
Three steps: (1) browser-side tokenize the card, (2) server-side mint a vp_pmt_* from the iframe handle, (3) charge it via Payment Intents. Card data flows browser → vault → token; never to your server, never to ours.
What you integrate browser-side
Your iframe-vault provider's SDK is what renders the card form on your checkout page. The path:
- Load your iframe-vault provider's JS SDK on your checkout page.
- Render their card-form iframe. The buyer enters card + billing address inside the iframe.
- The iframe SDK returns a
provider_reference(a vault-side token, format depends on the provider) once tokenization succeeds. - POST that handle to your server.
The card data never leaves the iframe boundary. Your page hosts the iframe; you don't see the PAN or CVV.
Mint a vp_pmt_* token
Your server posts the iframe-minted handle to /v1/tokens:
curl -X POST https://checkout.vonpay.com/v1/tokens \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"provider_reference": "<the iframe-minted handle>",
"buyer_id": "buyer_abc"
}'
{
"id": "vp_pmt_test_QAqnXEJF_TCum1jg",
"status": "active",
"card": { "brand": "visa", "last4": "4242", "exp_month": 12, "exp_year": 2030 }
}
The response carries display-safe metadata (brand, last4, exp_month, exp_year) you can show in your UI for "card on file" displays. The PAN itself never appears.
Sandbox keys auto-mint a mock card token if you call /v1/tokens with no provider_reference — useful for SDK examples and tests that don't need a real card.
Now charge it
Pass the vp_pmt_* ID into payment_method.id on paymentIntents.create:
const intent = await vonpay.paymentIntents.create(
{
amount: 1499,
currency: "USD",
captureMethod: "automatic", // sale
paymentMethod: { id: "vp_pmt_test_QAqnXEJF_TCum1jg" },
metadata: { orderId: "ord_42" },
},
{ idempotencyKey: "ord_42_create_attempt_1" },
);
That's the full server-side flow.
Where billing and shipping addresses live
The card form renders inside the iframe-vault provider's iframe — and the iframe itself collects billing address from the buyer. That billing address is sent to the processor for AVS verification automatically; you don't need to pass it to VORA separately. The Payment Intents request body has no billing_details or shipping_details fields today because address capture happens at the iframe step.
If your checkout collects address fields outside the iframe (in your own form) and you want to pass them through for AVS or fraud signals, the path today is via metadata on the token or intent (won't reach the processor as structured address data) OR by configuring the iframe to receive your address as input before tokenization (provider-specific).
Per-merchant requirements
The payment_method field's required-ness depends on which underlying processor your merchant is configured for — read /v1/capabilities once at startup and branch on the response. Some configurations require a vp_pmt_* token for direct charges; others accept intents created without one (used by the hosted-page flow). The capabilities matrix is the canonical source — don't hard-code per-processor assumptions.
Live activation gate. Direct server-side charges with
payment_methodmay be feature-flagged on production for some processors; sandbox works regardless. If you need direct-charge support on live keys, contact your VORA point of contact to enable it for your merchant.
Saved cards / merchant-initiated (MIT) charges
Once a card is on file (token vaulted, intent succeeded once with cardholder consent), subsequent charges against it — subscription renewals, retries, scheduled installments — are merchant-initiated transactions (MIT). MIT requires an extra mit block on paymentIntents.create so the chain is properly tagged for scheme-level transaction-ID compliance.
Capability gate
Read /v1/capabilities first — supported_operations.mit must be true. Sandbox returns false; live processors with MIT support enabled return true. Branch on the response — don't hard-code per-processor assumptions.
Charge a saved card (recurring renewal)
Pass mit plus the payment_method to charge the card on file. The original_transaction_id is the first payment intent in the chain — the cardholder-initiated anchor where consent was captured.
const renewal = await vonpay.paymentIntents.create(
{
amount: 2999,
currency: "USD",
captureMethod: "automatic",
paymentMethod: { id: "vp_pmt_test_QAqnXEJF_TCum1jg" },
mit: {
initiator: "merchant",
reason: "recurring",
originalTransactionId: "vpi_test_first_consent_intent_id",
},
metadata: { subscriptionId: "sub_8821", cycleId: "cyc_2026_05" },
},
{ idempotencyKey: "sub_8821_cyc_2026_05" },
);
renewal = vonpay.payment_intents.create(
amount=2999,
currency="USD",
capture_method="automatic",
payment_method={"id": "vp_pmt_test_QAqnXEJF_TCum1jg"},
mit={
"initiator": "merchant",
"reason": "recurring",
"original_transaction_id": "vpi_test_first_consent_intent_id",
},
metadata={"subscription_id": "sub_8821", "cycle_id": "cyc_2026_05"},
idempotency_key="sub_8821_cyc_2026_05",
)
mit field reference
| Field | Values | Notes |
|---|---|---|
initiator | merchant | customer | merchant for pure server-driven (renewal, retry). customer for buyer-initiated charges with a card on file. |
reason | recurring | unscheduled | installment | Scheme-level reason code. recurring for fixed-cadence subscriptions, unscheduled for retries / fraud-recovery / variable-cadence, installment for fixed-count installments. |
original_transaction_id | vpi_(test|live)_* | The first intent in the chain — where cardholder consent was captured. The chain anchors on this ID for scheme-level compliance. |
Chain validity
The server runs checkMITChainValidity before dispatching:
- The
original_transaction_idmust belong to the same merchant. - It must be on the same processor (or the merchant must have network-token support for cross-processor chains).
- It must be a chargeable anchor (a real cardholder-initiated intent, not another MIT in the chain).
Violations return 404 (cross-merchant) or 409 invalid_transition with a reject_reason so you can branch.
Authentication challenges (3DS)
When the buyer's bank requires Strong Customer Authentication, the intent returns status: "requires_action" and next_action is non-null. The shape is always:
{
"type": "redirect_to_url",
"redirect_to_url": {
"url": "https://challenge.example/3ds/abc123"
}
}
Handle the challenge
Today, the only type value is redirect_to_url. Branch on type so future action types don't break your handler.
if (intent.status === "requires_action" && intent.nextAction) {
if (intent.nextAction.type === "redirect_to_url") {
// Top-level navigation or new tab — NOT inside an iframe (banks block this).
res.redirect(intent.nextAction.redirect_to_url.url);
} else {
// Future action types — fail safe rather than guessing.
throw new Error(`Unsupported next_action type: ${intent.nextAction.type}`);
}
}
if intent.status == "requires_action" and intent.next_action:
if intent.next_action.type == "redirect_to_url":
return redirect(intent.next_action.redirect_to_url["url"])
else:
raise ValueError(f"Unsupported next_action type: {intent.next_action.type}")
After the challenge
The challenge URL handles the bank flow on the buyer's side. When the buyer completes (or fails) the challenge, you have two ways to learn the outcome:
- Webhook (recommended) — listen for
payment_intent.succeededorpayment_intent.failedon your subscription endpoint. The webhook fires within seconds of the bank's terminal callback. - Server-side retrieve —
GET /v1/payment_intents/{id}after a return-URL callback or polling.
Don't trust the buyer's browser to tell you the result. Your successUrl / cancelUrl is a UX hint, not a source of truth — always verify server-side.
SDK 0.6.0 corrects
next_action.@vonpay/checkout-node@0.6.0andvonpay-checkout==0.6.0ship the structured{ type, redirect_to_url: { url } }type shown above. Merchants pinned to 0.5.x still seenext_actiontyped asstring | null— upgrade to 0.6.x to pick up the corrected types, or treat the field asunknownand parse the documented shape on the older pin.
Capture an authorized intent
POST /v1/payment_intents/{id}/capture. Empty body captures the full authorized amount. Pass amount_to_capture (minor units) for a partial. The server enforces remaining = authorized − previous_captures; over-capture returns 422 with code: invalid_transition.
Full capture
Node (SDK 0.6.x)
const captured = await vonpay.paymentIntents.capture(
"vpi_test_abc123",
undefined,
{ idempotencyKey: "ord_42_capture_attempt_1" },
);
// captured.status === "succeeded"
Raw HTTP (works today on 0.5.0)
curl -X POST https://checkout.vonpay.com/v1/payment_intents/vpi_test_abc123/capture \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_42_capture_attempt_1" \
-d '{}'
Partial capture
Node (SDK 0.6.x)
const captured = await vonpay.paymentIntents.capture(
"vpi_test_abc123",
{ amountToCapture: 1000 }, // capture $10.00 of a $14.99 authorization
{ idempotencyKey: "ord_42_partial_capture_attempt_1" },
);
Raw HTTP (works today on 0.5.0)
curl -X POST https://checkout.vonpay.com/v1/payment_intents/vpi_test_abc123/capture \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_42_partial_capture_attempt_1" \
-d '{ "amount_to_capture": 1000 }'
The response is the same PaymentIntent shape as create, with status: "succeeded" and amount equal to the captured amount.
Some processors expose a separate
capturedstatus beforesucceeded. Treat both as terminal-success states; refunds are valid against either.
Refund a succeeded intent
POST /v1/refunds. Reference the intent by payment_intent. Omit amount to refund the full remaining balance — the server computes authorized − previously_refunded. Pass amount for a partial. Refund IDs are prefixed vpr_test_ or vpr_live_.
Full refund
Node (SDK 0.6.x)
const refund = await vonpay.refunds.create(
{
paymentIntent: "vpi_test_abc123",
reason: "customer_requested",
},
{ idempotencyKey: "ord_42_refund_attempt_1" },
);
// refund.id starts with "vpr_test_" or "vpr_live_"
// refund.status === "succeeded" (or "pending" for async processors)
Raw HTTP (works today on 0.5.0)
curl -X POST https://checkout.vonpay.com/v1/refunds \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_42_refund_attempt_1" \
-d '{
"payment_intent": "vpi_test_abc123",
"reason": "customer_requested"
}'
Partial refund
Node (SDK 0.6.x)
const refund = await vonpay.refunds.create(
{
paymentIntent: "vpi_test_abc123",
amount: 500,
reason: "customer_requested",
},
{ idempotencyKey: "ord_42_partial_refund_attempt_1" },
);
Raw HTTP
curl -X POST https://checkout.vonpay.com/v1/refunds \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_42_partial_refund_attempt_1" \
-d '{
"payment_intent": "vpi_test_abc123",
"amount": 500,
"reason": "customer_requested"
}'
Response:
{
"id": "vpr_test_JL3xPcFktvsF10Ib",
"payment_intent": "vpi_test_abc123",
"amount": 500,
"currency": "USD",
"status": "succeeded",
"reason": "customer_requested"
}
If amount exceeds the remaining refundable balance the server returns 422 with code: refund_amount_exceeds_remaining — see Lifecycle error envelope below.
Void an authorized (uncaptured) intent
POST /v1/payment_intents/{id}/void. Empty body. Voids release the authorization without moving funds. Once an intent is succeeded, void may not be available — see void_after_capture below.
The SDK method is
void(notcancel) to match the server endpoint name.voidis a valid TypeScript property name; only the operator keyword is reserved.
Node (SDK 0.6.x)
const voided = await vonpay.paymentIntents.void(
"vpi_test_abc123",
{ idempotencyKey: "ord_42_void_attempt_1" },
);
// voided.status === "voided"
Raw HTTP (works today on 0.5.0)
curl -X POST https://checkout.vonpay.com/v1/payment_intents/vpi_test_abc123/void \
-H "Authorization: Bearer vp_sk_test_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_42_void_attempt_1" \
-d '{}'
void_after_capture: rerouted_to_refund
Not every processor supports voiding a captured intent. The merchant capability matrix exposes this as supported_operations.void_after_capture, which takes one of three values:
| Value | Meaning |
|---|---|
supported | Void works against succeeded intents directly. |
unsupported | Void on a succeeded intent returns 422 invalid_transition. Use /v1/refunds instead. |
rerouted_to_refund | Same observable error as unsupported — the canonical fix is /v1/refunds. |
Both unsupported and rerouted_to_refund produce the same response on a void-after-capture attempt: a 422 with code: invalid_transition, current_status: "succeeded", and reject_reason: "already_captured". Read /v1/capabilities once at integration startup and branch up front rather than catching the error mid-flow.
const caps = await vonpay.capabilities.get();
async function reverse(intentId: string, intent: PaymentIntent) {
if (intent.status === "authorized") {
return vonpay.paymentIntents.void(intentId);
}
if (intent.status === "succeeded") {
if (caps.supportedOperations.voidAfterCapture === "supported") {
return vonpay.paymentIntents.void(intentId);
}
return vonpay.refunds.create({ paymentIntent: intentId });
}
throw new Error(`Cannot reverse intent in status ${intent.status}`);
}
Idempotency
Send Idempotency-Key on every POST. The server stores the key for 24 hours; a retry with the same key returns the original response (and same status code) without creating a duplicate operation. Choose keys that uniquely identify your server-side operation — <order_id>_<operation>_attempt_<n> is the convention used in this guide. Generate keys server-side; never derive them from buyer-supplied input (cookies, query strings, request bodies).
await vonpay.paymentIntents.create(
{ amount: 1499, currency: "USD", captureMethod: "automatic" },
{ idempotencyKey: "ord_42_create_attempt_1" },
);
vonpay.payment_intents.create(
amount=1499,
currency="USD",
capture_method="automatic",
idempotency_key="ord_42_create_attempt_1",
)
-H "Idempotency-Key: ord_42_create_attempt_1"
If you reuse a key with a different request body, the server returns 409 with code: idempotency_replay_incompatible rather than silently overwriting. Bump the attempt_n suffix when you genuinely intend a new operation.
Lifecycle error envelope
Capture, void, and refund return the standard ErrorResponse shape augmented with three lifecycle fields. This lets you branch on the rejection cause without a follow-up retrieve.
{
"error": "Payment intent already captured.",
"code": "invalid_transition",
"fix": "Use /v1/refunds to reverse a captured intent.",
"docs": "https://docs.vonpay.com/reference/error-codes#invalid_transition",
"payment_intent": "vpi_test_abc123",
"current_status": "succeeded",
"reject_reason": "already_captured"
}
| Field | Notes |
|---|---|
code | invalid_transition for state-machine rejections, refund_amount_exceeds_remaining for over-refunds. |
payment_intent | The intent the operation targeted. |
current_status | The intent's status at the moment of rejection — one of requires_action, authorized, succeeded, voided, failed. (Some processors expose a transient captured status before succeeded — treat it as terminal-success.) |
reject_reason | Server-canonical cause: terminal_state, not_authorized, already_captured, already_voided, already_refunded, amount_exceeds_remaining. |
Handle these in the SDK via the typed error:
import { VonPayError } from "@vonpay/checkout-node";
try {
await vonpay.paymentIntents.void("vpi_test_abc123");
} catch (err) {
if (err instanceof VonPayError && err.code === "invalid_transition") {
if (err.rejectReason === "already_captured") {
// pivot to refund
await vonpay.refunds.create({ paymentIntent: err.paymentIntent });
}
} else {
throw err;
}
}
For the full code catalog, see Error Codes.
/v1/capabilities
GET /v1/capabilities returns the effective capability matrix for the authenticated merchant. Read it once at integrator startup and cache the result — capabilities change rarely (only when a merchant's processor configuration changes) and the matrix gates which optional operations you can attempt.
Node
const caps = await vonpay.capabilities.get();
console.log(caps.supportedOperations.partialCapture); // boolean
console.log(caps.supportedOperations.voidAfterCapture); // "supported" | "unsupported" | "rerouted_to_refund"
console.log(caps.settlementCurrencies); // ["USD", "EUR", ...]
Python
caps = vonpay.capabilities.get()
print(caps.supported_operations["partial_capture"])
print(caps.supported_operations["void_after_capture"])
print(caps.settlement_currencies)
Raw HTTP
curl https://checkout.vonpay.com/v1/capabilities \
-H "Authorization: Bearer vp_sk_test_xxx"
Response:
{
"supported_operations": {
"auth_capture_separation": true,
"partial_capture": true,
"partial_refund": true,
"unreferenced_refund": false,
"void_after_capture": "rerouted_to_refund",
"mit": true,
"network_tokens": true,
"three_d_secure_2": false,
"ach": false,
"payouts_api": false
},
"settlement_currencies": ["USD", "EUR", "GBP", "CAD", "AUD"],
"rate_limits": {
"payment_intents_per_minute": 100
}
}
Branch on these fields before invoking optional operations:
| Field | Branch on it before… |
|---|---|
auth_capture_separation | …creating an intent with capture_method: "manual". If false, manual-capture is unavailable on this merchant. |
partial_capture | …passing amount_to_capture less than the authorized amount. |
partial_refund | …passing amount on /v1/refunds. |
void_after_capture | …calling /void on a succeeded intent (see section above). |
mit | …running a merchant-initiated transaction (recurring, unscheduled top-up). |
network_tokens | …relying on network-token-backed reuse for stored payment methods. |
three_d_secure_2 | …expecting a next_action of three_d_secure_2 on requires_action. |
The matrix deliberately does not identify the underlying processor — by design, integrators code against capabilities, not provider names.
Webhooks
Payment intents emit their own event family on the subscription-level webhook surface. The events confirm terminal state asynchronously — useful when an intent goes via requires_action (3DS), or when a refund is processed asynchronously by the provider.
Verify the signature first. Before processing any
payment_intent.*event, verify thet=…,v1=…signature using yourwhsec_*secret. Do not trust the payload until verification passes. See Webhook Signature Verification.
| Event | Fires when |
|---|---|
payment_intent.succeeded | Intent reached succeeded (auto-capture, manual capture, or post-3DS settle). |
payment_intent.failed | Intent reached failed. Carries decline_code. |
payment_intent.cancelled | Intent was voided. Note: the object's status field reports voided; the webhook event name is payment_intent.cancelled (these refer to the same lifecycle terminal state). |
payment_intent.refunded | A refund against the intent reached terminal state. |
These are subscription-level webhooks signed with a whsec_* secret using the t=…,v1=… header format — not the merchant-API-key-signed session-webhook format. See Webhook Signature Verification for the verifier; full payloads are in the Webhook Events catalog.
SDK availability
| Operation | @vonpay/checkout-node | vonpay-checkout (Python) |
|---|---|---|
paymentIntents.create (incl. payment_method, mit) | 0.5.0+ | 0.5.0+ |
capabilities.get | 0.5.0+ | 0.5.0+ |
paymentIntents.capture | 0.6.x | 0.6.x |
paymentIntents.void | 0.6.x | 0.6.x |
refunds.create | 0.6.x | 0.6.x |
tokens.create | 0.6.x | 0.6.x |
Until 0.6.x ships, capture / void / refund / token operations are reachable via raw HTTP — every operation in this guide includes the curl form alongside the SDK form so a partner pinned to 0.5.0 today can still complete the integration. When 0.6.x lands, drop the curl calls in favor of the typed methods; the wire shape and idempotency semantics are identical.
Known SDK type drift (0.5.0)
next_actiononPaymentIntentis typed asstring | null. The wire format is actually the structured{ type, redirect_to_url: { url } }object documented in Authentication challenges. 0.6.x ships the corrected type.payment_methodandmitparameters onpaymentIntents.create()may not appear in the 0.5.0 typed surface — the wire format accepts them, but you may need to cast the input or use raw HTTP until 0.6.x updates the types.
Related
- Create a Checkout Session — the hosted-redirect alternative.
- API Reference — Payment intent statuses
- Error Codes
- Test Cards
- Webhook Events —
payment_intent.*payload schemas. - Webhook Signature Verification —
whsec_*verifier.