Skip to main content

Charge at submit — CVV, AVS & 3-D Secure at the first charge

Charge at submit is the charge-and-save flow for elements mode. On the buyer's first charge one collection.submit() charges the card, verifies the CVV the buyer just typed, runs address verification against the billing address, hands off to the issuer's hosted 3-D Secure challenge when one is required, and — with a buyer on the session — vaults the card as a reusable vp_pmt_*.

A stored token has no CVV, so the default vault-then-charge path can never return a CVV result unless the buyer re-enters it; charging at submit keeps the buyer-present security code in the request that authorises the card. Charge at submit is enabled per session; CVV recovery, AVS and 3-D Secure activate at the account level with your gateway — confirm with your Vonpay contact before relying on the result codes in production.

Enable it on the session​

Create the session server-side with integrationMode: "elements" and chargeAtSubmit: true, and always set successUrl — it is where the issuer returns the buyer after a hosted 3-D Secure challenge (without one Vonpay uses its own hosted return page, so the buyer finishes on a Vonpay page instead of yours; cancelUrl is used when they abandon the challenge):

curl -X POST https://checkout.vonpay.com/v1/sessions \
-H "Authorization: Bearer $VON_PAY_SECRET_KEY" \
-H "Content-Type: application/json" \
-d '{
"amount": 4999,
"currency": "USD",
"buyerId": "buyer_123",
"integrationMode": "elements",
"chargeAtSubmit": true,
"successUrl": "https://your-store.example.com/checkout/return",
"cancelUrl": "https://your-store.example.com/checkout/cancelled"
}'

Return the session id and publishableKey to the browser, then mount and submit exactly as in custom layout — the session flag alone switches submit() to the charge path. A session created without chargeAtSubmit: true vaults the card and returns a token instead.

Read the result​

Charge at submit adds chargeStatus, redirectUrl and paymentIntentId to the SubmitResult. Branch error → chargeStatus → token → charged (charged: false beside chargeStatus: "succeeded" on a captureMethod: "manual" session: authorised, held, not yet taken; fulfil only on charged === true); on the requires_action arm nothing has been charged yet, and if your code only handles succeeded and pending a 3-D Secure card lands in neither — the buyer sees a spinner or a false success and the payment is silently abandoned:

const result = await collection.submit();

if (result.error) {
// Nothing was charged. Surface result.error.message (see "Errors" below).
} else if (result.chargeStatus === "requires_action") {
// 3-D Secure required. NOTHING has been charged yet. Send the buyer to the
// issuer's hosted challenge; they return to the session's successUrl and the
// charge.* webhook is the authoritative outcome.
window.location.href = result.redirectUrl;
} else if (result.chargeStatus === "pending") {
// Authorised but not yet settled — do NOT fulfil yet. Wait for the charge
// webhook, keyed on result.paymentIntentId. result.token may be absent here.
} else if (result.recovered) {
// Resolved by polling after a lost response. result.paymentIntentId is
// absent here (only result.transactionId). Never fulfil on result.charged
// for a recovered outcome; confirm the payment server-side first.
} else {
// chargeStatus === "succeeded": the charge completed. result.charged is true
// when money was captured, and FALSE on a session created with
// captureMethod: "manual": the card was authorised, the money is held, and
// nothing moves until you capture result.paymentIntentId server-side. Fulfil
// only on result.charged === true; treat an absent value as unknown.
// A buyer
// on the session also gets result.token (vp_pmt_*); a guest gets
// result.charged with no token.
}
Submit already charged — never charge the session twice

When charge at submit is on, submit() has already charged the card. Do not also call POST /v1/payment_intents for the same session — that double-charges the buyer. The client result is a UX signal; confirm settlement via the charge webhook before fulfilling, and treat chargeStatus: "pending" as not yet settled. For a hold (charged: false), take the outcome from the capture response, not a webhook.

Billing address for AVS​

AVS only runs when the charge carries a billing address. Mount an address element and it is forwarded automatically:

const address = collection.create("address", { mode: "billing" });
address.mount("#billing-address");
// …mount card fields…
const result = await collection.submit(); // the address rides along

Or pass it from your own form and skip the element:

const result = await collection.submit({
billingAddress: {
line1: "1600 Pennsylvania Ave NW",
city: "Washington",
state: "DC",
postalCode: "20500",
country: "US", // 2-letter ISO 3166-1 code
},
});

line1, postalCode and country are required and validated before any network call — a missing or malformed field returns frame_field_validation_failed and nothing is charged. An explicit submit({ billingAddress }) takes precedence over a mounted address element.

Read the address and security-code results on your server: avs_result_code and cvv_result_code on the charge.succeeded and charge.failed webhooks. The submit() result does not carry them: its avsResultCode and cvvResultCode fields are deprecated and always absent, and stay in the types so existing code compiles. A no_match is a signal for you to act on, not an automatic decline.

3-D Secure​

When a card requires 3-D Secure, the challenge runs on the issuer's hosted page and the buyer leaves your page for its duration (issuers increasingly refuse to render their authentication page inside another site's frame). submit() resolves with { chargeStatus: "requires_action", redirectUrl, last4, brand } and nothing has been charged. Send the buyer to redirectUrl; they authenticate, return to your successUrl, the charge settles, and the charge.succeeded / charge.failed webhook is the authoritative outcome — fulfil on that, not on the redirect. The return to successUrl is performed by the payment provider and Vonpay does not add or control its query string: treat it as "the buyer is back", and confirm the outcome from the webhook or a server-side read of the payment intent.

