3DS

3DS API Developer Guide

Accept secure card payments with EMV 3D Secure (3DS) authentication, helping merchants meet AusPayNet CNP Fraud Mitigation Framework requirements


Overview: when you need 3DS

3D Secure 2 (3DS) adds cardholder authentication to card-not-present payments. The card issuer assesses the transaction and either approves it silently (frictionless) or asks the cardholder to verify themselves (challenge). When authentication succeeds, liability for a fraud-related chargeback shifts from merchant to the issuer.

Why you would turn this on

In Australia, 3DS is how you meet the card scheme requirements for cardholder authentication and how you keep your card-not-present fraud rate inside the thresholds. Exceeding those thresholds puts you into a remediation process with the acquirer. 3DS is the primary control.


How it works

End-to-end sequence


The one rule that matters

onComplete and onError are frontend signals, not payment outcomes.

onComplete fires when the authentication flow finished. The payment can still be declined afterwards for insufficient funds, a velocity rule, or any other authorisation reason.
onError fires when the browser flow broke, which can include a network drop on a payment that succeeded server-side.

Treat both callbacks as "stop showing the spinner and go ask the backend". The authoritative outcome comes from webhooks, described in section 5.


Before you start

You need all of these before writing any code.

RequirementHow to get itReference
A Zai account with 3DS enabledContact your Zai representative. It is not self-serve.—
OAuth2 client credentialsDashboardAuthentication
Sandbox accessdashboard.hellozai.com—
Webhooks configured for itemsPOST /callbacksWebhooks
Buyer and seller users (or create them in step 1)POST /usersCards Payin Workflow
Cards tokenised through Hosted Fieldsassembly.jsIntegrating Hosted Fields

Tokenise cards

Capture card details with Hosted Fields or the Hosted Form. Both keep the card number out of your systems and reduce your PCI DSS scope to SAQ A or SAQ A-EP.

Posting a raw PAN to POST /users/{id}/card_accounts is possible but puts you in full PCI DSS scope. Do not build a new integration that way.

Content Security Policy

If your checkout sets a CSP header, add these before you start testing. Getting this wrong is the most common cause of "the iframe never appears", and it typically only surfaces in your production build.

Content-Security-Policy:
  frame-src   https://*.assemblypay.com https://*.cardinalcommerce.com;
  script-src  https://*.assemblypay.com;
  connect-src https://*.assemblypay.com https://*.promisepay.com;

Integrate in six steps

This section is one working example. Every value it uses is either returned by a previous step or listed in Testing.

The six steps fall into two halves. Steps 1 to 3 are ordinary Zai objects and are the same whether or not you use 3DS, so if you are already taking card payments you have them. Steps 4 to 6 are the 3DS part.

Step 1: Create the buyer and seller users

Every Zai payment moves funds from a buyer to a seller, so both need to exist before you can create an item. If your users already exist, skip to step 2 and use their IDs.

# Buyer
curl -X POST https://test.api.promisepay.com/users \
  -H "Authorization: Bearer {oauth_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "buyer_123",
    "first_name": "John",
    "last_name": "Doe",
    "email": "[email protected]",
    "country": "AUS"
  }'
# Seller
curl -X POST https://test.api.promisepay.com/users \
  -H "Authorization: Bearer {oauth_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "seller_456",
    "first_name": "Jane",
    "last_name": "Smith",
    "email": "[email protected]",
    "country": "AUS"
  }'

Step 2: Create the item

The item is the container that moves funds between the two users. It is what you call make_payment_async against in step 4.

curl -X POST https://test.api.promisepay.com/items \
  -H "Authorization: Bearer {oauth_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "id": "item_789",
    "name": "Product purchase",
    "amount": 5000,
    "payment_type": 2,
    "buyer_id": "buyer_123",
    "seller_id": "seller_456",
    "description": "3DS test payment",
    "currency": "AUD"
  }'

Step 3: Tokenise the card in the browser

<script src="https://hosted-fields.assemblypay.com/assembly.js"></script>
<script>
  const hostedFields = assembly.hostedFields({ environment: "pre-live" });
  hostedFields.createCardAccount({
    token: "HOSTED_FIELDS_TOKEN",
    user_id: "buyer_123"
  })
  .then(function (response) {
    // Send the card account id to your backend. Never send it straight to make_payment_async
    // from the browser: that call needs your OAuth token, which must stay server-side.
    return fetch("/api/pay", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ card_account_id: response.card_accounts.id })
    });
  })
  .catch(function (error) {
    showCardError(error);
  });
