Skip to main content

Create a Checkout Session

Create a session on your server, then redirect the buyer to the checkout URL.

When to create the session

Create the session on checkout intent — not on every page load

Mint the session when the buyer signals they're ready to pay — they open the cart or click Checkout / Pay — not on every product or page view. POST /v1/sessions is a server-to-server call you control, and each session carries a short TTL (default 30 minutes — see expiresIn). Creating one on every pageview means almost all of them expire unpaid, puts the create-call latency on your page-load hot path, and risks the session expiring before a slow buyer reaches checkout. Only the browser render belongs on the page — the publishable-key embed, or the redirect to checkoutUrl; the session mint belongs at the moment of intent.

Endpoint

POST /v1/sessions
Authorization: Bearer vp_sk_live_xxx
Content-Type: application/json
Idempotency-Key: <unique-key> (optional, recommended)

Headers

HeaderRequiredDescription
AuthorizationYesBearer token (vp_sk_live_xxx or vp_sk_test_xxx). The header must use the Bearer scheme; a missing or malformed token returns 401.
Content-TypeNo (recommended)Send application/json. The request body is parsed as JSON regardless of this header — POST /v1/sessions does not reject a missing or mismatched Content-Type with a 415. Always send application/json so intermediaries and SDKs behave predictably; a body that isn't valid JSON fails to parse and surfaces as a 500.
Idempotency-KeyNoUnique key to prevent duplicate session creation. If you retry a request with the same key, the original session is returned instead of creating a new one. Recommended for all production integrations. See Idempotency below for retention behavior.

Request Body

