Handle 3D Secure

3D Secure is the network-level authentication protocol that lets card issuers verify the cardholder is present before a charge. Frame triggers it automatically when the rules require it; this guide covers the integration-side handling — how to surface the authentication UI to the customer through frame-js, react to the outcome, and verify the cryptogram landed for dispute defense.

The most important thing: you don't request 3DS explicitly. Frame's engine decides per-transaction. Your job is to handle the case where it triggers, not to trigger it yourself.

Prerequisites

RequirementDetails
Frame publishable keyFor frame-js initialization on the client. See Install.
Frame secret keyFor server-side charge creation.
frame-js loaded3DS UI rendering requires frame-js. Without it, you'd have to render the issuer's authentication iframe yourself.
Customer with a cardEither attached during onboarding or collected via frame-js's Card element.

1. Confirm the charge from frame-js

Your server creates the account and payment method, then mints a checkout session with the locked amount (POST /v1/checkout_sessions) and returns client_secret (chk_sess_…) to the browser. The browser creates the transfer and confirms it. confirmTransfer is where 3DS runs if Frame's engine decides it's needed.

const frame = await Frame.init('pk_sandbox_your_publishable_key');

const transfer = await frame.createTransfer(
  { source: { payment_method_id: paymentMethodId } },
  checkoutClientSecret,
);
const result = await frame.confirmTransfer(transfer.id, checkoutClientSecret);

if (!result.hasError && result.transfer?.payment?.status === 'succeeded') {
  showSuccess();
} else {
  showFailure(result.error?.message);
}

What happens inside confirmTransfer:

  • If Frame decides no 3DS is needed, the charge proceeds. The result's payment status is succeeded, or the result has an error for a non-3DS decline.
  • If Frame decides 3DS is needed, the payment status comes back requires_3d_secure with next_action.use_frame_sdk.source. The SDK opens the issuer's authentication UI. The customer completes the challenge. The SDK polls the transfer and resolves with the final status.

You don't branch on that in your code. confirmTransfer handles it. Check result.hasError and result.transfer.payment.status.

A V1 charge intent still confirms with frame.confirmCardPayment(clientSecret). That helper accepts only a ci_ secret. A Transfers V2 checkout secret (chk_sess_…) is not a charge-intent secret, so pass it to createTransfer and confirmTransfer.

2. Handle the result states

Three outcomes to wire for:

ResultWhat it meansCustomer-facing UX
succeeded3DS either wasn't needed or completed; charge cleared.Standard success path. Show order confirmation.
failed with failure_code: authentication_required3DS was triggered but didn't complete (customer abandoned the challenge, timeout, network failure)."Authentication wasn't completed — please try again." Allow a retry.
failed with failure_code: <other>3DS may have completed but the charge declined for a different reason (e.g., insufficient_funds).Surface per the decline codes guidance for that specific code.

If failure_code is fraudulent, lost_card, stolen_card, or any risk-category code, surface as a generic error — don't disclose the underlying reason. See decline codes for the customer-facing display guidance.

3. Verify the cryptogram landed (optional)

When 3DS succeeds, Frame stores the 3DS cryptogram on the payment method. You don't need to do anything with it directly — it's automatically attached to future dispute evidence — but you can verify it's there by retrieving the payment method:

curl --request GET \
  --url https://api.framepayments.com/v1/payment_methods/<payment_method_id> \
  --header "Authorization: Bearer $FRAME_SECRET_KEY"

A cryptogram-bearing payment method is the most important asset for dispute defense on fraud-coded chargebacks — issuer-authenticated transactions shift liability for fraud disputes to the issuer.

If you want to maximize cryptogram coverage on a customer's card, consider running card verification when the card is first attached — that flow runs 3DS at link time and stores the cryptogram, defending all future charges on the card even when those charges don't run 3DS themselves.

Testing in sandbox

Use Frame's 3DS test cards to verify each outcome. Pair any of these with the sandbox key:

CardBehavior
40000000000032203DS required; authentication must succeed
41111101166388703DS required; frictionless (no customer prompt)
41111117389736953DS frictionless then declines with card_not_enrolled
40000084000016293DS required then declines with generic_decline
4111110148486413Visa not enrolled in 3DS; authentication fails with card_not_enrolled, no challenge shown
5555558824815612Mastercard not enrolled in 3DS; authentication fails with card_not_enrolled, no challenge shown

Run each card through your checkout flow. Confirm your application handles success, in-flight authentication, frictionless authentication, and post-authentication declines correctly.

Common variations

Off-session charges (subscription renewals). 3DS typically doesn't trigger on off-session payments because the cardholder isn't there to authenticate. If you run card verification at signup, the stored cryptogram defends the off-session charges even though they don't run 3DS at charge time.

Custom UI without frame-js. If you must render the authentication UI yourself, Frame returns the ACS URL and challenge parameters on the charge intent. You'd render those in an iframe or popup, then notify Frame when the customer completes. This is significantly more work than using frame-js's built-in modal — only do it if you have a compelling UX reason.

Regulatory mandates. Some jurisdictions (PSD2/SCA in EU/UK, parts of LATAM and India) require Strong Customer Authentication on many transactions. 3DS is how Frame meets those requirements; the engine triggers it automatically for transactions in those jurisdictions. You don't have to opt in.

Gotchas

Symptom: confirmTransfer hangs and never resolves. Why: the customer started a 3DS challenge and didn't complete it — they closed the tab, walked away, or hit a popup blocker. Fix: surface a timeout in your UI after a reasonable window (~5 minutes is typical). Cancel the transfer on your server if you want to free up the payment method for retry.

Symptom: 3DS challenge appears for every transaction during testing, but you expected only some. Why: you're using a test card configured to always require 3DS (like 4000000000003220). Fix: mix in a non-3DS-required test card (e.g., 4242424242424242) to verify your handling of both paths.

Symptom: the customer authenticated, the charge succeeded, but a later dispute on the same card still went against you. Why: the cryptogram defends fraud-category disputes, not other categories. A product_not_received dispute won't be defended by a stored cryptogram regardless of how well-authenticated the original charge was. Fix: track dispute outcomes by reason category to know what your defense strategies are actually winning on.

Next steps

Frame Assistant

Ask anything about Frame's APIs and products