</script>

Step 4: Start the payment from your backend

Steps 4 to 6 are the 3DS flow. Everything above this line is standard Zai.
curl -X PATCH https://test.api.promisepay.com/items/item_789/make_payment_async \
  -H "Authorization: Bearer {oauth_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "account_id": "725cc8c0-759b-0138-5d6d-0a58a9feac05",
    "request_three_d_secure": "challenge"

The response contains the item object. Only two fields matter here:

{
  "payment_id": "1000014012533486",
  "payment_token": "sample-token-1234567890",
  "account_id": "725cc8c0-759b-0138-5d6d-0a58a9feac05",
  "items": { "...": "full item object, see the API reference" }
}
FieldWhat it isWhere it goes
payment_idIdentifier for this payment attemptPassed to the SDK as paymentId
payment_tokenShort-lived token scoped to this one payment's statusPassed to the SDK as authToken

Step 5: Run the 3DS flow in the browser

<div id="threeds-container"></div>
<script src="https://hosted-fields.assemblypay.com/assembly-3ds.js"></script>
<script>
  new assembly3DS().initialize({
    authToken: paymentToken,        // payment_token from step 4
    paymentId: paymentId,           // payment_id from step 4
    env: "pre-live",                // "production" in production
    targetElementId: "threeds-container",
    onComplete: function () {
      // Authentication finished. This is NOT the payment outcome.
      confirmWithBackend(paymentId);
    },
    onError: function (error) {
      // The browser flow broke. The payment may still have succeeded server-side.
      console.error("3DS error:", error.message);
      confirmWithBackend(paymentId);
    }
  });
</script>

targetElementId must reference an element that already exists in the DOM. The SDK injects a hidden iframe for device data collection and, if a challenge is triggered, a visible iframe rendered by the issuer. Give the container enough room: 400px by 400px is a reasonable minimum, and it must be usable on mobile.

Step 6. Determining the payment outcome

Webhooks are the source of truth

Register callbacks before you take your first payment:

curl -X POST https://test.api.promisepay.com/callbacks \
  -H "Authorization: Bearer {oauth_token}" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://your-backend.example.com/webhooks/zai/items", "object_type": "items", "enabled": true }'

Webhooks are not ordered. Compare the payload's updated_at against what you have stored and discard anything older. Make your handler idempotent: the same event can arrive more than once.

Sample Webhooks Responses:

Below are sample webhook payloads for both successful payment events and scheme card declines following a 3DS check:

Successful Payment:

Once a successful payment is done for a transaction after 3DS check, the below webhooks will be received by the customer.

