Skip to main content

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 canonical elements.submit() collection path is the payment_method you pass to POST /v1/payment_intents here. The token's setup_for_future_use field, 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.setupForFutureUse is a boolean (true when the buyer opted into future/MIT use), not the string enum — the SDK maps the wire value down to that boolean, so branch on if (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. The next_action field on the response tells you what.
  • authorized — funds reserved on the buyer's card, not yet captured. This state is reached when capture_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 stays succeeded (there is no refunded intent status).
  • voided — authorization released without capture. Terminal.
  • failed — auth or capture rejected. Terminal. decline_code on 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 units1499 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 to succeeded on success.
  • Auth-only (also called authorize) — set capture_method: "manual". Authorization holds funds on the card; you settle later via POST /v1/payment_intents/{id}/capture. Intent stops at authorized and waits.
Terminology mapping

If you're coming from another gateway, here's how the lifecycle operations map to VORA:

Industry termVORA equivalent
Sale / Purchase / Auth+Capturecapture_method: "automatic" on POST /v1/payment_intents
Authorize / Auth / Auth-onlycapture_method: "manual" on POST /v1/payment_intents
Capture / SettlePOST /v1/payment_intents/{id}/capture
Void / CancelPOST /v1/payment_intents/{id}/void
Refund / CreditPOST /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 Fields elements.submit()). Every token is a vp_pmt_*; reusability is governed by the setup_for_future_use field on the vault row (null for single-use by the originating intent, "on_session" for in-session reuse like upsells, "off_session" for recurring / MIT). The token passes via payment_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_action is non-null only when status === "requires_action" (an intent may be requires_action with next_action: null when no challenge URL is required yet). When present it is always a structured object — see Authentication challenges (3DS) for the full handling.
  • decline_code is non-null when status === "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:

  1. Load your iframe-vault provider's JS SDK on your checkout page.
  2. Render their card-form iframe. The buyer enters card + billing address inside the iframe.
  3. The iframe SDK returns a provider_reference (a vault-side token, format depends on the provider) once tokenization succeeds.
  4. 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_yearexp_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:

FieldRequiredConstraint
address_line1requiredstring, 1–100 chars
address_line2optionalstring, ≤ 100 chars
cityoptionalstring, ≤ 100 chars
stateoptionalstring, ≤ 100 chars
postal_coderequiredstring, 1–16 chars
countryrequired2-letter ISO 3166-1 alpha-2, uppercase (e.g. US)

Raw REST only. The Node and Python SDKs don't accept billing_address on paymentIntents.create yet — send it on the raw request body shown above. (Don't confuse it with the mirror block'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_codeMeaning
matchAddress and postal code both matched
partial_postalPostal code matched, street did not
partial_addressStreet matched, postal code did not
no_matchNeither matched
unavailable / not_supportedThe 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_method may 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's setup_for_future_use is null or "on_session" and the server returns payment_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

FieldValuesNotes
initiatormerchant | customermerchant for pure server-driven (renewal, retry). customer for buyer-initiated charges with a card on file.
reasonrecurring | unscheduled | installmentScheme-level reason code. recurring for fixed-cadence subscriptions, unscheduled for retries / fraud-recovery / variable-cadence, installment for fixed-count installments.
original_transaction_idvpi_(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_id must 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_action is a structured object. On @vonpay/checkout-node the SDK camelCases the wire redirect_to_url key, so it is typed as { type, redirectToUrl: { url } } — dot access: intent.nextAction.redirectToUrl.url. On the Python SDK next_action is a dict that keeps the wire snake_case, so read it by subscript: intent.next_action["redirect_to_url"]["url"]. Branch on type rather 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 (not cancel) to match the server endpoint name. void is 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:

ValueMeaning
supportedVoid works against succeeded intents directly.
not_supportedVoiding a succeeded intent is not available. Use /v1/refunds instead.
rerouted_to_refundSame 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" }
}
FieldNotes
codeinvalid_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_intentThe intent the operation targeted.
current_statusThe intent's status at the moment of rejection — one of requires_action, authorized, captured, succeeded, voided, failed.
reject_reasonServer-canonical cause on the capture/void path: intent_not_found, terminal_state, invalid_transition, concurrent_update, lookup_failed.
selfHealMachine-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:

FieldBranch 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 the t=…,v1=… signature using your whsec_* secret. Do not trust the payload until verification passes. See Webhook Signature Verification.

EventFires when
payment_intent.succeededIntent reached succeeded (auto-capture, manual capture, or post-3DS settle).
payment_intent.failedIntent reached failed. The payload carries failure_reason, a generic failure_code, and network_decline_code.
payment_intent.cancelledIntent 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.