FieldTypeRequiredDescription
amountintegerConditionalAmount in minor units (cents). 1499 = $14.99. Integer in the range 099,999,999. Required and must be ≥ 1 for mode: "payment" (the default). For mode: "setup" (card-on-file, no charge) it may be omitted and is normalized to 0.
currencystringYesThree-character currency code, uppercased server-side (USD, EUR, GBP). The field enforces exactly 3 characters; it does not validate ISO 4217 membership, so an unsupported but well-formed 3-letter code is accepted at this layer.
countrystringNoISO 3166-1 alpha-2 country code, exactly 2 characters, uppercased (US, CA, GB)
successUrlstringNoHTTPS URL to redirect buyer after payment
cancelUrlstringNoHTTPS URL to redirect buyer on cancel
modestringNoPayment mode — enum "payment" | "setup", default "payment". "payment" is a one-time charge and is the only mode generally available on hosted checkout. "setup" (collect + vault a card with no charge) is a not-yet-released capability — a mode: "setup" request currently returns endpoint_not_implemented (HTTP 501). There is no subscription / recurring mode on hosted checkout — for recurring flows, use Payment Intents directly.
integrationModestringNoSelects how the embedded payment surface is composed for this session — enum "embed" | "elements", default "embed". You choose it per session; there is no account-level lock, so the same account can create one session as "embed" and the next as "elements". "embed" renders the whole payment surface (card, billing details, payment-method picker, and any wallet buttons) together in a single combined card iframe — the simplest integration. "elements" renders the pieces (card, email, address, cardholder, save-for-future-use, payment-method picker) as separately placed elements you lay out yourself, so you control where each appears. Discrete "elements" rendering applies only where your account supports it; where it isn't available the session transparently falls back to "embed" (never an error), which is why the response echoes the effective mode. In "embed" the card is always one combined box; in "elements" you can keep the combined box or split it into the discrete card-number / card-expiry / card-cvc fields. See Elements.
storedCredentialUsestringNoDeclare what a stored card will be used for — enum "recurring" | "installment" | "unscheduled". Card networks require the payment that stores a card to say what the card is for; your later merchant-initiated charge is anchored to it. Send the same value you will later send as mit.reason on POST /v1/payment_intentsrecurring for a fixed-interval subscription, installment for a known number of scheduled payments for one purchase, unscheduled for a card kept on file and charged with no fixed schedule. Omit it for a one-off payment. If you omit it, nothing is declared to the card networks and the payment is sent exactly as it was before this field existed — even when the buyer consents to saving their card. There is no inferred default, and a subscription must declare recurring explicitly: buyer consent looks identical for a subscription and a card on file, so no value is guessed on your behalf. You also need buyer consent (setup_for_future_use: "off_session") on the payment that saves the card — a declaration sent without that consent is discarded, with no error. Echoed back on session retrieve so you can confirm it landed; null means none was recorded. See Recurring & saved cards.
descriptionstringNoHuman-readable description of the payment (max 500 chars). This is not the bank-statement descriptor — statement text is configured per-merchant server-side via a separate statement-descriptor field.
localestringNoCheckout page language, max 10 chars (e.g. "en", "fr")
expiresInintegerNoSession TTL in seconds (300–604800, default 1800). Minimum 5 minutes, maximum 7 days.
buyerIdstringNoYour stable, unique-per-user account ID (max 200 chars) — the same value every visit, not a per-visit token. See Buyer identification. Drives saved payment methods in both directions. Save: for the embedded charge-and-save flow a buyer on the session is the vaulting driver — with a buyer, submit() charges the card AND returns a reusable vp_pmt_* in one step; a guest / no-buyer session charges once and saves nothing (no token). Reuse: when a returning buyer is on the session, the embed lists that buyer's previously-saved cards inside the iframe for one-click reuse — only cards they vaulted in a prior session appear (a first-time buyer has none); see the note under Buyer identification. See Tokenization — charge-and-save for the buyer-vaulting model.
buyerNamestringNoBuyer's name, max 200 chars (pre-fills billing form, encrypted at rest)
buyerEmailstringNoBuyer's email — must be a valid email, max 254 chars (encrypted at rest). When provided, it links the buyer across sessions by email (a fallback identifier when buyerId varies or isn't set). On its own it does not surface saved cards — see the note under Buyer identification.
buyerobjectNoNested buyer profile (camelCase on this surface): externalId, email, name, phone, company, address (addressLine1, addressLine2, city, state, postalCode, country), metadata. Upserts the same buyer record as POST /v1/buyers and links the session to it — the richer alternative to the flat buyerId / buyerName / buyerEmail fields above, which remain supported. Must carry an identity (buyer.externalId or buyer.email) directly or via a flat field. Sending a flat field AND its nested counterpart with equal values is fine; different identity values return 400 validation_error. Echoed back on the create response (present only when sent; buyer.metadata echoed post-scrub). See Buyers.
lineItemsarrayNoOrder items to display on the checkout page (max 100 items). This field drives the buyer's checkout page only. Mirroring the order into a connected store? Send order.lineItems instead — one list that feeds both the checkout page and the store order. See Describing the order.
shippingstringNoShipping-address collection mode — "none" | "auto" | "required". Omit it to inherit your account setting (none unless you change it in the dashboard). The captured address is returned on the session object as shippingAddress. See Asking the buyer for more than a card.
phonestringNoPhone collection mode — "none" | "auto" | "required". Omit it to inherit your account setting (none unless you change it). A phone the buyer types does reach a mirrored store order, filling the order's shipping-address phone and its customer record — but only where you left that phone unset; one you supplied at session-create is never overwritten. ⚠ A typed phone must also pass a format check or the buyer cannot pay. See Asking the buyer for more than a card.
billingAddressstringNoBilling-address collection mode — "none" | "auto" | "required". Omit it to inherit your account setting (auto unless you change it) — note the different starting point: billing is shown-but-optional out of the box, while shipping and phone are hidden. See Asking the buyer for more than a card.
collectEmailbooleanNoWhen true, the hosted checkout shows an email input even if a buyer email was already provided on the session. An email input is shown automatically whenever no buyer email is on file, regardless of this flag. Defaults to false. Still a boolean — deliberately not folded into the three-way vocabulary above, because the auto-show behaviour means "none" could not be honoured.
metadataobjectNoKey-value string pairs (each value max 500 chars). Stored on the session for your own buyer/app context (e.g. device_id, app_user_id). Note: session metadata is persisted on the session record but is not currently echoed into webhook event payloads. Do not put a mirror block here: metadata values are strings, and a stringified mirror block is never parsed, so the session is created with no mirror attached and no store order is ever made. Use the top-level mirror field below.
orderobjectNoWhat the buyer is purchasing — lineItems, optional coupon and metadata, plus shipping on Next Commerce. Sent with mirrorTo, it drives both the checkout page and the connected-store order, so the purchase is described once. What you describe must add up to amount or the request is refused with 400 order_total_mismatch before any charge. On Shopify send shipping as a line item — the shipping field is refused there. See Describing the order.
mirrorToobjectNoWhere to record the order — platform ("shopify" or "nextcommerce") and store. The customer and addresses are inherited from buyer unless you set them here. Sent with order. The store order is created asynchronously after the charge, so it is not in the create response: poll GET /v1/public/sessions/{sessionId}/mirror-order or subscribe to the mirror.order.created webhook. See Describing the order.
mirrorobjectNoThe older connected-store block, still accepted — order / mirrorTo compiles into it. It carries its own line_items, separate from the top-level lineItems above, so on this path state the items in both places. Send this or order / mirrorTo, never both: a request carrying both is refused with 400 order_mirror_conflict. For the block's fields, the per-platform differences, and the error codes, see Connected platforms.