{
            "uuid": "29eade14-2a8f-4e9d-a4ef-c5bc5a9a4b47",
            "hashed_payload": "5cfdac10fb20718b7da68de5f693d060ed5232be58bd9d543f2dbcde8ad7dc9a",
            "webhook_uuid": "924069f1-2cbd-4ed0-a910-96b9c8df15ce",
            "payload": {
                "items": {
                    "amount": 10250,
                    "batch_state": "successful",
                    "buyer_country": "AUS",
                    "buyer_email": "[email protected]",
                    "buyer_fees": 0,
                    "buyer_name": "Testbed Buyer",
                    "buyer_url": "",
                    "chargedback_amount": 0,
                    "created_at": "2026-10-13T00:25:02.082Z",
                    "credit_card_fee": 0,
                    "currency": "AUD",
                    "custom_descriptor": null,
                    "deposit_reference": "100014244017164",
                    "description": null,
                    "direct_debit_fee": 0,
                    "due_date": null,
                    "dynamic_descriptor": "ASM*QA WP Prelive",
                    "id": "tb-item-mv5xv84y-9748",
                    "invoice_url": null,
                    "links": {
                        "batch_transactions": "/items/tb-item-mv5xv84y-9748/batch_transactions",
                        "bpay_details": "/items/tb-item-mv5xv84y-9748/bpay_details",
                        "buyers": "/items/tb-item-mv5xv84y-9748/buyers",
                        "fees": "/items/tb-item-mv5xv84y-9748/fees",
                        "self": "/items/tb-item-mv5xv84y-9748",
                        "sellers": "/items/tb-item-mv5xv84y-9748/sellers",
                        "status": "/items/tb-item-mv5xv84y-9748/status",
                        "transactions": "/items/tb-item-mv5xv84y-9748/transactions",
                        "wire_details": "/items/tb-item-mv5xv84y-9748/wire_details"
                    },
                    "name": "Testbed item tb-item-mv5xv84y-9748",
                    "net_amount": 10250,
                    "payment_credit_card_enabled": true,
                    "payment_direct_debit_enabled": true,
                    "payment_method": "credit card",
                    "payment_type_id": 2,
                    "payments": [
                        {
                            "account_id": "cdbbb403-2667-43f2-949d-389e6ad08d2f",
                            "amount": 10250,
                            "created_at": "2026-10-13T00:25:06.687Z",
                            "payment_id": "ab6bf844-bf63-4c25-bf32-e4dd270bb55d",
                            "status": "completed"
                        }
                    ],
                    "payout_descriptor": null,
                    "paypal_fee": 0,
                    "pending_release_amount": 0,
                    "promisepay_fee": 388,
                    "refunded_amount": 0,
                    "related": {
                        "buyers": "tb-buyer-mv5xv84y",
                        "sellers": "tb-seller-mv5xv84y"
                    },
                    "released_amount": 10250,
                    "remaining_amount": 0,
                    "requested_release_amount": 0,
                    "seller_country": "AUS",
                    "seller_email": "[email protected]",
                    "seller_fees": 0,
                    "seller_name": "Testbed Seller",
                    "seller_url": "",
                    "state": "completed",
                    "status": 22500,
                    "total_amount": 10250,
                    "total_outstanding": 0,
                    "updated_at": "2026-10-13T00:26:23.713Z"
                }
            },
            "object_type": "items",
            "request_responses": [
                {
                    "response_code": 404,
                    "message": "{\"success\":false,\"error\":{\"message\":\"Token \\\"3d8f1a69-4cb3-450d-bc0d-e7ea5b596d49\\\" not found\",\"id\":\"\"}}",
                    "created_at": "2026-10-13T00:26:27.274755574Z"
                }
            ],
            "updated_at": "2026-10-13T00:26:27.274854711Z",
            "created_at": "2026-10-13T00:26:23.713217Z",
            "object_id": "tb-item-mv5xv84y-9748"
        },

Payment Declines:

Card decline errors are not returned synchronously in the API response or displayed to end users during the 3DS flow. If a payment is declined by the card scheme during post-authentication processing, failure details are delivered asynchronously via webhooks.

