3D Secure

3D Secure card authentication.

3D Secure adds an extra verification step for card payments, reducing fraud and enabling liability shift. It is a standalone step that runs against a card in the Paysio vault, so raw card data never touches your page. paysio.js provides a client-side threeDS API that handles the entire flow — initialization, device fingerprinting, challenge iframes, and polling.

Identifying the card

You can 3D-Secure a new card or a card already saved on file. Identify it with exactly one of these:

OptionUse it forWhere the value comes from
tokenA new card being entered nowpaysio.createToken()ptok_.... Not consumed by 3DS — charge it afterward as usual.
paymentMethodIdA card already saved on the customerGET /customers/:id/payment-methodsid (vault_… or the bare uuid). The same id you pass to POST /charges.
cardIdA Paysio vault card id you already holdServer-side vault integrations.

Raw PAN is not accepted by 3DS — tokenize first. Stripe workspaces on the Paysio vault (Stripe Connect or Paysio-provisioned accounts) use this API like every other gateway: the result is imported into the Stripe charge, so the liability shift applies. Stripe workspaces on their own API keys don't need it; Stripe runs 3DS itself during the charge.

You must send the 3DS result to the charge

Nothing is attached automatically: there is no server-side association between an authentication and a later charge. If you don't pass the 3DS result to POST /charges, the charge is authorized with no cryptogram, gets no liability shift, and the transaction records no 3DS data (the 3DS panel stays empty on the transaction). This applies identically to new cards and to saved cards.

Send the entire result. Don't cherry-pick fields. Pass the whole object through as three_ds and let Paysio decide what the acquirer needs — the required set differs by gateway and protocol version, and Paysio maps and forwards the right subset for whichever gateway routes the authorization. Dropping a field that looks unnecessary can silently cost you the liability shift on a gateway you didn't have in mind.

3DS on a saved card

  1. List the customer's cards: GET /customers/:id/payment-methods
  2. Run 3DS with threeDS.authenticate({ amount, paymentMethodId })
  3. Charge: POST /charges with { customer_id, payment_method_id, amount, three_ds }
  • The customer must have already used that payment method at your business. A card they saved at a different merchant can't be authenticated (or charged) with your API key — you get a 400.
  • Pass the same payment_method_id to /three-ds/authenticate and to /charges. Authenticating one card and charging another produces a mismatched cryptogram and no liability shift.
  • Merchant-initiated charges (renewals, retries, dunning) have no cardholder present to complete a challenge — 3DS is for customer-initiated payments where the buyer is on the page.

3DS on a new card

The recommended order is: tokenize, then 3DS with the token.

  1. Mount the secure card fields with paysio.mountCardInputs()
  2. Tokenize with paysio.createToken() → get a ptok_ payment token
  3. Run 3DS with threeDS.authenticate({ amount, token }) → get the 3DS result. The token is not consumed by 3DS.
  4. Send the token and the full 3DS result to your server to create the charge

How 3DS works

  1. Your page calls threeDS.authenticate() with the amount and a card reference (token, paymentMethodId, or cardId).
  2. Paysio initializes the authentication and renders the issuer's hidden device-fingerprint iframe.
  3. Paysio contacts the card issuer's 3DS server to verify the cardholder.
  4. Frictionless flow: If the issuer approves silently, you get back a terminal result immediately.
  5. Challenge flow: If the issuer requires cardholder interaction, an iframe is rendered in your specified container and Paysio polls until the result is terminal. If no container is supplied, paysio.js opens an automatic verification overlay.
  6. The Promise resolves with the full 3DS result (status, eci, authenticationValue, dsTransId, threeDSServerTransID, threeDsVersion) that you pass to the charge endpoint for liability shift.

Client-side: new card

JavaScript
const paysio = Paysio('pk_live_your_key');
await paysio.mountCardInputs('#card-fields');

// Step 1: Tokenize the card from the secure hosted fields
const { token } = await paysio.createToken(); // "ptok_..."

// Step 2: Run 3DS with the token — no raw card data on your page.
// The token is NOT consumed; you still charge it afterward.
const threeDS = paysio.threeDS();
let threeDsResult = null;
try {
  threeDsResult = await threeDS.authenticate({
    amount: 29.99,
    token: token,
    billing: {
      firstName: 'John',
      lastName: 'Doe',
      addressLine1: '123 Main St',
      city: 'New York',
      state: 'NY',
      postalCode: '10001',
      country: 'US',
    },
    iframeTarget: '#threeds-container', // Where to render challenge iframe
  });

  console.log('3DS Status:', threeDsResult.status); // 'Y' or 'A' = success
  console.log('ECI:', threeDsResult.eci);
  console.log('CAVV:', threeDsResult.authenticationValue);
} catch (err) {
  console.error('3DS failed:', err.message);
  // You may still proceed without 3DS (no liability shift)
}