If 3-D Secure is required but no usable challenge URL can be produced, submit() fails closed with frame_3ds_required and nothing is charged — and re-running submit() will not help. From vora.js 1.39.0 error.reason says why; for try_another_payment_method, ask the buyer to pay another way (a card or method that does not need a bank check can succeed), and report the session id to support. The reasons are on Errors. The 3-D Secure guide covers sandbox test cards and the server-driven confirm path used by the tokenize-then-charge flow.

Getting the saved card back​

A card is vaulted when the charge completes, so what you get back depends on the arm:

chargeStatusresult.tokenresult.paymentIntentId
"succeeded"present, with a buyer on the sessionpresent
"pending"may be present (a buyer's card is vaulted at authorisation) — read it if it is, otherwise read it back once settled; it is not a settled chargepresent
"requires_action"nevernever

On requires_action you get neither — that is every 3-D Secure card, and it needs a recovery path.

Recovering the card after a 3-D Secure charge​

The charge.succeeded webhook is where the payment intent's id comes from — there is no list endpoint for payment intents and GET /v1/sessions/{id} does not return one.

  1. Receive charge.succeeded. Read payment_intent_id from it.
  2. Read the intent back from your server (neither the Node nor the Python SDK exposes a payment-intent read, so this is a direct REST call with your secret key):
curl https://checkout.vonpay.com/v1/payment_intents/$PAYMENT_INTENT_ID \
-H "Authorization: Bearer $VON_PAY_SECRET_KEY"
{
"id": "vpi_live_8rT3jQv5xY9mNa_pQ",
"status": "succeeded",
"payment_method_id": "vp_pmt_live_sLCHe8nrneFldDJF"
}
A null on the first read does not mean the card was not saved

The webhook can reach you before the vaulted card is attached to the intent, so status: "succeeded" with payment_method_id: null is a normal first read. Re-read with a short backoff. A null that persists on a succeeded payment that had a buyer attached is a support case — writing "no card on file" into your records re-prompts a buyer for a card they already gave you.

payment_method_id is legitimately null when the payment vaulted nothing — a guest payment, or one that never succeeded. A saved card outlives the payment that saved it, so it keeps being returned on later reads. The same handle appears as id on a POST /v1/tokens response, token on the charge-at-submit result, and payment_method_id on the payment intent; all three are what you pass as payment_method.id on POST /v1/payment_intents.

A vaulted card is single-use by default. Mounting the save-for-future-use element records two consents when the buyer ticks it, and both are write-once — a token vaulted without them cannot be upgraded (Tokenization → reusability):

FieldThe question it answers
setup_for_future_useMay you charge this card again? off_session makes the token usable for merchant-initiated charges (subscriptions, renewals); on_session permits reuse only while the buyer is present.
allow_redisplayMay the card be shown back to the buyer in a later checkout? always | limited | unspecified.

A ticked box vaults off_session with allow_redisplay: "always". When your label names one arrangement ("Save this card for my subscription"), pass displayScope: "limited" on the element so the card is not offered back on an unrelated purchase; an unticked box records unspecified. Both values come back on the result (setupForFutureUse, allowRedisplay) echoing what was persisted on the returned token — absent when nothing was vaulted. Saved cards are read server-side with GET /v1/payment_methods.

Errors​

All failures come back on result.error — the card is not charged when error is set:

CodeMeaning
frame_field_validation_failedA required field (including a supplied billing address) was missing or malformed. Fixed before any charge.
frame_payment_declinedThe card was authorised through 3-D Secure (or no 3DS was required) but the issuer/processor declined the charge.
frame_3ds_challenge_failedThe 3-D Secure challenge was rejected, or couldn't finish technically. Nothing charged. Not buyer cancellation — that has its own code.
frame_3ds_challenge_timeoutThe buyer ran out of time on the 3-D Secure challenge. Nothing charged.
frame_3ds_challenge_cancelledThe buyer closed the 3-D Secure challenge. Nothing charged, card is fine — an abandoned checkout, not a failure.
frame_3ds_required3-D Secure was required but no usable challenge URL could be produced, so the charge failed closed. Re-running submit() will not help. From vora.js 1.39.0 error.reason says why: for try_another_payment_method, ask the buyer to pay another way (a card or method that does not need a bank check can succeed), and report the session id to support; for not_supported_here, contact support with the session id.

A charge can also be rejected by the provider before any authentication is attempted: HTTP 422 provider_request_rejected (selfHeal.retryable: false, nextAction: "fix_request"). The card was neither charged nor declined, so a different card fails the same way — fix the request. The error reference has the complete code union.

If you change the session's total​

A charge-at-submit session is an elements session, so its total can be changed with PATCH /v1/sessions/{sessionId} (sessions.update() in the server SDKs). After changing it, call collection.fetchUpdates() and wait for it before the buyer pays (vora.js 1.36.0 and later):

// Your server has just changed the total.
const { amount, currency } = await collection.fetchUpdates();
showTotal(amount, currency); // show the buyer what they will pay

It re-reads the session and resolves with the total the checkout now holds. From then on, every card payment from this collection carries that total, and a payment whose total changed again without another fetchUpdates() is refused with frame_wallet_total_mismatch; the card is not charged. Show the new total and let the buyer pay again.

  • Calling submit() while fetchUpdates() is still reading is refused with frame_session_not_ready, and nothing is charged.
  • A failed fetchUpdates() leaves the previous total in force, so a later payment can only be refused, never charged at a total nobody saw.
  • It is opt-in. Until the first call, card payments are sent as before, and a total change is caught only if it happens while the charge request is being processed (409 expected_amount_mismatch, nothing charged).

A server that calls POST /v1/public/sessions/{sessionId}/charge directly sends the same check itself as expected_amount ({ amount, currency }, the total the buyer was shown).