Skip to main content

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

Charge at submit is a variant of the charge-and-save flow for elements mode. On the buyer's first charge, one collection.submit() does four things in a single call:

  • charges the card for the session amount,
  • verifies the CVV the buyer just typed (returns a cvvResultCode),
  • runs Address Verification against the billing address (returns an avsResultCode), and
  • hands off to the card issuer's hosted 3-D Secure challenge when it's required — submit() returns chargeStatus: "requires_action" with a redirectUrl and you send the buyer there (see 3-D Secure) —
  • then vaults the card as a reusable vp_pmt_* (when a buyer is on the session).
3-D Secure requires one branch in your code

When a card needs 3-D Secure the charge does not complete inside submit(). You must handle a chargeStatus: "requires_action" result by redirecting the buyer to result.redirectUrl, and create the session with a successUrl so they come back to your page. Requires vora.js ≥ 1.17.0.

Why a dedicated flow: the default vault-then-charge path stores the card first and charges the stored token later. A stored token has no CVV — the security code can never be stored — so vault-then-charge can't return a CVV result. Charging at submit keeps the buyer-present CVV in the same request that authorises the card, so you get the CVV and AVS signals on the transaction that actually moves the money.

Availability

Charge at submit is enabled per session and, for CVV recovery, per account. 3-D Secure and AVS activate at the account level with your gateway. If a session wasn't created for charge-at-submit, submit() uses the standard vault-then-charge path and returns no cvvResultCode / chargeStatus. Ask your Vonpay contact to confirm it's enabled for your account before you rely on it in production.

Enable it on the session

Charge at submit is a session-level opt-in. Create the session server-side (with your secret key) using integrationMode: "elements" and chargeAtSubmit: true:

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 the discrete card fields and submit exactly as in custom layout (elements mode). Nothing changes about how you mount the fields — the session flag alone switches submit() to the charge path.

Always send successUrl

successUrl is where the card issuer returns the buyer after a hosted 3-D Secure challenge, so a charge-at-submit session should always carry one. If the session has no successUrl, Vonpay falls back to its own hosted return page so the payment still completes — but the buyer finishes on a Vonpay page instead of yours. cancelUrl is used when they abandon the challenge.

A session created with mode: "setup" (vault-only) or without chargeAtSubmit: true will not charge at submit; submit() vaults the card and returns a token instead.

Read the result

Charge at submit adds five fields to SubmitResult:

interface SubmitResult {
// ── standard fields ──
token?: string; // vp_pmt_* — a reusable method was vaulted. Requires a buyer on the
// session AND a completed charge: absent on requires_action/pending
// (see "Getting the saved card back" below)
charged?: true; // the card was charged
last4?: string; // display-only
brand?: string; // "visa" | "mastercard" | … (display-only)
error?: VoraMirrorError; // present iff submit failed

// ── charge-at-submit fields ──
chargeStatus?: "succeeded" | "pending" | "requires_action";
redirectUrl?: string; // present iff chargeStatus === "requires_action" —
// the hosted 3-D Secure challenge (vora.js ≥ 1.17.0)
avsResultCode?: AvsResultCode; // address verification outcome (nullable)
cvvResultCode?: CvvResultCode; // security-code outcome (nullable)
paymentIntentId?: string; // reconcile against the charge.* webhook
}

Discriminate in the same order as every other submit — errortokencharged — then read chargeStatus. Handle requires_action before assuming a charge happened, because on that arm nothing has been charged yet:

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,
// the charge settles, and the charge.* webhook is the authoritative outcome.
window.location.href = result.redirectUrl;
return;
} else {
// The card was charged.
if (result.chargeStatus === "succeeded") {
// Money captured. result.avsResultCode / result.cvvResultCode carry the verification.
// A buyer on the session also gets result.token (vp_pmt_*); a guest gets
// result.charged with no token.
} else if (result.chargeStatus === "pending") {
// Authorised but not yet settled — do NOT fulfil yet. Wait for the charge webhook,
// keyed on result.paymentIntentId. The card is vaulted at settlement, so
// result.token is absent here even with a buyer on the session.
}
}
Don't let requires_action fall through

