3D Secure (3DS2)
3DS2 is the current industry standard for authenticating a cardholder during an online payment. Depending on the issuer's risk assessment, the customer either passes through invisibly (frictionless) or is prompted for a second factor, typically a one-time code or bank app approval (challenge).
Used correctly, 3DS2 shifts liability for fraudulent transactions to the card issuer. It's the single biggest lever most merchants have for reducing chargebacks.
Before you start
- Contact ArtsPay support to have 3DS2 enabled on your account. Nothing below works until this is done.
- Decide your integration path based on whether you already have an MPI (Merchant Plug-in) and what card data your own systems touch: see "Choosing your integration path" below.
- If you're integrating with the Hosted Payments Page, you must use fatzebra.js (
renderPaymentsPage), not the plain iframe-URL approach described in the Hosted Payment Pages guide. That simpler integration doesn't support 3DS2. - Create a one-off OAuth Client in the Merchant Dashboard, then generate a fresh access token per checkout session before initialising fatzebra.js: see "Getting an OAuth access token" below.
Choosing your integration path
The right approach depends on whether you already have an MPI (Merchant Plug-in) and what card data your own systems touch.
| You have | PCI level required | Path |
|---|---|---|
| Your own MPI already | Depends on your existing setup | Authenticate with your MPI, then pass the resulting 3DS fields into ArtsPay's Purchase/Refund extra payload (see below) |
| No MPI, ArtsPay's Hosted Payments Page | SAQ A | Path A: Hosted Payments Page |
| No MPI, your own payment form | SAQ A-EP | Path B: Custom payment form |
Getting an OAuth access token
Both paths below need a short-lived OAuth token before initialising fatzebra.js:
- In the Merchant Dashboard, go to Settings → OAuth Clients → Create new OAuth Client and download the
access_key/access_secret(a one-off download, so save them securely) - Exchange them for a token:
curl -X POST https://api.pmnts-sandbox.io/oauth/token \ -H "Content-Type: application/json" \ -d '{"access_key": "xxx", "access_secret": "xxx"}'// Response{ "message": "created", "data": { "token": "xxx" }}Store data.token from that response in the browser's localStorage under the key fz-access-token before initialising fatzebra.js
Tokens expire after 15 minutes, so create a fresh one per checkout session.
Path A: Hosted Payments Page
<script src="https://cdn.pmnts-sandbox.io/sdk/v1/fatzebra.js"></script><script>const fz = new FatZebra({ username: '<YOUR MERCHANT USERNAME>' })fz.on('fz.sca.error', (event) => { /* show an error, offer retry */ })fz.on('fz.payment.success', (event) => { /* checkout complete */ })fz.renderPaymentsPage({ containerId: 'fz-paynow', customer: { firstName: 'Captain', lastName: 'America', email: 'hello.world@example.com', address: '123 Australia Blvd.', city: 'Sydney', postcode: '2000', state: 'NSW', country: 'AU', }, paymentIntent: { payment: { amount: 500, currency: 'AUD', reference: 'ref_123490' }, verification: 'ver_123480', }, options: { hideLogos: true, enableSca: true },})</script>Setting options.enableSca: true is what turns the 3DS2 check on. Without it, the page behaves like a plain hosted payment page. When the customer submits, fatzebra.js tokenises the card, runs the 3DS2 check, then submits the purchase itself; you just react to the emitted events.
Path B: Custom payment form
If you collect card details on your own form, run the check explicitly with verifyCard:
fz.on('fz.sca.success', (event) => { // event.data contains cavv, sli, xid, ver, par, directoryServerTxnId, threedsVersion // send these to your backend to make the purchase call})fz.on('fz.sca.error', (event) => { // event.data.errorCode, show an error, offer retry})fz.verifyCard({ customer: { firstName: 'Captain', lastName: 'America', email: 'hello.world@example.com', address: '123 Australia Blvd.', city: 'Sydney', postcode: '2000', state: 'NSW', country: 'AU' }, paymentIntent: { payment: { amount: 1000, currency: 'AUD', reference: 'ref_123490' }, verification: 'ver_123480', }, paymentMethod: { type: 'card', data: { number: '4111111111111111', holder: 'John Doe', expiryMonth: '01', expiryYear: '2022', cvv: '123' }, },})The issuing bank then decides between a frictionless pass-through or a challenge (OTP) flow; fatzebra.js displays the challenge modal automatically if one is required, no extra code needed.
Passing the result into your purchase call
On fz.sca.success, send the 3DS result fields to your backend and include them under extra on the Purchase or Refund call (snake_case, not the camelCase used in the browser event):
{ "amount": 10, "currency": "AUD", "card_token": "syupxv5b1tqv4bqdsazr", "reference": "GfNStL8XJ1111", "customer_ip": "111.222.111.123", "extra": { "sli": "05", "cavv": "kAMRDwUADQVXiAPovxs+SQNhfGpb", "xid": "", "ver": "Y", "par": "Y", "directory_server_txn_id": "a8a35bcf-21d5-4f55-995a-a4b5e60d356d", "threeds_version": "2" }}Important: A successful 3DS check can only be used for one purchase. Run a fresh verifyCard for each payment attempt; you can't reuse the same result data across retries.
The 3D Secure extra fields (reference)
| Field | Description |
|---|---|
sli | Security Level Indicator (see table below) |
xid | Transaction ID from your 3DS provider (Base64, or 36-char string for Eftpos) |
cavv | Card authentication verification value (Base64) |
par | The PARes value from 3D Secure |
ver | The VERes value from 3D Secure |
threeds_version | e.g. 2 , 2.1 , 2.1.0 ; defaults to 1 if omitted |
directory_server_txn_id | Directory Server Transaction ID, usually a UUID |
| Visa & eftpos sli | Mastercard sli | Meaning |
|---|---|---|
05 | 02 | Authenticated: XID and CAVV present |
06 | 01 | Attempted: XID present, CAVV not present |
07 | 00 | Not authenticated: neither present (default) |
Full field reference: 3D Secure Card Payments.
Testing
| Scenario | Visa | Mastercard | Liability shift |
|---|---|---|---|
| Successful frictionless authentication | 4000000000001000 | 5200000000001005 | Yes |
| Successful step-up (OTP) authentication | 4000000000001091 | 5200000000001096 | Yes |
| Unsuccessful enrolment check | 4000000000002446 | 5200000000002037 | No |
| Unsuccessful frictionless authentication | 4000000000001018 | 5200000000001013 | No |
| Unsuccessful step-up authentication | 4000000000001109 | 5200000000001104 | No |
Full list including Amex numbers and error codes: 3DS Cards for Testing.
Note: Watch for ver: "N" in a successful response: it means the card isn't enrolled in 3DS at all. The check technically "succeeds" but there's no liability shift for that transaction.
FAQ
Do I need my own MPI?
No. If you don't have one, ArtsPay can act as your MPI. Contact support to have it enabled, then choose the Hosted Payments Page or custom-form path based on your PCI level.
What happens if the customer's issuer doesn't support 3DS2?
You'll usually see "attempts processing": the cardholder is enrolled but the issuer can't run the full check, so a stand-in authentication occurs. This still qualifies for liability shift.
What happens if the OTP challenge fails?
fz.sca.error fires with an error code, no liability shift applies, and you should prompt the customer to retry the check (which may result in a different frictionless/challenge outcome).
Can I reuse a successful 3DS check across multiple purchase attempts?
No. Each purchase you want liability-shifted needs its own fresh verifyCard result.
Which PCI SAQ level applies to my integration?
Hosted Payments Page keeps you at SAQ A. Building your own payment form (even calling fatzebra.js from it) puts you at SAQ A-EP, since your page is handling raw card fields before they're sent client-side. A direct backend API integration is SAQ D.