Line Item Object

FieldTypeRequiredDescription
namestringYesItem name (1–200 chars)
quantityintegerYesQuantity (1-9999)
unitAmountintegerYesUnit price in minor units (integer ≥ 0)
imageUrlstringNoProduct image URL (HTTPS)

Asking the buyer for more than a card

Three fields on the hosted checkout share one vocabulary:

ValueWhat the buyer sees
noneThe field is not shown.
autoShown, optional — the buyer may leave it blank.
requiredShown, and Pay stays disabled until it's filled in.
{
"amount": 4999,
"currency": "USD",
"shipping": "required",
"phone": "auto",
"billingAddress": "required"
}

Omitting a field is not the same as setting it to none. Omit it and the session inherits your account-level setting for that field, configurable in the dashboard. Out of the box those settings are none for phone and shipping (hidden unless you ask) and auto for billingAddress (shown but optional) — but if you have changed one, omitting the field gives you your setting, not the original default.

A value sent on the request applies to that one payment only and never changes the account setting. So use the field when a particular checkout needs something different, and the account setting when it is your normal policy.

Sent with a publishable key, a value normally can only make collection stricter, not looser. Your publishable key is readable by anyone who loads your checkout page, so a value weaker than your account setting is raised to that setting rather than applied; a stricter value is honoured exactly as sent. Secret-key callers are unaffected.

Treat that as a contract guard rather than a security boundary. The floor it clamps against is your account setting, read at session-create time — and if that read fails, it falls back to the built-in defaults instead of yours. In that window a publishable "none" for shipping or phone is honoured even if you configured "required". billingAddress falls back only as far as "auto", so a publishable "none" is still refused for that field. If a field genuinely must be collected, enforce it server-side too — do not rely on this clamp alone.

That is why the create response reports back what it did. The 201 returns shipping, phone and billingAddress carrying the mode actually applied — which differs from what you sent if you omitted the field (account default) or if a publishable-key value was raised. Read them rather than assuming your request was taken verbatim. Note these three are on the create response; a later GET /v1/sessions/{id} echoes only shipping.

What required actually guarantees

The page will not enable Pay without the field, AND our server re-checks immediately before the charge — so a page that misbehaves cannot let a payment through with it empty. It is not a defence against a deliberately modified client.

That distinction is worth stating plainly rather than rounding up to "unbypassable." The realistic thing that defeats a browser-side check isn't a hostile buyer typing their own phone number — it's our code: an input that fails to render, a stale cached page, a script error halfway down the form, an address layout behaving oddly in a country nobody tested. Every one of those sends blanks from a page that believes it's fine, and the server check is what catches them.

Both sides read the same rule, so they can't disagree with each other and strand a buyer at the last step with no explanation.

A typed phone is checked for shape, in every mode

required decides whether the buyer must answer. It does not decide whether an answer is usable — and an unusable phone blocks the sale whichever mode you are in.

An optional phone can still stop a buyer paying

If the buyer types a phone, it is checked for shape even under phone: "auto", where you only asked for it optionally. A value that fails the check hides the payment surface until they fix it — the same treatment a missing required field gets.

Leaving an optional phone empty blocks nothing, and never has. The blocking case is a phone that was answered with something unusable.

The rule is deliberately loose about punctuation and strict about digits:

Allowed charactersdigits, +, spaces, (, ), ., -
Digits requiredbetween 7 and 15, counting digits only

So (555) 123-1234, +44 20 7946 0958 and 555.123.1234 all pass — refusing those would lose sales over punctuation. What fails is a value that cannot be a phone number in any country: too few digits to be a subscriber number, more than the international numbering plan permits, or characters a phone number never contains.

Why it is enforced at all: the number travels to your connected store and becomes the contact a courier calls when they cannot find the address. A nonsense value there costs a delivery, not just a tidy record.

What this means for you. If you collect phones optionally and see buyers dropping at the payment step, an unusable phone is now a candidate cause. It is not silent — the page names the field — but it is new, and a buyer who typed their extension into the box will not connect the two.

An address counts only when it's complete