{
    "uuid": "f2f34979-0378-4e40-860e-782b83043c89",
    "hashed_payload": "594fa5e69142866ec21760eb6d8a62c584343dc4ded2b858d644a617aadbec86",
    "webhook_uuid": "aecaf4ae-eeae-4e8d-ab52-8e951c8fb70e",
    "payload": {
        "items": {
            "amount": 100000,
            "batch_state": "cancelled",
            "buyer_country": "AUS",
            "buyer_email": "[email protected]",
            "buyer_fees": 0,
            "buyer_name": "Prod Validation",
            "buyer_url": "",
            "chargedback_amount": 0,
            "created_at": "2026-10-05T23:49:40.291Z",
            "credit_card_fee": 0,
            "currency": "AUD",
            "custom_descriptor": null,
            "deposit_reference": "100014998591919",
            "description": null,
            "direct_debit_fee": 0,
            "due_date": null,
            "dynamic_descriptor": "100014998591919",
            "id": "prodtest-muvwimul-6143",
            "invoice_url": null,
            "links": {
                "batch_transactions": "/items/prodtest-muvwimul-6143/batch_transactions",
                "bpay_details": "/items/prodtest-muvwimul-6143/bpay_details",
                "buyers": "/items/prodtest-muvwimul-6143/buyers",
                "fees": "/items/prodtest-muvwimul-6143/fees",
                "self": "/items/prodtest-muvwimul-6143",
                "sellers": "/items/prodtest-muvwimul-6143/sellers",
                "status": "/items/prodtest-muvwimul-6143/status",
                "transactions": "/items/prodtest-muvwimul-6143/transactions",
                "wire_details": "/items/prodtest-muvwimul-6143/wire_details"
            },
            "name": "3DS production validation prodtest-muvwimul-6143",
            "net_amount": 100000,
            "payment_credit_card_enabled": true,
            "payment_direct_debit_enabled": true,
            "payment_method": "pending",
            "payment_type_id": 2,
            "payments": [
                {
                    "account_id": "53bf9471-fbcd-409a-8ec7-2447574480ea",
                    "amount": 100000,
                    "created_at": "2026-10-05T23:49:44.226Z",
                    "errors": {
                        "base": [
                            "Credit card payment failed: Insufficient Funds"
                        ],
                        "provider_response_code": [
                            "51"
                        ],
                        "provider_response_message": [
                            "Insufficient funds"
                        ]
                    },
                    "payment_id": "6ce65ece-739e-45e4-ab7e-4596f6105343",
                    "status": "payment_failed"
                }
            ],
            "payout_descriptor": null,
            "paypal_fee": 0,
            "pending_release_amount": 0,
            "promisepay_fee": 0,
            "refunded_amount": 0,
            "related": {
                "buyers": "prodtest-buyer-muvwimul",
                "sellers": "prodtest-seller-muvwimul"
            },
            "released_amount": 0,
            "remaining_amount": 100000,
            "requested_release_amount": 0,
            "seller_country": "AUS",
            "seller_email": "[email protected]",
            "seller_fees": 0,
            "seller_name": "Prod Seller",
            "seller_url": "",
            "state": "pending",
            "status": 22000,
            "total_amount": 100000,
            "total_outstanding": 100000,
            "updated_at": "2026-10-05T23:50:03.513Z"
        }
    },

For a full list of possible decline codes and their descriptions, refer to the Card Decline Errors page


Testing

Test cards

ScenarioVisaMastercardAmex
Frictionless success40000000000027015200000000002235340000000002708
Frictionless failure40000000000029255200000000002276340000000002096
Challenge40000000000025035200000000002151340000000002534
Challenge Failed40000000000023705200000000002490340000000002237
Attempts stand-in40000000000027195200000000002482340000000002872
Authentication unavailable40000000000023135200000000002268340000000002922
Rejected40000000000025375200000000002185340000000002062
Not available on lookup40000000000029905200000000002409340000000002468
Error on lookup40000000000024465200000000002037340000000002732
Timeout on lookup40000000000023545200000000002326340000000002047

Test plan

  1. Frictionless success end to end, including both webhooks landing.
  2. Forced challenge, passed, on desktop and on a real mobile browser.
  3. Forced challenge, abandoned. Verify your timeout handling and that you confirm server-side before showing failure.
  4. Each remaining test card, asserting against the scenario.
  5. Webhook failure and replay. Kill your endpoint, confirm retry behaviour, confirm idempotency.
  6. Out-of-order webhooks. Confirm your updated_at comparison holds.
  7. CSP verification against your production CSP header, not a relaxed dev one.
  8. Retry after failure.

Going live

Testing & Validation

  • All test scenarios validated in sandbox
  • Error handling implemented for all scenarios
  • Webhook endpoint tested and secured
  • UI/UX optimized for challenge flows
  • Mobile browser compatibility verified
  • Timeout handling tested

Monitoring & Operations

  • Logging implemented for debugging
  • Error tracking and alerting configured
  • Payment success/failure rates monitored
  • Customer support processes updated
  • Documentation and runbooks created

Security & Compliance

  • PCI DSS requirements reviewed
  • Security headers configured (CSP, HTTPS)
  • Data retention policies defined
  • Incident response procedures documented


Support


Getting Help


Troubleshooting

The iframe never appears. Almost always CSP. Check the browser console for a blocked frame and compare against section 3. Second most likely cause: targetElementId points at an element that does not exist yet.

Authentication always times out. Check payment_token has not expired and that connect-src allows the Zai API host.

Challenge does not work on mobile. Ensure is set and that your container is not inside a fixed-height element that clips the iframe.

Can I style the challenge screen? No. The issuer renders and controls it.

Concurrent payments. Each payment needs its own payment_id and payment_token. Use a separate SDK instance per payment.

The shopper refreshed the page mid-challenge. The payment will timeout. Implement page refresh detection and guide users to restart the payment process.



Did this page help you?