Skip to main content

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. 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 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 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.

Coming from another gateway:

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

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_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. 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
},
});
FieldTypeRequiredDescription
rule_tagsobject (string→string)optionalLabels 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'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.

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​

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.

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_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.)

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 flowSend setup_for_future_use: "off_session" on
Embedded fields, charging at submitPOST /v1/public/sessions/{id}/charge
Embedded fields, vault without chargingPOST /v1/public/tokens
Server-to-server, and you already hold a provider-side card handlePOST /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 (localhost is 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_id and transaction_status query parameters — UI hints only. Use the webhook, or GET /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 on status.
  • Not applicable to merchant-initiated charges. On an off-session mit charge with initiator: "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 in requires_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.

Ignoring requires_action silently loses the sale

requires_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_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.

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:

FieldOn a capture response
amountThe 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_authorizedThe 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 (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" },
);
// 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" }
}
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";

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:

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 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 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.failedIntent 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.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.

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.