For shipping and billingAddress, required means a usable address, not a filled-in-looking one. A half-completed address is worse than a missing one: it looks answered, travels to your store, and gets discovered when nothing arrives.

Completeness is judged per country — see addresses and states, which uses the same rule the form renders against.

collectShipping and collectPhone are retired

These are refused, not ignored

Sending collectShipping or collectPhone now returns 400 validation_unknown_field. The session is not created.

If you have either in your code, swap it before you upgrade:

OldNew
"collectShipping": true"shipping": "auto"
"collectShipping": falseomit, or "shipping": "none"
"collectPhone": true"phone": "auto"
"collectPhone": falseomit, or "phone": "none"

true maps to auto, not required — the old booleans only ever showed a field, they never enforced one. Use "required" when you want the stronger behaviour that wasn't previously expressible.

They were replaced rather than kept alongside the new fields: collectPhone: false with phone: "required" would be expressible and meaningless, and two knobs for one idea is how the two answers drift apart.

Buyer identification

Pass these fields on every session so Von Payments can link a buyer's transactions across sessions, consolidate them, and help you troubleshoot (duplicate charges, disputes, "is this the same buyer?").

  • buyerId — your stable, unique-per-user account ID. Send the same value every time this buyer pays, across visits, devices, and cards (e.g. user_8f3a2b — a persistent ID from your users table). Do not send a per-visit, per-page-load, session, cart, or random value: a buyerId that changes per visit makes a returning buyer look brand-new and breaks duplicate-charge detection.
  • buyerEmail — the buyer's email. When provided, Von Payments links the buyer across sessions by email — a fallback identifier when buyerId varies or isn't set.
  • metadata — optional extra context as string key/values, e.g. { "app_user_id": "8f3a2b", "device_id": "d-1029" }.

Minimum for clean linking: send buyerId (stable) and buyerEmail together. If you're generating this call from an SDK or an AI agent, source buyerId from the authenticated user's persistent record ID — never a request-scoped or random token.

To manage the buyer record directly — richer profile fields (name, phone, company, address), lookup by your reference or email, and sparse updates — see the Buyers API. The nested buyer object on this call writes the same record.

Saved cards need a prior vault

Setting a buyer is what makes the embed show that buyer's saved cards — but a card only appears if the buyer vaulted one in a prior completed session (an embedded charge-and-save, or a setup flow, with a buyer on it). Identifying a brand-new customer doesn't conjure saved cards; it just makes the card they vault this time reusable next time. You don't pre-register buyers — the buyer record is created automatically the first time you reference one.

Contacting support about a transaction? Include the buyerId, the buyer's email, and the X-Request-Id from any API response — these let support correlate the transaction to the buyer instantly.

Response

{
"id": "vp_cs_live_k7x9m2n4p3q8r5t1",
"checkoutUrl": "https://checkout.vonpay.com/checkout?session=vp_cs_live_k7x9m2n4p3q8r5t1",
"expiresAt": "2026-03-31T15:30:00.000Z",
"integrationMode": "embed"
}