// Step 3: Send token + the WHOLE 3DS result to your server
await fetch('/your-server/charge', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ token, threeDsResult }),
});

Client-side: saved card

Nothing to mount and nothing to tokenize — the vault already holds the card. Swap token for paymentMethodId and the rest of the flow is identical.

JavaScript
const paysio = Paysio('pk_live_your_key');

// Step 1: your server returns the customer's saved cards from
// GET /customers/:id/payment-methods; the buyer picks one.
const paymentMethodId = 'vault_2f6c…'; // the method's "id" — treat as opaque

// Step 2: Run 3DS against the SAVED card
const threeDS = paysio.threeDS();
let threeDsResult = null;
try {
  threeDsResult = await threeDS.authenticate({
    amount: 29.99,
    paymentMethodId: paymentMethodId,
    billing: { country: 'US' },   // same country you send on the charge
    iframeTarget: '#threeds-container',
  });
} catch (err) {
  console.error('3DS failed:', err.message);
}

// Step 3: Send the method id + the WHOLE 3DS result to your server
await fetch('/your-server/charge', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ paymentMethodId, threeDsResult }),
});

Server-side: pass the result to the charge

On your server, pass threeDsResult through as the three_ds field — no field mapping, nothing left behind:

JavaScript
// New card — your server receives { token, threeDsResult }
const charge = await fetch('https://paysio.com/api/v1/charges', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_live_your_secret_key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    payment_token: token,
    customer_id: customerId,    // optional
    amount: 2999,               // cents
    three_ds: threeDsResult,    // the FULL result, verbatim
  }),
});

// Saved card — your server receives { paymentMethodId, threeDsResult }
const charge = await fetch('https://paysio.com/api/v1/charges', {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer sk_live_your_secret_key',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    customer_id: customerId,
    payment_method_id: paymentMethodId,  // the card you authenticated
    amount: 2999,
    three_ds: threeDsResult,             // the FULL result, verbatim
  }),
});

HTML for the 3DS container

HTML
<!-- Hidden by default, shown when challenge iframe is needed -->
<div id="threeds-container" style="margin: 16px 0;"></div>

The challenge iframe renders at 100% width and 460px height. Style the container to fit your layout. The container is optional; without one, paysio.js opens an automatic verification overlay. For frictionless flows, nothing is rendered.

3DS result fields

Forward all of these to the charge. Paysio sends whatever the routed gateway requires; don't filter by gateway yourself.

status
Y = fully authenticated, A = attempted, N = denied, U = unavailable, R = rejected
eci
Electronic Commerce Indicator
authenticationValue
CAVV/AAV — cryptographic proof of authentication
dsTransId
Directory Server Transaction ID
threeDSServerTransID
3DS Server Transaction ID (XID) — also used for polling and result lookup
threeDsVersion
Protocol version, e.g. 2.2.0

Passing the object as three_ds sends all of them in one field. If you map them yourself to the flat three_ds_* fields, map every one — sending only a subset (for example eci + cavv without dsTransId) can leave the authorization without a usable authentication on some gateways.

Field formats and troubleshooting

The 3DS server is strict about types. Paysio normalizes the common pitfalls for you, but if you build the request yourself, match these:

  • amount is a number in major units (e.g. 29.99), not a string and not cents.
  • browser.browserJavaEnabled and browserJavaScriptEnabled are booleans (false, not "false"). The spec key is capital-S browserJavaScriptEnabled — we also accept the lowercase alias and coerce string booleans.
  • billing.country: send the ISO alpha-2 code (US) — we convert it to the numeric code the provider needs.
  • billing.state: use the ISO 3166-2 subdivision code (NY), not the full name.
  • Omit blank fields — an empty string (e.g. addressLine1: "") is a malformed value to the 3DS spec, not "unset," and triggers a format error. Paysio strips blank billing fields for you, so just leave out what you don't have.
  • redirect_url must be a public https URL. There is no default and localhost is rejected — an omitted or local URL returns a 400.

If the provider still rejects the request, authenticate returns a 400 with the provider's exact message (error + code: "three_ds_provider_error"). A poll response that contains an error field means authentication failed — stop polling and surface it.

API reference

The REST endpoints that the paysio.js threeDS helper calls under the hood. The SDK handles initialization, fingerprinting, the challenge iframe, and polling for you — call these directly only if you're building your own 3DS flow. Authenticate with a publishable key (the browser SDK uses pk_*) or a secret key. 3DS runs on Paysio Debit & Payouts / NMI workspaces; the Paysio vault and 3DS must be enabled for the workspace.

