Skip to main content

Getting a payment-method token

A server-side charge needs a vp_pmt_* payment-method token — the Payment Intents body has no card fields — and the token has to come from somewhere the card number never reaches your server.

The flow

┌─────────────────────────────────────────────────────────────┐
│ Buyer's browser │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Iframe-vault provider's SDK │ │
│ │ [Card #][Exp][CVV][Billing address] (PCI-isolated) │ │
│ └────────────────────────────────────────────────────────┘ │
│ │ tokenize │
│ ▼ │
│ provider_reference handle │
└───────────────────────┼─────────────────────────────────────┘

▼ (sent to your server)
POST /v1/tokens
{ provider_reference: "..." }


vp_pmt_test_QAqnXEJF...


POST /v1/payment_intents
{ amount, currency,
payment_method: { id: "vp_pmt_..." } }

Three steps: (1) browser-side tokenize the card, (2) server-side mint a vp_pmt_* from the iframe handle, (3) charge it via Payment Intents. Card data flows browser → vault → token; never to your server, never to ours.

What you integrate browser-side

Your iframe-vault provider's SDK is what renders the card form on your checkout page. The path:

  1. Load your iframe-vault provider's JS SDK on your checkout page.
  2. Render their card-form iframe. The buyer enters card + billing address inside the iframe.
  3. The iframe SDK returns a provider_reference (a vault-side token, format depends on the provider) once tokenization succeeds.
  4. POST that handle to your server.

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 ours

Anything 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_yearexp_month and exp_year are integers) you can show in your UI for "card on file" displays. The PAN itself never appears.

On a self-serve sandbox, POST /v1/tokens always mints a synthetic card token (default visa / 4242, expiry 12 / 2030) on the sandbox path — even with an empty {} body. 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" },
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" },
);

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 — ordinary data from your own checkout form:

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" \
-d '{
"amount": 1499,
"currency": "USD",
"capture_method": "automatic",
"payment_method": { "id": "vp_pmt_test_QAqnXEJF_TCum1jg" },
"billing_address": {
"address_line1": "1600 Pennsylvania Ave NW",
"postal_code": "20500",
"country": "US"
}
}'

The object is strict — unknown keys are rejected:

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

Raw REST only. The Node and Python SDKs don't accept billing_address on paymentIntents.create yet — send it on the raw request body shown above. (Don't confuse it with the mirror block's own address, which routes connected-platform orders, not AVS.)

When AVS is enabled for your merchant, the create response carries a vendor-neutral avs_result_code:

avs_result_codeMeaning
matchAddress and postal code both matched
partial_postalPostal code matched, street did not
partial_addressStreet matched, postal code did not
no_matchNeither matched
unavailable / not_supportedThe issuer/processor didn't return a result

The response also carries cvv_result_code, but a charge against a stored token can't verify one — a vaulted vp_pmt_* carries no security code, so where the field is populated it reads not_provided (a distinct enum member, not JSON null). To actually check a CVV you have to catch it on the buyer's first charge: see charge at submit, where the security code rides the charge in elements mode. Both codes are null when no billing_address is sent or AVS/CVV didn't run for your merchant/processor. Treat a no_match as a risk signal, not an automatic decline — you own the accept / review / refund decision.

Shipping address: there is no shipping_details field on the Payment Intents body. Keep shipping on metadata for your own records, or — to create a matching already-paid order in a connected store (with its shipping address) — 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:

FieldTypeDescription
buyer_idstringYour 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_emailstringThe 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). Links the charge to the buyer record (best-effort; never blocks the charge). We never email the buyer — no receipt is sent from this field. It is forwarded to your payment provider on the charge's billing block, where a complete billing block helps the processor authorise and gives you stronger evidence when defending a dispute. ⚠️ Forwarding is an account-level setting, not a per-request one, so a successful response does not by itself confirm the billing block was sent — ask us if you need it switched on. Send it explicitly on every charge you want it applied to: there is a fallback to the email already on file, but it is off by default and can only find a buyer you also identify by buyer_id / buyer.external_id — it cannot look one up by email alone.
buyerobjectNested buyer profile: external_id, email, first_name, last_name, 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. ⚠️ first_name / last_name are the exception: they are the structured pair that reaches the processor's billing block (see what reaches your provider), they belong to this charge and are not stored on the buyer record, and they cannot be added to an existing buyer later — see Putting a name on the payment. A combined name is stored but never split.
session_idstringThe 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 each arm a server-side guard and are silent no-ops when omitted — an integration that leaves them out looks healthy until it double-charges someone or bills the wrong customer's saved card.

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_method may not be enabled on production for every processor; sandbox works regardless. If you need direct-charge support on live keys, contact your VORA point of contact to enable it for your merchant.