The POST /v1/sessions response returns the session id, the checkoutUrl to redirect the buyer to, the session expiry (expiresAt), and integrationMode (the effective integration mode for this session — the value you requested, or "embed" if an "elements" request fell back because your account doesn't yet support discrete rendering). Processor selection happens server-side inside Von Payments and is not exposed on the merchant API. See VORA — Payment Routing for why.

integrationMode is also an optional request-body field ("embed" | "elements", default "embed") — you choose it per session, and there is no account-level lock. If you request integrationMode: "elements" but your account doesn't yet support discrete rendering, the session silently falls back to "embed" (it never errors). The returned integrationMode is therefore the mode that was actually built, not necessarily the one you asked for — always read it back rather than assuming your request was honored. See the integrationMode request field above and Elements for what each mode renders.

The session id has the form vp_cs_{live|test}_{16-character id} — for example vp_cs_live_k7x9m2n4p3q8r5t1.

The checkoutUrl host depends on the session. Test-mode sessions (and live-mode sessions without a configured merchant slug) use the platform host https://checkout.vonpay.com/checkout?session=<sessionId>. Live-mode sessions with a merchant slug use the merchant subdomain https://<slug>.vonpay.com/checkout?session=<sessionId>. Always redirect the buyer to the exact checkoutUrl returned in the response rather than constructing it yourself.

Session Expiry

Sessions expire after the expiresIn window (default 30 minutes, up to a max of 7 days). If the buyer hasn't completed payment by then, the session status becomes expired and the checkout page shows an error.

Idempotency

Idempotency-Key is bound to the resulting checkout-session record, not to a separate cache with a TTL. A replayed key returns the same session for as long as the session record is retained on our side — which today is effectively the lifetime of the row. Use unique keys per attempt (e.g. order_123_attempt_1) so a retry with a different request body is surfaced as idempotency_replay_incompatible (HTTP 422) rather than silently merged.

successUrl / cancelUrl

successUrl and cancelUrl are validated for shape only — HTTPS scheme required (with localhost exempt for test-mode keys), max 2048 characters. Live-mode keys reject localhost/loopback redirect URLs. There is no dashboard allowlist of buyer-redirect URLs today. Any HTTPS URL is accepted on session create; for security-sensitive flows, terminate the redirect at a server-side endpoint you control and read the payment status from the API before trusting the buyer's browser state. See Handle the return.

Amount Format

Amounts are always in minor units (the smallest currency unit):

AmountCurrencyValue
1499USD$14.99
1000EUR10.00 EUR
999GBP9.99 GBP
100000JPY100,000 JPY (JPY has no minor unit)

Example: With Line Items

curl -X POST https://checkout.vonpay.com/v1/sessions \
-H "Authorization: Bearer vp_sk_live_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order_456_attempt_1" \
-d '{
"amount": 3298,
"currency": "USD",
"country": "US",
"successUrl": "https://mystore.com/order/456/confirm",
"cancelUrl": "https://mystore.com/cart",
"description": "Order #456",
"locale": "en",
"buyerId": "user_8f3a2b",
"buyerName": "Jane Doe",
"buyerEmail": "jane@example.com",
"lineItems": [
{ "name": "Wireless Headphones", "quantity": 1, "unitAmount": 2499 },
{ "name": "USB-C Cable", "quantity": 1, "unitAmount": 799 }
],
"metadata": { "orderId": "order_456" }
}'

Example: Simple Payment (No Items)

curl -X POST https://checkout.vonpay.com/v1/sessions \
-H "Authorization: Bearer vp_sk_live_xxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: simple_pay_001" \
-d '{
"amount": 5000,
"currency": "USD",
"country": "US",
"successUrl": "https://mystore.com/thank-you"
}'

If no lineItems are provided, the checkout page shows the total amount without an itemized breakdown.

Also recording the order in a connected store? Add order and mirrorTo to either request above and the captured payment creates the matching already-paid order in your Shopify or Next Commerce store. order.lineItems describes the purchase once and drives both the checkout page and the store order, so there is no second list to keep in step. See Describing the order for the fields and a complete request.

Validation Rules

  • amount is an integer in 099,999,999; for mode: "payment" (the default) it is required and must be ≥ 1, so payment-mode amounts are positive integers from 1 to 99,999,999. mode: "setup" may omit it.
  • currency must be exactly 3 characters (uppercased; not checked against an ISO 4217 allowlist)
  • country must be exactly 2 characters
  • successUrl and cancelUrl must be HTTPS (localhost exempt for test-mode keys only)
  • expiresIn must be between 300 and 604800 (seconds — 5 minutes to 7 days)
  • lineItems max 100 items
  • metadata values must be strings, max 500 characters each
  • phone, shipping and billingAddress must each be one of none, auto, required — defaults none, none and auto respectively; collectEmail is a boolean (default false)
  • collectShipping and collectPhone are retired. They are not ignored — the schema is strict, so a request still sending either is refused with 400 validation_unknown_field. See Asking the buyer for more than a card for the replacement.
  • Unknown / unrecognized fields are rejected (strict schema). Fields are camelCase; a snake_case key returns 400 validation_unknown_field with field suggestions. That code is not specific to this endpoint — every strict schema returns it, and its other common cause is a field that is real but belongs on a different call.

Errors

StatusErrorCause
400Validation error messageInvalid request body
401Authentication required / Authentication failedMissing or wrong Bearer token (codes auth_missing_bearer / auth_invalid_key)
403Sandbox merchants cannot create live payment sessionsSandbox key used to create a live session (auth_key_type_forbidden)
422Idempotency-Key replay body does not match the original request.Replayed Idempotency-Key with a changed body (idempotency_replay_incompatible)
429Too many requestsRate limited (rate_limit_exceeded; 10/min per IP on the sessions bucket)
500Internal errorServer error (internal_error)
501Card-on-file setup sessions are not yet available.mode: "setup" — card-on-file setup sessions are not yet available (endpoint_not_implemented)