Every endpoint identifies the card the same way — with ONE of token, payment_method_id, or card_id — and you must use the same one across initialize, authenticate, and finish.

Initialize 3DS

Start the issuer device-fingerprint step. Returns the transaction_info you echo to /three-ds/authenticate, plus hidden-iframe HTML to render. Render device_fingerprint_html, wait about 3 seconds, then authenticate. paysio.js does this sequencing for you.

POSThttps://paysio.com/api/v1/three-ds/initialize

Authorizations

Authorizationstringheaderrequired

Publishable or secret key. Prepend your key with Bearer, e.g. Bearer sk_test_your_secret_key.

Body

tokenstring

A ptok_ payment token from paysio.createToken(). Provide one of token / payment_method_id / card_id

payment_method_idstring

A SAVED payment method id from GET /customers/:id/payment-methods ("vault_…" or the bare uuid)

card_idstring

A Paysio vault card id

Authenticate 3DS

Start a 3D Secure authentication against a vaulted card. Identify the card with a ptok_ token (a NEW card — resolved server-side and NOT consumed, so you can still charge it), a payment_method_id (a card already SAVED on the customer), or a card_id. Raw PAN is not accepted — tokenize first. Returns a terminal status for frictionless flows, or challenge data when the issuer requires it — then poll POST /three-ds/finish until the status becomes terminal. If the card's brand has no acquirer profile configured for your account, the endpoint returns { skipped: true, status: 'SKIPPED', reason: 'no_acquirer_profile' } instead of an error — proceed with the charge and omit three_ds. SKIPPED is unrelated to the workspace 3D Secure toggle: this endpoint authenticates whenever you call it.

POSThttps://paysio.com/api/v1/three-ds/authenticate

Authorizations

Authorizationstringheaderrequired

Publishable or secret key. Prepend your key with Bearer, e.g. Bearer sk_test_your_secret_key.

Body

amountnumberrequired

Charge amount in major units (e.g. 29.99), used for the 3DS risk assessment

redirect_urlstringrequired

PUBLIC challenge return URL that accepts the ACS POST. Alias: threeDSRequestorURL. There is no default and localhost is rejected — omitting it returns 400

browserobject

Device fingerprint collected in the browser: { browserUserAgent, browserLanguage, browserColorDepth, browserScreenHeight, browserScreenWidth, browserTZ, browserJavaEnabled, browserJavaScriptEnabled, ... }. The SDK builds this for you; defaults fill anything missing

tokenstring

A ptok_ payment token from paysio.createToken(). Resolves the card server-side; not consumed by 3DS. Provide one of token / payment_method_id / card_id

payment_method_idstring

A SAVED payment method id from GET /customers/:id/payment-methods ("vault_…" or the bare uuid) — this is how you 3D-Secure a card on file. The customer must already have used that method at your business, otherwise the call returns 400. Pass the SAME id to POST /charges

card_idstring

A Paysio vault card id

currencystring

ISO currency for the risk assessment (default "USD")

billingobject

Billing address: { country (ISO alpha-2), addressLine1, city, state, postalCode, ... }. Use the same country on the charge so 3DS and authorization select the same Smart Routing gateway/acquirer

billing_countrystring

ISO alpha-2 billing country alias. For a saved card, the address stored on the method is used when you omit this

transaction_infoobject

{ xid, merchantTransactionId } echoed from /three-ds/initialize. Omit it and the endpoint initializes internally (no separate fingerprint step)

Finish 3DS

Fetch the terminal result after a challenge. Once you have rendered the challenge iframe returned by /three-ds/authenticate, poll this (~every 3s) until the status is terminal (Y / A / N / U / R). Frictionless authentications are already terminal from /three-ds/authenticate and do not need this call.

POSThttps://paysio.com/api/v1/three-ds/finish

Authorizations

Authorizationstringheaderrequired

Publishable or secret key. Prepend your key with Bearer, e.g. Bearer sk_test_your_secret_key.

Body

token / card_id / payment_method_idstring

One of — the same card you authenticated (ptok_ token, card_id, or payment_method_id)

transaction_infoobjectrequired

{ xid, merchantTransactionId } echoed from /three-ds/authenticate

Once the status is terminal, forward the whole result to POST /charges — as three_ds, or by mapping every field to three_ds_status / three_ds_eci / three_ds_cavv / three_ds_directory_server_id / three_ds_xid / three_ds_version. A charge sent without them is authorized with no liability shift and stores no 3DS data.