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: every charge uses a vaulted vp_pmt_* token — minted through our iframe (Embedded Fields / hosted checkout), or, if you handle cards under your own PCI compliance, by binding your provider-vault handle via provider_reference. The card is always tokenized first; raw card numbers are never sent to Von Payments. If hosted-redirect is fine, use Sessions instead.
Pairing with Embedded Fields. When your front-end uses Embedded Fields, the
vp_pmt_*token returned by the canonicalelements.submit()collection path is thepayment_methodyou pass toPOST /v1/payment_intentshere. The token'ssetup_for_future_usefield, set at vault time, governs whether subsequent charges are allowed — omitted for single-use,"on_session"for in-session reuse like upsells,"off_session"for recurring / MIT. On the browser side,submitResult.setupForFutureUseis aboolean(truewhen the buyer opted into future/MIT use), not the string enum — the SDK maps the wire value down to that boolean, so branch onif (submitResult.setupForFutureUse), not string equality. The two flows compose: Embedded Fields handles the iframe PCI side; Payment Intents handles the server-side lifecycle (auth, capture, void, refund, MIT). See Tokenization for the reusability model, and the Embedded Fields quickstart for the full server + browser handshake.
Lifecycle
A payment intent is a discrete state machine. The success, void, and failure states are terminal — once an intent is succeeded, voided, or failed, it does not move again.
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. This state is reached whencapture_method: "manual", and also whenever the underlying processor returns an auth-only outcome (for example after a 3DS challenge resolves to an authorization). It then settles via an explicit capture, or is released via void.succeeded— funds captured. Terminal for the auth/capture leg. Refunds against a succeeded intent are recorded separately on the refund ledger; the intent itself stayssucceeded(there is norefundedintent status).voided— authorization released without capture. Terminal.failed— auth or capture rejected. Terminal.decline_codeon the response carries a generic reason.
Some processors surface a transient captured status on the way to succeeded; it is a non-terminal step (it transitions on to succeeded when the charge settles) and you can treat a captured intent as in-flight settlement. Refunds require the intent to be succeeded.
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 payment-method token from
POST /v1/tokens(or, for browser-side flows, a token returned by the Embedded Fieldselements.submit()). Every token is avp_pmt_*; reusability is governed by thesetup_for_future_usefield on the vault row (nullfor single-use by the originating intent,"on_session"for in-session reuse like upsells,"off_session"for recurring / MIT). The token passes viapayment_method.id. If you don't yet have a token, see Capturing the card below for the upstream tokenization flow, and Tokenization for the full reusability model.
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, PaymentMethodRef
vonpay = VonPayCheckout(os.environ["VON_PAY_SECRET_KEY"])
intent = vonpay.payment_intents.create(
amount=1499,
currency="USD",
capture_method="automatic",
payment_method=PaymentMethodRef(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=PaymentMethodRef(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 only whenstatus === "requires_action"(an intent may berequires_actionwithnext_action: nullwhen no challenge URL is required yet). When present it is 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 (2xx) 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. The body accepts provider_reference and buyer_id (both optional):
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 — exp_month and exp_year are integers) you can show in your UI for "card on file" displays. The PAN itself never appears.
On sandbox/test keys, POST /v1/tokens always mints a mock card token (default visa / 4242, expiry 12 / 2030) on the sandbox path — even with an empty {} body. This is an intentional dev affordance for SDK examples and tests that don't need a real card. On live keys, provider_reference is required and a request without it is rejected with validation_error.
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.
Billing address & AVS
When you charge a card server-side, pass the buyer's billing address on the request so the processor can run Address Verification (AVS). Supply it as an optional top-level billing_address object on POST /v1/payment_intents — unlike the card number, the address is ordinary data that comes from your own checkout form, so it never needs to touch the iframe:
curl -X POST https://checkout.vonpay.com/v1/payment_intents \
-H "Authorization: Bearer $VON_PAY_SECRET_KEY" \
-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" },
"billing_address": {
"address_line1": "1600 Pennsylvania Ave NW",
"postal_code": "20500",
"country": "US"
}
}'
The object is strict — unknown keys are rejected:
| Field | Required | Constraint |
|---|---|---|
address_line1 | required | string, 1–100 chars |
address_line2 | optional | string, ≤ 100 chars |
city | optional | string, ≤ 100 chars |
state | optional | string, ≤ 100 chars |
postal_code | required | string, 1–16 chars |
country | required | 2-letter ISO 3166-1 alpha-2, uppercase (e.g. US) |
Raw REST only. The Node and Python SDKs don't accept
billing_addressonpaymentIntents.createyet — send it on the raw request body shown above. (Don't confuse it with themirrorblock's own address, which routes connected-platform orders, not AVS.)
When AVS is enabled for your merchant, the create response carries a vendor-neutral avs_result_code:
avs_result_code | Meaning |
|---|---|
match | Address and postal code both matched |
partial_postal | Postal code matched, street did not |
partial_address | Street matched, postal code did not |
no_match | Neither matched |
unavailable / not_supported | The issuer/processor didn't return a result |
The response also carries cvv_result_code, but a charge against a stored token can't verify one — a vaulted vp_pmt_* carries no security code, so where the field is populated it reads not_provided (a distinct enum member, not JSON null). To actually check a CVV you have to catch it on the buyer's first charge: see charge at submit, where the security code rides the charge in elements mode. Both codes are null when no billing_address is sent or AVS/CVV didn't run for your merchant/processor. Treat a no_match as a risk signal, not an automatic decline — you own the accept / review / refund decision.
Shipping address: there is no shipping_details field on the Payment Intents body. Keep shipping on metadata for your own records, or — to create a matching already-paid order in a connected store (with its shipping address) — attach a mirror block.
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 not be enabled on production for every processor; 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
You own the rebill loop. Von Payments vaults the token and relays the charge — you keep the token reference (server-side, keyed to your customer), run the scheduler (cron, queue, whatever fires "charge customer X on day N"), handle dunning on failure, and own the subscription state machine. MIT primitives are the substrate you build that loop on.
Once a card is on file as a vault token with setup_for_future_use: "off_session" (minted when the buyer gave consent at checkout — see Tokenization — reusability) and a prior cardholder-initiated intent has succeeded, 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.
MIT requires a token vaulted with
setup_for_future_use: "off_session". If the buyer didn't opt into save-for-future-use, the token'ssetup_for_future_useisnullor"on_session"and the server returnspayment_method_consent_missing(HTTP 422) on a merchant-initiated charge. Re-vault with explicit off-session consent before retrying.
Capability gate
The merchant capability matrix exposes supported_operations.mit. Read /v1/capabilities and branch on the response rather than hard-coding per-processor assumptions — the matrix tells you which optional operations a merchant is configured for. (The matrix is advisory metadata for your integration; the hard server-side gate on a merchant-initiated charge is MIT-chain validity, described under Chain validity.)
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_R6mzKBh3_Ud8nGgf" }, // vaulted with setup_for_future_use: "off_session"
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" },
);
from vonpay.checkout import MITBlock, PaymentMethodRef
renewal = vonpay.payment_intents.create(
amount=2999,
currency="USD",
capture_method="automatic",
payment_method=PaymentMethodRef(id="vp_pmt_test_R6mzKBh3_Ud8nGgf"), # vaulted with setup_for_future_use: "off_session"
mit=MITBlock(
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 captured/succeeded cardholder-initiated intent, not another MIT in the chain).
A cross-merchant original_transaction_id returns 404. An anchor that is not chargeable returns 409 with code: invalid_transition. (The MIT-reject error body does not carry a reject_reason field — branch on the code and HTTP status.)
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.redirectToUrl.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, learn the outcome via the webhook:
Webhook — listen for payment_intent.succeeded or payment_intent.failed on your subscription endpoint. The webhook fires within seconds of the bank's terminal callback.
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 via the webhook.
next_actionis a structured object. On@vonpay/checkout-nodethe SDK camelCases the wireredirect_to_urlkey, so it is typed as{ type, redirectToUrl: { url } }— dot access:intent.nextAction.redirectToUrl.url. On the Python SDKnext_actionis a dict that keeps the wire snake_case, so read it by subscript:intent.next_action["redirect_to_url"]["url"]. Branch ontyperather than treating it as a string.
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. A successful capture moves the intent to succeeded; there is no incremental multi-capture model, so a second capture on an already-captured intent is rejected as a state-machine error. Requesting more than the authorized amount returns 422 with code: capture_amount_exceeds_authorized.
Full capture
Node
const captured = await vonpay.paymentIntents.capture(
"vpi_test_abc123",
undefined,
{ idempotencyKey: "ord_42_capture_attempt_1" },
);
// captured.status === "succeeded"
Raw HTTP
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
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
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 capture response is HTTP 200 with a compact body — id, status: "succeeded", amount (the captured amount), and currency:
{
"id": "vpi_test_abc123",
"status": "succeeded",
"amount": 1000,
"currency": "USD"
}
Refund a succeeded intent
POST /v1/refunds. Reference the intent by payment_intent. Omit amount to refund the full remaining balance — the server computes the remaining from the captured (settled) amount minus what's already been refunded. Pass amount for a partial. Refund IDs are prefixed vpr_test_ or vpr_live_. A refund is only valid against a succeeded intent.
Full refund
Node
const refund = await vonpay.refunds.create(
{
paymentIntent: "vpi_test_abc123",
reason: "requested_by_customer",
},
{ idempotencyKey: "ord_42_refund_attempt_1" },
);
// refund.id starts with "vpr_test_" or "vpr_live_"
// refund.status === "succeeded" (in-flight async refunds report "requested")
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_refund_attempt_1" \
-d '{
"payment_intent": "vpi_test_abc123",
"reason": "requested_by_customer"
}'
Partial refund
Node
const refund = await vonpay.refunds.create(
{
paymentIntent: "vpi_test_abc123",
amount: 500,
reason: "requested_by_customer",
},
{ 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": "requested_by_customer"
}'
The reason field accepts duplicate, fraudulent, requested_by_customer, or expired_uncaptured_charge. Response:
{
"id": "vpr_test_JL3xPcFktvsF10Ib",
"payment_intent": "vpi_test_abc123",
"amount": 500,
"currency": "USD",
"status": "succeeded",
"reason": "requested_by_customer"
}
The refund status resolves to succeeded (provider confirmed) or failed (provider rejected); requested is the in-flight state while an async provider is dispatching, and canceled is rare. There is no pending value.
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; on success the intent becomes voided. Void requires the intent to be authorized — 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
const voided = await vonpay.paymentIntents.void(
"vpi_test_abc123",
{ idempotencyKey: "ord_42_void_attempt_1" },
);
// voided.status === "voided"
Raw HTTP
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 '{}'
The void response is HTTP 200 with a compact body — id, status: "voided", amount, and currency.
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. |
not_supported | Voiding a succeeded intent is not available. Use /v1/refunds instead. |
rerouted_to_refund | Same observable behavior as not_supported — the canonical fix is /v1/refunds. |
Voiding an already-captured (succeeded) intent is rejected by the void endpoint's status guard: it returns a 409 with code: invalid_transition and current_status: "succeeded" (void requires the intent to be authorized). There is no void_after_capture-specific error and no 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. A retry with the same key returns the original resource without creating a duplicate operation. The replay is signalled by the status code: the original create returns 201, while an idempotent replay returns 200. For POST /v1/payment_intents the status code is the only replay signal (the response body is byte-identical to the original). POST /v1/refunds and POST /v1/tokens additionally echo an idempotent: true field in the replay body. 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 422 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 error envelope (error, code, fix, docs, and selfHeal) augmented with up to three lifecycle fields (payment_intent, current_status, reject_reason). This lets you branch on the rejection cause without a follow-up retrieve.
{
"payment_intent": "vpi_test_abc123",
"current_status": "succeeded",
"reject_reason": "terminal_state",
"error": "Payment intent is not in a valid state for this operation.",
"code": "invalid_transition",
"fix": "Payment intent is not in a valid state for this operation",
"docs": "https://docs.vonpay.com/reference/error-codes#invalid_transition",
"selfHeal": { "retryable": false, "nextAction": "no_action" }
}
| Field | Notes |
|---|---|
code | invalid_transition (HTTP 409) for state-machine rejections, capture_amount_exceeds_authorized (HTTP 422) for over-capture, refund_amount_exceeds_remaining (HTTP 422) 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, captured, succeeded, voided, failed. |
reject_reason | Server-canonical cause on the capture/void path: intent_not_found, terminal_state, invalid_transition, concurrent_update, lookup_failed. |
selfHeal | Machine-readable retry guidance (e.g. retryable, nextAction) attached to every error. |
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") {
// The intent is no longer voidable (e.g. 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" | "not_supported" | "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
}
}
The nine fields auth_capture_separation, partial_capture, partial_refund, unreferenced_refund, mit, network_tokens, three_d_secure_2, ach, and payouts_api are booleans; void_after_capture is the three-value enum above. The payment_intents endpoint is rate-limited to 100 requests per minute (a sliding 60-second window).
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 3DS challenge 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. The payload carries failure_reason, a generic failure_code, and network_decline_code. |
payment_intent.cancelled | Intent was voided. The event name is payment_intent.cancelled (the cancellation is conveyed by the event type; the payload carries session_id, payment_intent_id, transaction_id, amount, currency, and cancellation_reason). |
Refunds are surfaced on the separate charge.refunded event, not a payment_intent.* event — there is no payment_intent.refunded.
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
Every operation in this guide is available as a typed method in the current @vonpay/checkout-node and vonpay-checkout (Python) SDKs: paymentIntents.create (including payment_method and mit), capabilities.get, paymentIntents.capture, paymentIntents.void, refunds.create, and tokens.create. Each example pairs the typed method with the equivalent curl form as a language-neutral reference; the wire shape and idempotency semantics are identical across both.
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.