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.
Send
return_urlon every buyer-present charge. It is schema-optional, but a card that requires 3-D Secure is rejected without it (422 provider_request_rejected/missing_redirect_url) rather than returning a challenge. It is ignored when no challenge is needed, so there is no downside to always sending it. See Authentication challenges (3DS).
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; a nested buyer profile object is also accepted — see Tokens):
provider_reference must be the provider's handle, not one of oursAnything beginning vp_ is rejected with 400 validation_error. The usual mistake is passing a vp_pmt_* — but that is already the reusable token this endpoint produces. If you hold a vp_pmt_*, you don't need this endpoint at all: charge it directly with payment_method.id on POST /v1/payment_intents.
Using embedded fields? Prefer POST /v1/public/tokens, which resolves the provider-side handle from the session, so you never supply this field.
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) — send order and mirrorTo. They work the same way here as on hosted checkout: order.lineItems describes the purchase once, and the store order's customer and addresses are inherited from buyer. The older mirror block is still accepted; send one or the other, never both.
Buyer attribution
POST /v1/payment_intents accepts optional buyer fields (snake_case on this surface) so a server-side charge is attributed to the same buyer record the checkout flows use:
| Field | Type | Description |
|---|---|---|
buyer_id | string | Your own customer reference (1–200 chars) — the same value you pass as buyerId on session create. Links the charge to the buyer record (best-effort; never blocks the charge). Also the saved-card ownership assertion: when the stored payment_method was saved against a buyer, this must match the buyer the card was saved for — a mismatch returns 404 payment_method_not_found, so a saved card can't be charged against the wrong customer. Send it on every charge against a saved card; omit it for guest or one-off charges. |
buyer_email | string | The buyer's email (valid email, max 254 chars) — for flows that start from a payment intent instead of a checkout session (saved-card charges, subscription rebills, server-side charges). Attribution only, not a receipt address — no email is sent. |
buyer | object | Nested buyer profile: external_id, email, name, phone, company, address, metadata. Upserts the same buyer record as POST /v1/buyers and links the charge to it — the richer alternative to the flat fields above, which remain supported. Must carry an identity (buyer.external_id or buyer.email) directly or via a flat field, else 400 validation_error. |
session_id | string | The checkout session (vp_cs_*) this charge belongs to. Arms the double-charge guard: if that session already charged the buyer at submit time, this call is refused with 409 session_already_completed instead of billing the card a second time. Send it on charges related to that session. The refusal is deliberately conservative — it also fires while a charge is still in flight (processing) or after the session expired, because in those states we cannot prove no money moved. Don't attach an unrelated session ID to a fresh charge. |
session_id and buyer_id both switch on a server-side guard, and both are silent no-ops when omitted — so an integration that leaves them out looks completely healthy right up until the day it double-charges someone or bills the wrong customer's saved card.
session_id— send it on any charge related to a checkout session. It cannot cause a false refusal: if the session never charged, it is ignored.buyer_id— send it on every charge against a saved card, so a card saved for one customer can't be billed for another.
Sending a flat field AND its nested counterpart with equal values is fine; different identity values return 400 validation_error rather than silently preferring one (buyer_id doubles as the ownership assertion above, so the server never guesses which value you meant). The accepted buyer fields are echoed back on the response so a successful write is verifiable (buyer.metadata echoed post-scrub). When the stored payment_method already carries a buyer from vault time, that saved buyer still wins for attribution and is echoed as the effective buyer_id.
The linkage reads back on GET /v1/payment_intents/{id}: a buyer-linked charge includes buyer_id and buyer_email; charges with no buyer linkage omit both keys. This applies to automatic and manual (authorize-then-capture) captures — buyer identity sent at create carries through the capture. See the Buyers API for the full buyer-record reference.
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.
Preventing a double charge — session_id
If the payment originated from a checkout session, send that session's id:
{
"amount": 1499,
"currency": "USD",
"payment_method": { "id": "vp_pmt_test_QAqnXEJF_TCum1jg" },
"session_id": "vp_cs_test_a1b2c3d4e5f6g7h8"
}
Optional string matching ^vp_cs_(test|live)_[A-Za-z0-9_-]+$.
Why it matters. Embedded card fields can be configured to charge at submit time. When they are, the buyer has already been billed by the time your server runs — and a server that then creates a payment intent bills the same card twice. Passing session_id lets us refuse the second charge instead of taking the money.
Send it whenever you have it. It is not stored on the payment, and it is ignored when the session did not already charge — so there is no case where sending it costs you anything. The only way it changes behaviour is by preventing a duplicate charge.
Common mistake — setup_for_future_use does not belong here
setup_for_future_use is captured when the card is vaulted, not when it is charged, and it is read back automatically at charge time. POST /v1/payment_intents uses a strict schema, so sending it here returns 400 validation_unknown_field rather than being silently ignored.
That refusal is deliberate. Accepting the field would leave you believing you had captured off-session consent while every later merchant-initiated charge quietly failed the consent gate. See Reusability for where it does belong.
Rule tags
Attach an optional rule_tags map to a charge so your payment provider's pre-configured rules can match on it — to route to a specific processor, require or skip 3-D Secure, decline early, or change payment options. It's distinct from metadata: metadata is stored as your own record and is not forwarded to the provider, whereas rule_tags is delivered to the provider for rule matching. As with metadata, your keys round-trip verbatim (they're not case-converted).
The matching rule must already exist in your provider dashboard — you create a rule that matches on a key + value, then send that key + value here. A tag with no matching rule is a harmless no-op (forwarded, but changes nothing).
const intent = await vonpay.paymentIntents.create({
amount: 19999,
currency: "USD",
paymentMethod: { id: "vp_pmt_test_QAqnXEJF_TCum1jg" },
ruleTags: {
funnel: "premium",
order_value: "199.99", // numbers travel as numeric strings; match with a numeric condition
},
});
| Field | Type | Required | Description |
|---|---|---|---|
rule_tags | object (string→string) | optional | Labels forwarded to your provider for rule matching. Up to 5 keys; each value ≤ 80 chars; key names ≤ 64 chars; keys and values are non-empty strings. A small set of reserved internal keys is rejected, and values shaped like an email / API key / secret are rejected — values are visible to your provider, so never put secrets or PII in them. Send numbers as numeric strings (e.g. "199.99") and match with the provider's numeric condition. |
rule_tags is only honored when your merchant's provider supports orchestration rules; on a provider that doesn't, the create call is rejected pre-charge with capability_not_supported (HTTP 422) rather than silently ignoring the tags. Available today on this direct-charge paymentIntents.create call.
See the Rule tags guide for the full workflow, string-vs-numeric matching, and gotchas.
Saved cards / merchant-initiated (MIT) charges
Building a subscription or a card-on-file flow? Start with Recurring & saved cards — it walks the four steps in order, including the declaration you make when the card is first stored. This section is the field reference for the charge itself.
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.You cannot repair the existing token. Consent is recorded once, at vault time, and no endpoint updates it. The buyer must present the card again, with
setup_for_future_use: "off_session"sent on the call that vaults it — then charge the resulting new token. See Fixing a missing consent.
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. |
Matching the values is necessary, not always sufficient
Matching what you declared when the card was stored — the storedCredentialUse field on POST /v1/sessions, which uses these same three words — against the reason you send when you charge it again is required, and on some connections it still isn't enough. What a connection will accept for a merchant-initiated charge is set by the processor and the card issuer, not by us.
Observed on a live network-token connection in August 2026:
| Stored as | Charged again as | Result |
|---|---|---|
| (not declared) | recurring | declined |
recurring | recurring | approved |
recurring | unscheduled | declined |
unscheduled | unscheduled | declined |
On that connection only recurring was accepted for a repeat charge — a correctly matched unscheduled pair was still refused, because the processor required a recurring indicator that a card-on-file arrangement doesn't carry. Every refusal came back with the issuer's generic "revalidate payment information" advice, which does not tell you this is what happened.
If you keep cards on file and charge them at irregular intervals, confirm your connection supports it before you build. The failure mode is a decline you cannot debug from the response.
All of this applies only when the payment actually stores a card — that is, when the session identifies a buyer. On a payment that stores nothing, mit is accepted and ignored: it is not an error, and there is nothing to debug if you send it.
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.)
Fixing a missing consent
If a merchant-initiated charge returns payment_method_consent_missing (422), the token was vaulted without off-session consent.
Consent is write-once. It is recorded at the moment the card is vaulted, and there is no endpoint that updates it — no PATCH, no PUT, no "promote consent" call. Any retry loop built on the assumption that the existing token can be re-stamped will fail every time, no matter how many attempts it makes.
The card has to be collected again, with consent captured on that same call:
| Your flow | Send setup_for_future_use: "off_session" on |
|---|---|
| Embedded fields, charging at submit | POST /v1/public/sessions/{id}/charge |
| Embedded fields, vault without charging | POST /v1/public/tokens |
| Server-to-server, and you already hold a provider-side card handle | POST /v1/tokens |
POST /v1/tokens is the wrong endpoint for the common case: for a card collected by embedded fields it needs a provider-side handle you don't hold. Use the route that collected the card.
Obtain the buyer's explicit consent before that call — typically a "Save my card for future purchases" checkbox — because PSD2/SCA stored-credential rules require the consent record to match what the buyer actually agreed to. Then charge the resulting new token with a normal POST /v1/payment_intents plus the mit block.
Authentication challenges (3DS)
return_url, or you never get a challenge — you get a rejectionA 3-D Secure challenge only happens if you send return_url on the create call. It is optional in the schema, but it is required in practice for any card that needs authentication — most notably European/SCA cards.
Send it and the buyer gets the challenge described below. Omit it and the charge is rejected outright — the processor refuses the request with missing_redirect_url, which we surface as HTTP 422 provider_request_rejected. You never reach requires_action, so the handling code in this section never runs.
This is the single most common dead-end on the server-side path: the failure reads like a card problem, so integrators debug the card instead of the request.
POST /v1/payment_intents
{
"amount": 4999,
"currency": "USD",
"payment_method": { "id": "vp_pmt_live_..." },
"return_url": "https://mystore.com/checkout/return"
}
- Must be an absolute HTTPS URL (
localhostis allowed for test keys), max 2048 characters. - Ignored when no challenge is needed — it is safe to send on every charge, and that is what we recommend.
- Do not rely on the return redirect as your source of truth. On return, the processor appends its own
transaction_idandtransaction_statusquery parameters — treat those as UI hints only. The redirect can be interrupted or tampered with. Use the webhook, or aGET /v1/payment_intents/{id}, as the authoritative outcome. - Not applicable to merchant-initiated charges. On an off-session
mitcharge withinitiator: "merchant"the value is deliberately not forwarded — nobody is present to complete a challenge, and sending it risks stepping one up that strands the payment inrequires_action.
Using Sessions instead? The equivalent field is successUrl, and hosted checkout handles the whole challenge for you.
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 return_url is a UX hint, not a source of truth — the buyer can close the tab, lose connectivity, or never come back at all, and the payment still resolves. Always verify server-side via the webhook.
requires_action silently loses the salerequires_action is not a decline and not a retryable error. It means the payment is waiting on the buyer, and nothing further happens until you send them to the challenge URL.
If your integration treats any non-succeeded status as a failure and moves on, the intent simply sits unresolved: no money is captured, no webhook arrives, and nothing in your logs looks like an error. This is easy to miss on a server-to-server integration where there may be no browser step wired up at all — the payment appears to "just not work" for a subset of cards.
If you cannot send the buyer to a challenge in your flow, use Sessions instead — hosted checkout runs the whole challenge for you.
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, reverse it with a refund instead — see reversing a captured intent 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.
Reversing a captured intent
Once an intent is succeeded, reverse it with a refund. Void applies to an authorization that has not been captured yet.
The merchant capability matrix exposes this as supported_operations.void_after_capture. Every processor returns not_supported today, so /v1/refunds is the path on all of them. The enum carries three values:
| Value | Meaning |
|---|---|
not_supported | What every processor returns today. Voiding a succeeded intent is not available. Use /v1/refunds. |
supported | Void works against succeeded intents directly. |
rerouted_to_refund | Same observable behavior as not_supported — the canonical fix is /v1/refunds. |
Branch so that /v1/refunds is the fallback rather than a special case, as the example below does. Handling only supported and rerouted_to_refund reverses nothing on today's processors, raises no error, and leaves the buyer unrefunded.
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": "not_supported",
"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, network_decline_code, and rule_code (which of your own rules refused it, or null). |
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.