If your branching only handles succeeded and pending, a 3-D Secure card lands in neither — the buyer is never redirected, sees a spinner or a false success, and the payment is silently abandoned. Handle requires_action explicitly.

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 server-side via the charge webhook before fulfilling, and treat chargeStatus: "pending" as not yet settled.

Billing address for AVS

AVS only runs when the charge carries a billing address. You can supply it two ways:

Mount an address element — the address element collects the billing address in-form and forwards it 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 — supply a billingAddress to submit() and skip the element entirely:

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. Unlike the card security code, the billing address is ordinary data — it may come from your own form.

The result codes are gateway-independent:

avsResultCodeMeaning
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/gateway didn't return a result
cvvResultCodeMeaning
matchSecurity code matched
no_matchSecurity code did not match
not_provided / unavailableNo result returned

Both are nullable. You own the decision — a no_match is a signal, not an automatic decline; decide whether to fulfil, review, or refund based on your own risk policy.

3-D Secure

When a card requires 3-D Secure, the challenge runs on the card issuer's own hosted pagesubmit() does not complete it for you, and the buyer does leave your page for the duration of the challenge. This is deliberate: issuers increasingly refuse to render their authentication page inside another site's frame, so a hosted redirect is the path that reliably completes.

submit() resolves with:

{ chargeStatus: "requires_action", redirectUrl: "https://…", last4, brand }

Nothing has been charged at this point. Send the buyer to redirectUrl:

if (result.chargeStatus === "requires_action") {
window.location.href = result.redirectUrl;
return;
}

What happens next:

  1. The buyer authenticates on the issuer's page.
  2. They return to the successUrl you set on the session (or Vonpay's hosted return page if you didn't set one).
  3. The charge settles.
  4. The charge.succeeded / charge.failed webhook is the authoritative outcome — fulfil on that, not on the redirect.
Don't trust the return URL's query parameters

The redirect back to your successUrl is performed by the payment provider, and Vonpay does not add or control its query string. Treat the return as a UX signal only ("the buyer is back — show a spinner"), and confirm the real outcome from the charge.* 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. That is a configuration fault, not a card fault — a different card fails identically. The usual cause is a session created without a successUrl.

See the 3-D Secure guide for the full model, 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 which arm submit() returned on:

chargeStatusresult.tokenresult.paymentIntentId
"succeeded"present, with a buyer on the sessionpresent
"pending"may be present — read it if it ispresent
"requires_action"nevernever

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

Recovering the card after a 3-D Secure charge

The buyer returns to your successUrl, the charge settles, and the charge.succeeded webhook is the authoritative outcome — it is also 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, so the webhook is the only route.

  1. Receive charge.succeeded. Read payment_intent_id from it.
  2. Read the intent back from your server:
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"
}

Neither the Node nor the Python SDK exposes a read for payment intents today, so this is a direct REST call. It needs your secret key — make it from your server, never the browser.

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 on the asynchronous path. Re-read with a short backoff before concluding anything.

Treat a null that persists on a succeeded payment that had a buyer attached as a support case, not as "this buyer has no card on file" — writing that conclusion 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 with no buyer attached, or one that never succeeded. Unlike next_action, it is not tied to the payment's status: a saved card outlives the payment that saved it, so it keeps being returned on later reads.

The same handle appears under three names depending on where you meet it — 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 back as payment_method.id on POST /v1/payment_intents.

Saving a card is not the same as being allowed to charge it again

A vaulted card is single-use by default. To charge it later without the buyer present (subscriptions, renewals), the buyer must consent at the time the card is first vaulted — mount the save-for-future-use element, which vaults the card off_session. Consent is write-once: a token vaulted without it returns payment_method_consent_missing on the later charge and cannot be upgraded. See Tokenization → reusability.

Errors

All failures come back on result.error — the card is not charged when error is set. The codes you'll handle on this path:

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. A configuration fault, not a card fault — a different card fails identically, and re-running submit() will not help. Check the session was created with a valid successUrl.

A charge can also be rejected by the provider before any authentication is attempted. That returns HTTP 422 provider_request_rejected from the charge endpoint (selfHeal.retryable: false, nextAction: "fix_request") — the card was neither charged nor declined, so sending the buyer for a different card will fail the same way. On this path the usual cause is a missing 3-D Secure return target; see provider_request_rejected.

See the full error reference for the complete code union and handling guidance.