Payment Intents
Payment Intents is the server-side engine, not a third way to collect a card. Hosted Checkout and Embedded Fields both vault a vp_pmt_* token and settle through it, so if you are on either path you are already using this API — Choose your integration compares the two. You also call it directly, with no buyer-facing redirect, when your server is the source of truth: delayed capture, fraud-check-before-capture, voids, refunds, subscriptions and MIT, and platform-integrator flows that drive the state machine themselves.
New to Payment Intents? The quickstart is a 5-step walkthrough from tokenize to capture/refund; this page is the reference. The sequenced path through the whole integration is Build your integration.
Before you start
When to use Payment Intents vs Sessions
Sessions are the right choice when a hosted checkout page is acceptable. Payment Intents are for what Sessions can't cover — delayed capture (auth on order, capture on ship), fraud-check-before-capture, and any flow where your server is the source of truth with no buyer redirect. Every charge uses a vaulted vp_pmt_* token — minted through our iframe (Embedded Fields or hosted checkout), or, if you handle cards under your own PCI compliance, by binding a provider-vault handle via provider_reference; raw card numbers are never sent to Von Payments.
With Embedded Fields, the vp_pmt_* returned by elements.submit() is the payment_method you pass here. Its setup_for_future_use, set at vault time, governs later charges — omitted for single-use, "on_session" for in-session reuse, "off_session" for recurring / MIT (Tokenization); on the browser result setupForFutureUse is a boolean, so branch on if (submitResult.setupForFutureUse), not string equality.
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.
Take a payment
The charge itself: creating it, getting a card token to charge, and the two things most likely to cost you money — a double charge, and assuming a field reached your provider.
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.
Coming from another gateway:
| 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 |
There is no separate "charge" object — the payment intent is the charge.
POST /v1/payment_intents.
You need a vp_pmt_* token first — from elements.submit() in the browser, or POST /v1/tokens server-side (Capturing the card); it passes as payment_method.id. Send return_url on every buyer-present charge: a card that requires 3-D Secure is rejected without it (422 provider_request_rejected / missing_redirect_url) rather than challenged, and it is ignored when no challenge is needed — Authentication challenges.
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
returnUrl: "https://mystore.com/checkout/return?order=ord_42", // where the bank returns the buyer; your order ref tells the page which order
metadata: { orderId: "ord_42" },
},
{ idempotencyKey: "ord_42_create" },
);
if (intent.status === "succeeded") {
// funds captured
} else if (intent.status === "requires_action") {
// 3-D Secure: send the buyer to intent.nextAction.redirectToUrl.url (no nextAction: still processing, do not retry)
} 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
return_url="https://mystore.com/checkout/return?order=ord_42", # where the bank returns the buyer; your order ref tells the page which order
metadata={"order_id": "ord_42"},
idempotency_key="ord_42_create",
)
if intent.status == "succeeded":
pass # funds captured
elif intent.status == "requires_action":
pass # 3-D Secure: send the buyer to intent.next_action's redirect URL
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" \
-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
returnUrl: "https://mystore.com/checkout/return?order=ord_42", // where the bank returns the buyer; your order ref tells the page which order
metadata: { orderId: "ord_42" },
},
{ idempotencyKey: "ord_42_authorize" },
);
// 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
return_url="https://mystore.com/checkout/return?order=ord_42", # where the bank returns the buyer; your order ref tells the page which order
metadata={"order_id": "ord_42"},
idempotency_key="ord_42_authorize",
)
# 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" \
-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. Which input produces each decline code depends on your test environment: see Test Cards.
decline_code is distinct from the API-level error codes returned in the code field on a 4xx response (those are documented in Error Codes). A failed intent is a successful API call that reports a payment-level decline; an API error is a request that never reached the processor.
Creating an intent returns 201 whatever the outcome: a decline and an approval carry the identical HTTP status, so branch on status, never on the status code. A replay of the same Idempotency-Key returns 200, having created nothing.
Capturing the card (where vp_pmt_* tokens come from)
A server-side charge needs a vp_pmt_* token, minted from card details the browser collects so the number never reaches your server. Getting a payment-method token covers the browser-side integration, minting the token, and the billing-address and buyer fields to send with the charge.
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_-]+$.
A session that charged at submit has already billed the buyer by the time your server runs; passing session_id lets us refuse the second charge instead of taking the money. It is ignored when the session did not charge, so send it whenever you have it.
Common mistake — setup_for_future_use does not belong here
setup_for_future_use is recorded when the card is vaulted and read back at charge time; sending it here returns 400 validation_unknown_field. Where it belongs: Reusability.
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 is distinct from metadata in purpose: rule_tags is delivered to the provider so its rules can match on it, while metadata is your own system-of-record and nothing matches on it.
⛔ That is a difference of purpose, not a privacy boundary. How much of metadata reaches the provider depends on your connection — on the narrowing connection, only the single order reference; on at least one live connection, every non-reserved key is copied onto the provider's own record. Do not put anything in metadata that you would not want the processor to hold, and do not rely on it as chargeback evidence either. Metadata is the full reference, and What reaches your payment provider has the per-field detail.
As with metadata, your rule_tags 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" },
returnUrl: "https://mystore.com/checkout/return?order=ord_42",
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 honoured only where your provider supports orchestration rules; elsewhere the create call is rejected pre-charge with capability_not_supported (HTTP 422) rather than silently ignoring the tags.
See the Rule tags guide for the full workflow, string-vs-numeric matching, and gotchas.
What reaches your payment provider
Not everything you send is forwarded to the provider that processes the charge, and what is forwarded depends on your connection rather than on the API. What reaches your payment provider covers which fields travel, which never do, and why metadata is neither reliable evidence nor reliably private.
Saved cards / merchant-initiated (MIT) charges
Building a subscription or card-on-file flow? Recurring & saved cards walks the four steps in order; this section is the field reference for the charge. You keep the token, run the schedule and handle dunning — Von Payments vaults the card and relays each charge.
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.
On a network-token connection, a repeat charge declared recurring against a card stored as recurring is approved, while an unscheduled pair — correctly matched — can still be refused because the processor requires a recurring indicator a card-on-file arrangement does not carry; the refusal arrives as the issuer's generic "revalidate payment information" advice. If you keep cards on file and charge them at irregular intervals, confirm your connection supports it before you build.
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
Before dispatching a merchant-initiated charge the server checks the chain:
- 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 — there is no endpoint that updates it, so no retry against the existing token can succeed. 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 on that call (a "Save my card for future purchases" checkbox), then charge the resulting new token with a normal POST /v1/payment_intents plus the mit block.
Authentication challenges (3DS)
Send return_url, or you never get a challenge — you get a rejection
Without return_url a card that needs a challenge is refused with missing_redirect_url, surfaced as HTTP 422 provider_request_rejected — you never reach requires_action, and the failure reads like a card problem.
POST /v1/payment_intents
{
"amount": 4999,
"currency": "USD",
"payment_method": { "id": "vp_pmt_live_..." },
"return_url": "https://mystore.com/checkout/return?order=ord_42"
}
- In the server SDKs it is
returnUrl(Node) /return_url(Python), from 2.5.0. Earlier versions cannot send it. - Put your own order reference in it (
?order=ord_42). The buyer comes back carrying only the processor's own parameters, so without your reference the return page cannot tell which order to look up. - Must be an absolute HTTPS URL (
localhostis accepted for local development), max 2048 characters. - Ignored when no challenge is needed — it is safe to send on every charge, and that is what we recommend.
- The return redirect is not your source of truth. The processor appends its own
transaction_idandtransaction_statusquery parameters — UI hints only. Use the webhook, orGET /v1/payment_intents/{id}(paymentIntents.retrieve(id)in Node,payment_intents.retrieve(id)in Python), as the authoritative outcome. Read it with the id you stored when you created the intent, never one from the return URL, and fulfil only onstatus. - 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
The only type value is redirect_to_url; branch on type so an unknown action type fails safe.
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
Learn the outcome from the payment_intent.succeeded / payment_intent.failed webhook, which fires within seconds of the bank's terminal callback — the buyer can close the tab or never come back and the payment still resolves.
requires_action silently loses the salerequires_action is not a decline and not a retryable error: the payment is waiting on the buyer, and nothing happens until you send them to the challenge URL. An integration that treats every non-succeeded status as a failure leaves the intent unresolved — no money captured, no webhook, nothing in your logs — for the subset of cards that need a challenge. If you cannot send the buyer to a challenge, use Sessions, where hosted checkout runs it 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.
After the charge
Capturing, refunding, voiding — and which of those is actually available once an intent has settled.
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.
Capture and void return the whole payment intent, the same body GET /v1/payment_intents/{id} returns, read back after the state change. The API answers retrieve, capture and void with the same body, so you do not need to re-fetch afterwards. In the server SDKs, retrieve() carries the decline fields from 3.8.0; capture() and void() always have.
Two fields need care on a capture response:
| Field | On a capture response |
|---|---|
amount | The amount actually captured. On a partial capture this is less than the original authorization, so reconciliation that sums amount must read it as the captured figure. |
amount_authorized | The original authorization the intent carries. Present on capture responses. |
On a void response amount remains the authorized amount, matching a plain retrieve.
Two different 502s. provider_unavailable means the provider call failed or timed out: read the intent back, and if you retry, send the same Idempotency-Key. payment_outcome_unknown, with capture_outcome: "unknown" or void_outcome: "unknown", means the provider accepted the capture or void but it could not be recorded. Do not retry that one: a retried capture can capture twice. Read the intent back with GET /v1/payment_intents/{id}, and do not tell the buyer a hold was released until the intent confirms it. Branch on code, never on the status alone.
Full capture
Node
const captured = await vonpay.paymentIntents.capture(
"vpi_test_abc123",
undefined,
{ idempotencyKey: "ord_42_capture" },
);
// 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" \
-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" },
);
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" \
-d '{ "amount_to_capture": 1000 }'
The capture response is HTTP 200 with the whole payment intent, as above: status is "succeeded", amount is 1000 (the captured figure on this partial capture) and amount_authorized is the original authorization.
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" },
);
// 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" \
-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" },
);
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" \
-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); canceled means the reversal was voided before it left, so no money went back. There is no pending value.
⚠️ requested is not only an internal state — it is returned to you directly, with HTTP 202, when the processor has accepted the refund but not yet completed it. A 202 is not a failure: re-issuing the refund on it pays the buyer twice. See Refunds → Response for all four success shapes and how to resolve a requested refund.
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" },
);
// 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" \
-d '{}'
The void response is HTTP 200 with the whole payment intent: status is "voided" and amount stays the authorized amount.
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.
supported_operations.void_after_capture on the capability matrix is not_supported on every processor, so /v1/refunds is the path on all of them; the enum also carries supported and rerouted_to_refund, so branch with the refund as the fallback, as below. Voiding a succeeded intent returns 409 invalid_transition with current_status: "succeeded" — read /v1/capabilities once at startup and branch up front.
const caps = await vonpay.capabilities.get();
async function reverse(intentId: string, intent: PaymentIntent) {
if (intent.status === "authorized") {
return vonpay.paymentIntents.void(intentId, { idempotencyKey: `${intentId}_void` });
}
if (intent.status === "succeeded") {
if (caps.supportedOperations.voidAfterCapture === "supported") {
return vonpay.paymentIntents.void(intentId, { idempotencyKey: `${intentId}_void` });
}
const refund = await vonpay.refunds.create({ paymentIntent: intentId });
// `canceled` is terminal and moved no money; `requested` is still in flight.
// Only `succeeded` means the buyer has their money back.
return refund;
}
throw new Error(`Cannot reverse intent in status ${intent.status}`);
}
Retries and failures
What to send so a retry cannot charge twice, and how a refusal is reported back to you.
Idempotency
Send Idempotency-Key on every POST. A retry with the same key returns the original resource instead of creating a second operation — on a charge or a refund, that is the difference between one movement of money and two.
Idempotency is the full rule: choosing keys, the routes where the key is required rather than advisory, and the three responses that look alike and mean opposite things.
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";
const intentId = "vpi_test_abc123";
try {
await vonpay.paymentIntents.void(intentId, { idempotencyKey: `${intentId}_void` });
} catch (err) {
if (err instanceof VonPayError && err.code === "invalid_transition") {
// Only `succeeded` can be refunded. `invalid_transition` also fires for
// `voided`, `failed` and `requires_action`, where a refund is wrong — so read
// `currentStatus` instead of assuming "not voidable" means "captured".
if (err.currentStatus !== "succeeded") throw err;
// ⛔ A refund that does not throw is NOT a refund that paid the buyer.
// `200` with `status: "canceled"` is terminal and moves NO money; `202` with
// `status: "requested"` is still in flight. Read the status.
const refund = await vonpay.refunds.create(
{ paymentIntent: intentId },
{ idempotencyKey: `reverse_${intentId}` },
);
if (refund.status === "canceled") {
// The provider voided it before settlement. The buyer was NOT repaid, and
// retrying needs a NEW idempotency key. Escalate, do not mark it refunded.
throw new Error(`Refund canceled before settlement for ${intentId}`);
}
} else {
throw err;
}
}
For the full code catalog, see Error Codes.
Reference
What your account supports, what we send you, and which SDK versions carry each field.
/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": 300
}
}
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. A further member, dispute_reporting, is a string enum and is the one to read before you treat a succeeded status as evidence a payment was never disputed — on not_tracked connections it is not. With a secret key, the payment_intents endpoint is rate-limited to 300 requests per minute per key (a sliding 60-second window), shared with the other payment routes; see Rate Limits.
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 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 on a sale (auto-capture, or post-3DS settle). For a capture you make yourself, take the outcome from the capture response rather than waiting on this event (see Authorise now, capture later). |
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.
They are signed with the endpoint's whsec_* secret in the t=…,v1=… header format. See Webhook Signature Verification for the verifier; full payloads are in the Webhook Events catalog.
Related
- Create a Checkout Session — the hosted-redirect alternative.
- API Reference — Payment intent statuses
- Error Codes
- Test mode
- Webhook Events —
payment_intent.*payload schemas. - Webhook Signature Verification —
whsec_*verifier.