Direct-API Card Enrollment Integration

Introduction

This page is the direct-API integration reference for partners who have been granted permission to enroll cards without using the Astrada Card Enrollment SDK. If you are not sure whether your integration qualifies for direct-API access, talk to your Astrada contact before relying on this page. Most integrators should use the SDK, which handles every response path described below for you.

The page is the contract you need to implement: every request shape, every response shape, every status code, and the literal validation messages your error handler will see. It covers both verification paths a card can take:

  • The Stripe path (currentStepId: "stripe-3ds" and, at HIGHEST, "stripe-holds-pending" / "stripe-amount-confirm"): how Visa, and the Mastercard HIGHEST second factor, verify today.
  • The legacy network path (currentStepId: "fingerprint" / "challenge"): the EMV 3DS fingerprint-and-challenge flow still used by Mastercard at LOW/MEDIUM/HIGH and by any subaccount with no tier set.

You do not choose the path per request. It is determined by the subaccount's verification risk tier and the card network. Your client handles both by branching on a single field, currentStepId, on every response (see How enrollment completes). Adopting the Stripe path is purely additive: keep your legacy branches, add the Stripe ones, and they only fire for subaccounts moved to a tier.

Customers with personal cards or business cards can add their cards individually. Cardholders are required to complete a verification process to ensure they own the card when they link it.

Drive every path (Stripe and legacy) deterministically before going live with Test Cards & Sandbox Testing.

Authentication

Every request authenticates with a bearer token obtained from Astrada's OAuth2 token endpoint using the client-credentials grant. Request a token, then send it as Authorization: Bearer <token> on every call. Tokens are short-lived. Fetch a fresh one when it expires rather than caching indefinitely. Full token mechanics (endpoint, client id/secret, refresh) are in Authentication.

The token must carry the scope for each endpoint you call:

ScopeGrants
card-subscriptions:writePOST /card-subscriptions (create the subscription)
card-subscriptions:readGET /card-subscriptions/{id} (read subscription state)
card-verifications:writePOST /card-verifications/3ds and every /steps/... POST
card-verifications:readGET /card-verifications/3ds/{id} (poll verification state)
subaccounts:writePATCH /subaccounts/{id} (set the tier) and POST /card-verifications/unlock

A call whose token is missing the required scope is rejected with 403 before any business logic runs. Contact your Astrada representative for a reference client-credentials implementation.

How enrollment completes

Every 2xx response on a verification (the initial POST /card-verifications/3ds and every /steps/... POST after it) returns a verification body carrying a currentStepId field. Use it as your single routing key: one dispatcher handles every response in the flow until state flips to completed or failed.

// Call this on the create response AND on every step response.
function dispatch(verification) {
  if (verification.state === "completed") return showSuccess(verification);
  if (verification.state === "failed")    return showFailure(verification);

  switch (verification.currentStepId) {
    // Stripe path: fires for subaccounts on MEDIUM / HIGH / HIGHEST.
    case "stripe-3ds":            return run3DS(verification);            // load Stripe.js, then /steps/stripe-callback
    case "stripe-holds-pending":  return runPlaceHolds(verification);     // HIGHEST only
    case "stripe-amount-confirm": return runConfirmAmounts(verification); // HIGHEST only

    // Legacy network path: fires for no-tier subaccounts and Mastercard LOW/MEDIUM/HIGH.
    case "fingerprint":           return runFingerprint(verification);
    case "challenge":             return runChallenge(verification);

    // currentStepId === null with state=completed is handled at the top (frictionless terminal).
    default: throw new Error(`unexpected currentStepId: ${verification.currentStepId}`);
  }
}
currentStepIdstateWhat it meansNext actionPath
nullcompletedFrictionless success. Verification done.Terminal. Do not call any /steps/... endpoint.all
"stripe-3ds"in-progress3DS challenge required. Response also carries clientSecret + stripePublishableKey.Run stripe.handleNextAction(clientSecret), then POST /steps/stripe-callback.Stripe (MEDIUM/HIGH/HIGHEST)
"stripe-holds-pending"in-progressIssuer went frictionless; HIGHEST falls back to the two-hold second factor.Disclose, then POST /steps/place-holds.HIGHEST only
"stripe-amount-confirm"in-progressTwo holds placed; cardholder enters both amounts.Collect {amount1, amount2} (integer cents), then POST /steps/amount-confirm.HIGHEST only
"fingerprint"in-progressLegacy EMV 3DS device fingerprint. (Mastercard HIGHEST can also land here mid-flow, see Edge cases.)Run the fingerprint step.legacy + MC HIGHEST edge case
"challenge"in-progressLegacy EMV 3DS challenge.Run the challenge step.legacy
📘

Which path will I get?

The path is fixed by the subaccount's tier and the card network, not by your request. Visa with a
tier set runs the Stripe path; Mastercard runs the legacy network path at every tier except
the HIGHEST two-hold second factor; a subaccount with no tier set runs the legacy path. See
Verification Risk Tiers for the full routing matrix and what each
tier validates.

The key insight: not every enrollment needs an interactive step. If the issuer supports frictionless authentication you'll receive a creation response with state: "completed" and currentStepId: null. The enrollment is already finished, and calling a /steps/... endpoint would return 404 because no step row exists.

🚧

Failures are error envelopes, not a state: "failed" body

A /steps/... POST (and the create call) never returns 200 with state: "failed". A failure
comes back as a problem+json error envelope (4xx/5xx) carrying errorCode / category. See
Errors. The terminal state: "failed" only appears on a GET poll of the
verification (for example after the abandon sweep). Branch on the HTTP status first; read state
from a GET.

Client-side branching rule

Run dispatch (above) on the POST /card-verifications/3ds creation response and again on every step response it leads to. Legacy step responses also expose _links.currentStep.href if you prefer to follow HAL links rather than hardcode /steps/{id} paths:

if (response.state === "completed" || response.currentStepId === null) {
  // Enrolled. No further action needed.
} else {
  dispatch(response); // routes on currentStepId; legacy steps also carry _links.currentStep
}

Polling a verification

If a client abandons the flow mid-way (closed tab, hung iframe), the verification transitions to state: "failed" roughly one hour after creation via a scheduled check (one per verification, cancelled on step completion). To detect this, call GET /card-verifications/3ds/{verificationId} and branch on the full state taxonomy:

state valueMeaning
in-progressVerification is still pending a step. Continue or wait.
completedVerification succeeded. Card is enrolled.
failedVerification failed (a step explicitly failed, or the flow was abandoned and expired). Start a new verification if the cardholder is still available.

On the creation response, state is only ever in-progress or completed, never failed. failed appears only on subsequent reads.

When a GET returns state: "failed", the verification also carries a failureReason explaining why it failed: a normalized category, a cardholder-safe description, a retryable flag, and (when the issuer returns them) issuerMessage, code, and acsReferences. See Error States & Remediation for the field reference and the full category list.

To list every step recorded for a verification, follow _links.steps.href on the verification resource, equivalent to GET /card-verifications/3ds/{verificationId}/steps. Useful when reconciling state after a partial flow.

📘

Response media type

Success responses are application/hal+json: every verification body carries _links.self and
_links.steps (plus _links.currentStep when a step is pending). The examples below elide _links
for brevity except where the link is the point. Error responses are application/problem+json.

Single Card Enrollment Walkthrough

1. Customer Introduction

The first stage we recommend in any card enrollment journey is to clearly outline to your customers the scope of the data sharing they are about to consent to and any important information about how their data will be used.

This step improves user conversion by providing a feeling of trust and security.

2. Create the card subscription

The second stage of the card-linking journey requires the collection of card data and consent. At this stage the user provides sensitive data and opts into the terms of the data-sharing arrangement explicitly.

Subscription request

POST https://api.astrada.co/card-subscriptions
{
    "subaccountId": "<SUBACCOUNT_ID>",
    "country": "<ISO_3166_ALPHA3>",
    "expiryMonth": "<EXP_MONTH>",
    "expiryYear": "<EXP_YEAR>",
    "pan": "<CARD_NUMBER>",
    "cvc": "<CVC_NUMBER>",
    "cardholderName": "<HOLDER_NAME>",
    "customerReferenceId": "<CUSTOMER_REFERENCE_UUID>"
}

customerReferenceId is optional. When provided, it must be a UUID; use it to correlate this subscription with an entity in your own system. Requires the card-subscriptions:write scope.

Response states

The POST /card-subscriptions call can return one of two outcomes. Branch on the response before deciding your next step.

StatusstateMeaningNext action
201reqSCAThe subscription was created and a 3DS verification is required.Proceed to Start card verification.
409noneAn active subscription already exists for this card + subaccount.GET /card-subscriptions/{id} for the existing subscription (its id is in currentValue) and branch on its current state. Do not retry the POST.

The subscription is always created in reqSCA on success. It transitions to active only after the verification flow below completes.

3. Start card verification

For subscriptions that require SCA, start the verification:

Verification request

POST https://api.astrada.co/card-verifications/3ds
{
    "subaccountId": "<SUBACCOUNT_ID>",
    "expiryMonth": "<EXP_MONTH>",
    "expiryYear": "<EXP_YEAR>",
    "pan": "<CARD_NUMBER>",
    "cvc": "<CVC_NUMBER>",
    "cardholderName": "<HOLDER_NAME>"
}

Requires the card-verifications:write scope. The verification create body does not accept country or customerReferenceId. Both fields belong only to POST /card-subscriptions. Correlate the verification with your own records via the subscription's id (returned on the POST /card-subscriptions response).

Interpret the response

Run your dispatcher on the response. The creation response is one of three shapes:

Frictionless, verification complete, no further steps:

{
  "id": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "cardId": "8309b5f8-d5d8-49bb-9001-38bf1bb0f1e4",
  "type": "3DS",
  "currentStepId": null,
  "state": "completed",
  "authenticationFlow": "frictionless",
  "createdAt": "2024-01-04T18:53:32.000Z",
  "updatedAt": "2024-01-04T18:53:32.000Z",
  "_links": {
    "self":  { "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b" },
    "steps": { "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps" }
  }
}

authenticationFlow reports how 3DS resolved: frictionless, challenge, or null (3DS did not run). When you see currentStepId: null the card is enrolled; do not follow with a /steps/... call.

Stripe 3DS required, drive with Stripe.js:

{
  "id": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "cardId": "8309b5f8-d5d8-49bb-9001-38bf1bb0f1e4",
  "type": "3DS",
  "currentStepId": "stripe-3ds",
  "state": "in-progress",
  "authenticationFlow": null,
  "clientSecret": "seti_1Abc…_secret_Xyz…",
  "stripePublishableKey": "pk_live_…",
  "createdAt": "2024-01-04T18:53:32.000Z",
  "updatedAt": "2024-01-04T18:53:32.000Z",
  "_links": {
    "self":  { "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b" },
    "steps": { "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps" }
  }
}

Continue to 3.1 Stripe 3DS challenge.

HIGHEST frictionless, second factor required:

{
  "id": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "cardId": "8309b5f8-d5d8-49bb-9001-38bf1bb0f1e4",
  "type": "3DS",
  "currentStepId": "stripe-holds-pending",
  "state": "in-progress",
  "createdAt": "2024-01-04T18:53:32.000Z",
  "updatedAt": "2024-01-04T18:53:32.000Z"
}

Continue to 3.2 HIGHEST two-hold second factor. A subaccount on the legacy path returns currentStepId: "fingerprint" here instead. Continue to 3.3 Device fingerprint (legacy path).

sequenceDiagram
    autonumber
    participant Browser as Cardholder browser
    participant Backend as Your backend
    participant Astrada as Astrada API
    participant StripeJS as Stripe.js
    participant ACS as Issuer (ACS)
    Browser->>Backend: Cardholder submits card
    Backend->>Astrada: POST /card-subscriptions (→ reqSCA)
    Backend->>Astrada: POST /card-verifications/3ds
    Astrada-->>Backend: verification body, branch on currentStepId
    alt currentStepId = null (frictionless terminal)
        Backend-->>Browser: Enrolled
    else currentStepId = "stripe-3ds" (Visa / Stripe tiers)
        Browser->>StripeJS: handleNextAction(clientSecret)
        StripeJS->>ACS: Render 3DS challenge
        ACS-->>StripeJS: Cardholder completes / cancels
        Backend->>Astrada: POST /steps/stripe-callback
        Astrada-->>Backend: completed | stripe-holds-pending | error
    else currentStepId = "fingerprint" (legacy / Mastercard non-HIGHEST)
        Backend->>Astrada: GET then POST /steps/fingerprint
        Backend->>Astrada: GET then POST /steps/challenge (if requires-challenge)
    end
    opt HIGHEST frictionless second factor
        Backend->>Astrada: POST /steps/place-holds
        Backend->>Astrada: POST /steps/amount-confirm {amount1, amount2}
        Astrada-->>Backend: completed | mismatch(attemptsRemaining) | locked
    end

If you want to check verification status on an existing subscription, use GET /card-verifications/3ds/{verificationId}. See Polling a verification for the full state taxonomy.

3.1 Stripe 3DS challenge (Stripe path)

On currentStepId === "stripe-3ds", drive the 3DS challenge in the cardholder's browser. Load Stripe.js, initialize with the stripePublishableKey from the create response, and call handleNextAction with the clientSecret:

<script src="https://js.stripe.com/v3/"></script>
const stripe = Stripe(verification.stripePublishableKey);
const result = await stripe.handleNextAction({ clientSecret: verification.clientSecret });

// On success: result.error is undefined → POST an empty body to stripe-callback.
// On failure: result.error is populated → forward {code, message} so the backend
// can transition the verification to FAILED correctly.
const sdkError = result.error && {
  code: result.error.code ?? result.error.type,
  message: result.error.message,
};

Add these CSP directives if you don't already allow Stripe:

script-src   https://js.stripe.com
frame-src    https://js.stripe.com https://m.stripe.network
connect-src  https://api.stripe.com
🚧

clientSecret is single-use and short-lived

Pass it to handleNextAction promptly; never cache, queue, or surface it twice. A cardholder retry
needs a fresh verification, not a reused secret. If Stripe.js can't load at all (CSP block,
ad-blocker, network failure), Stripe(...) returns no usable instance. Route the cardholder to a
retry path; the Stripe path can't proceed without it.

Then tell the backend the challenge has settled. Send an empty body on success; include the captured sdkError on failure. The endpoint is idempotent: retry is safe.

POST https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/stripe-callback
{}
{ "sdkError": { "code": "canceled", "message": "Cardholder closed the modal" } }

The response is a verification body. Dispatch on currentStepId again. The two success shapes:

{ "id": "9fab1bea-…", "type": "3DS", "state": "completed", "currentStepId": null,
  "authenticationFlow": "challenge" }
{ "id": "9fab1bea-…", "type": "3DS", "state": "in-progress",
  "currentStepId": "stripe-holds-pending" }

On failure the call returns a problem+json error envelope (not a 200): a 400 for a decline (e.g. stripe.generic_decline) or a 500 for a 3DS authentication failure (e.g. stripe.auth_rejected_by_issuer), both carrying errorCode + category. See Errors.

📘

Presence of sdkError is authoritative for failure

If you forward an sdkError, the backend forces the verification to FAILED even if the upstream
re-check reports the intent as succeeded. This stops a cardholder who actively canceled the
challenge from being enrolled because the underlying intent reached success through a parallel
path. Omitting the body is treated as success.

The Stripe path has no separate "challenge complete" GET. handleNextAction owns the iframe lifecycle and stripe-callback settles it. If your subaccount is MEDIUM/HIGH, the stripe-callback response is terminal and you're done. HIGHEST subaccounts continue to the second factor below.

3.2 HIGHEST two-hold second factor (Stripe path)

HIGHEST adds a second factor only when the issuer goes frictionless (no real 3DS challenge). When you see currentStepId: "stripe-holds-pending" (on the create response or the stripe-callback response), the cardholder confirms two small temporary holds. The cardholder-facing UX, disclosure copy, and timing are in HIGHEST Verification; the wire calls are below.

Place the holds. Show a disclosure with an explicit "Place the holds" action and do not call place-holds until the cardholder taps it. Silent hold placement is not acceptable.

POST https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/place-holds

No request body. Success advances to amount confirmation:

{ "id": "9fab1bea-…", "type": "3DS", "state": "in-progress",
  "currentStepId": "stripe-amount-confirm" }

The two hold amounts are never returned on this (or any) response: that round-trip is the second factor. If the bank declines a hold you get a 400 stripe.place_holds_declined (category: soft-decline, retryable: true).

Confirm the amounts. Show a two-input form. Amounts are integer cents ($0.5757) and order-insensitive: {57, 89} and {89, 57} both match a stored {57, 89}.

POST https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/amount-confirm
{ "amount1": 57, "amount2": 89 }

On a match the response is terminal (state: "completed", currentStepId: null). On a mismatch with attempts left (each session allows two tries) you get a 400 you can retry inline:

{
  "title": "Bad Request",
  "detail": "The amounts entered did not match the verification holds.",
  "errorCode": "stripe.amount_confirm_mismatch",
  "category": "auth-failed",
  "retryable": true,
  "metadata": { "attemptsRemaining": 1 }
}

On the second wrong try the same code returns with metadata.attemptsRemaining: 0, retryable: false, and the verification is failed. If that final miss also crosses the per-card lockout threshold the code is stripe.amount_confirm_locked instead (category: verification-locked, cleared only by Astrada). See Errors and Error States & Remediation.

3.3 Device fingerprint (legacy path)

On currentStepId === "fingerprint", run the legacy EMV 3DS fingerprint flow. The first sub-step collects device and browser data for frictionless authentication. Retrieve the fingerprint step:

GET https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/fingerprint
{
  "id": "fingerprint",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "verificationId": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "type": "fingerprint",
  "state": "in-progress",
  "data": {
    "threeDSMethodData": "eyJ0aHJlZURTTWV0aG9kTm90aWZpY2F0aW9uVVJMIjoiaHR0cHM6Ly9zYW5kYm94LmFwaS5tYXN0ZXJjYXJkLmNvbS9vcGVuYXBpcy9hdXRoZW50aWNhdGlvbi9jYWxsYmFja3MvdGhyZWVEU01ldGhvZE5vdGlmaWNhdGlvbiIsInRocmVlRFNTZXJ2ZXJUcmFuc0lEIjoiOTNhN2NjNzUtY2I3Yy00Y2QzLWEwNTMtYjJjNGMxODZiZTVmIn0=",
    "threeDSMethodURL": "https://acs-public.tp.mastercard.com/api/v1/3ds_method",
    "threeDSMethodNotificationURL": "https://sandbox.api.mastercard.com/openapis/authentication/callbacks/threeDSMethodNotification",
    "threeDSServerTransID": "93a7cc75-cb7c-4cd3-a053-b2c4c186be5f"
  },
  "createdAt": "2024-01-05T14:46:50.000Z",
  "updatedAt": "2024-01-05T14:46:51.000Z",
  "_links": {
    "self": {
      "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps/fingerprint"
    },
    "nextStep": {
      "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps/challenge"
    }
  }
}

Use the data properties to open an iframe and collect browser information. The hidden iframe POSTs to the ACS (Access Control System) and posts a message to the window (threeDSMethodNotificationURL) when complete.

A small number of Mastercard cards return the field as threeDsMethodUrl (lowercase s and Url) instead of the spec-canonical threeDSMethodURL. Consume both casings defensively if you support Mastercard.

<html>
    <head>
        <script src="/static/fingerprint.js"></script>
        <script>
            window.onload = function() {
                doFingerprint(
                    '{{ threeDSMethodURL }}',
                    '{{ threeDSMethodNotificationURL }}',
                    '{{ threeDSMethodData }}',
                    '{{ threeDSServerTransID }}');
            }
        </script>
    </head>
</html>
// This listener receives events from the window.
// On the arrival of threeds-method-notification event, it forwards
// the required data to the server to start authentication.
function fingerprintCompleteListener(m) {
    if (m.data.type === 'threeds-method-notification') {
        console.log('fingerprintCompleteListener called');
        proceedAfterFingerprint('complete');
    }
};

// Next step after fingerprinting (either when it is completed or it was not needed)
function proceedAfterFingerprint(fingerprintStatus) {
    const body = {
        fingerprintStatus: fingerprintStatus,
        browserData: {
          challengeWindowSize: '04', // 600x400
          acceptHeader: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
          colorDepth: window.screen.colorDepth,
          javaEnabled: true,
          language: navigator.language,
          screenHeight: window.screen.height,
          screenWidth: window.screen.width,
          timezone: new Date().getTimezoneOffset(),
          userAgent: window.navigator.userAgent,
        },
    };
    post("https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/fingerprint", body);
};

function doFingerprint(threeDSMethodURL, threeDSMethodNotificationURL, threeDSMethodData, threeDSServerTransID) {
    if (threeDSMethodURL) {
        const html = `<script>
                document.addEventListener("DOMContentLoaded", function () {
                    var form = document.createElement("form");
                    form.method = "POST";
                    form.action = "${threeDSMethodURL}";
                    form.appendChild(createInput("threeDSMethodNotificationURL", "${threeDSMethodNotificationURL}"));
                    form.appendChild(createInput("threeDSMethodData", "${threeDSMethodData}"));
                    form.appendChild(createInput("threeDSServerTransID", "${threeDSServerTransID}"));
                    document.body.appendChild(form);
                    form.submit();
                    document.body.removeChild(form);
                });
                function createInput(name, value) {
                    var result = document.createElement("input");
                    result.name = name;
                    result.value = value;
                    return result;
                }
            </script>`

        const iframe = document.createElement("iframe");
        iframe.id = '3ds-fingerprint';
        document.body.appendChild(iframe);
        iframe.style.display = "none";
        const win = iframe.contentWindow;
        if (win != null) {
            const doc = win.document;
            win.name = "3DS Fingerprint";
            doc.open();
            doc.write(html);
            doc.close();
        }
        window.addEventListener("message", fingerprintCompleteListener);
    } else {
        // No threeDSMethodURL so skip fingerprinting
        proceedAfterFingerprint('unavailable');
    }
};

Once fingerprinting is complete, send the browser details to the ACS so the challenge iframe can be correctly sized:

POST https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/fingerprint
{
   "fingerprintStatus":"complete",
   "browserData":{
      "colorDepth":24,
      "language":"en-US",
      "timezone":-120,
      "screenHeight":1080,
      "screenWidth":1920,
      "challengeWindowSize":"05",
      "javaEnabled":false,
      "acceptHeader":"application/json",
      "userAgent":"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
   }
}

The server derives ip from the request (X-Forwarded-For or sourceIp); any value sent in the body is ignored.

Fingerprint step outcome values

After a successful POST, the fingerprint step's outcome field tells you whether a challenge is still needed:

outcomeMeaningNext step
authenticatedFrictionless success. Verification state becomes completed.Done. Card is enrolled.
requires-challengeAdditional cardholder interaction needed.Follow _links.nextStep.href to the 3DS Challenge.

A third value, null, exists in the schema but is never returned by the POST itself. It only appears on a GET of a step that was created and then never POST-completed (for example, an in-progress step or one that the 1-hour scheduled check expired before completion).

outcome vs. state: outcome describes the 3DS result; state describes the step's lifecycle. To detect failure, branch on state === "failed", not on outcome. There is no failed outcome value.

📘

Mastercard HIGHEST can return a verification body here

For Mastercard HIGHEST, the bank can defer its frictionless decision until after fingerprint. When
that happens, POST /steps/fingerprint returns a verification body with
currentStepId: "stripe-holds-pending" instead of the usual step body. Detect with
"currentStepId" in response and hand it back to your dispatcher. See
Edge cases.

authenticated response:

{
  "id": "fingerprint",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "verificationId": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "type": "fingerprint",
  "state": "completed",
  "outcome": "authenticated",
  "createdAt": "2024-01-05T14:46:50.000Z",
  "updatedAt": "2024-01-05T14:46:51.000Z",
  "_links": {
    "self": {
      "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps/fingerprint"
    }
  }
}

requires-challenge response:

{
  "id": "fingerprint",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "verificationId": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "type": "fingerprint",
  "state": "completed",
  "outcome": "requires-challenge",
  "createdAt": "2024-01-05T14:46:50.000Z",
  "updatedAt": "2024-01-05T14:46:51.000Z",
  "_links": {
    "self": {
      "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps/fingerprint"
    },
    "nextStep": {
      "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps/challenge"
    }
  }
}

3.4 3DS Challenge (legacy path)

When the fingerprint response indicates outcome: "requires-challenge", the cardholder needs to complete a token-based challenge (typically a push notification from their bank's app or an SMS code).

The challenge is delivered to the registered cardholder by their issuer. The iframe is fully controlled by the card-verification provider, so use the URL they provide.

Retrieve the challenge step data:

GET https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/challenge
{
  "id": "challenge",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "verificationId": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "type": "challenge",
  "state": "in-progress",
  "data": {
    "acsUrl": "https://acs-public.tp.mastercard.com/api/v1/browser_challenges",
    "encodedCReq": "eyJ0aHJlZURTU2VydmVyVHJhbnNJRCI6IjkzYTdjYzc1LWNiN2MtNGNkMy1hMDUzLWIyYzRjMTg2YmU1ZiIsImFjc1RyYW5zSUQiOiJiODBkNTZkNy01N2I1LTRhMzAtYmYwZC0xNzE4ZDlmNzI1ZTYiLCJjaGFsbGVuZ2VXaW5kb3dTaXplIjoiMDQiLCJtZXNzYWdlVHlwZSI6IkNSZXEiLCJtZXNzYWdlVmVyc2lvbiI6IjIuMi4wIn0"
  },
  "createdAt": "2024-01-05T14:46:51.000Z",
  "updatedAt": "2024-01-05T18:47:22.000Z",
  "_links": {
    "self": {
      "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps/challenge"
    }
  }
}

Display the challenge iframe using the acsUrl and encodedCReq:

<html>
    <head>
        <script src="/static/challenge.js"></script>
        <script>
            window.onload = function() {
                doChallenge('{{ acsUrl }}', '{{ encodedCReq }}');
            }
        </script>
    </head>
    <body>
        Performing 3DS challenge.
    </body>
</html>
// This listener receives messages posted to the window. After listening
// threeds-challenge-notification message, the challenge results window will pop-up.
function challengeCompleteListener(m) {
    if (m.data.type === 'threeds-challenge-notification') {
        console.log("challengeCompleteListener called");
        post("https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/challenge", {});
    }
};

// Opens 3DS challenge iframe and listens to event completion.
function doChallenge(acsUrl, encodedCReq) {

    const html = `<script>
            document.addEventListener("DOMContentLoaded", function () {
                var form = document.createElement("form");
                form.method = "POST";
                form.action = "${acsUrl}";
                form.appendChild(createInput("creq", "${encodedCReq}"));
                document.body.appendChild(form);
                form.submit();
                document.body.removeChild(form);
            });
            function createInput(name, value) {
                var result = document.createElement("input");
                result.name = name;
                result.value = value;
                return result;
            }
        </script>`

    const iframe = document.createElement("iframe");
    iframe.id = "3ds-challenge";
    iframe.width = "600px";
    iframe.height = "400px";
    iframe.frameBorder = "0";
    iframe.style.display = 'block';
    iframe.style.position = 'absolute';
    iframe.style.top = "100px";
    iframe.style.left = "50%";
    iframe.style.transform = "translate(-50%, 0%)";
    iframe.style.background = "white";
    document.body.appendChild(iframe);
    const win = iframe.contentWindow;

    if (win != null) {
        const doc = win.document;
        win.name = "3DS Challenge";
        doc.open();
        doc.write(html);
        doc.close();
    }

    window.addEventListener("message", challengeCompleteListener);
};

When the challenge iframe completes, it posts a message to the window. On that message, POST to the challenge step to finalize the verification:

POST https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/challenge
{
  "id": "challenge",
  "subaccountId": "f297d659-c13d-4219-aeaa-e10a845140a5",
  "verificationId": "9fab1bea-bc1e-4757-bd47-479422e5983b",
  "type": "challenge",
  "state": "completed",
  "createdAt": "2024-01-05T14:46:51.000Z",
  "updatedAt": "2024-01-05T18:47:22.000Z",
  "_links": {
    "self": {
      "href": "/card-verifications/3ds/9fab1bea-bc1e-4757-bd47-479422e5983b/steps/challenge"
    }
  }
}

409 on challenge POST

The challenge POST can return 409 Conflict for two reasons:

  • The user abandoned the verification during the authentication process.
  • The user did not complete the 3DS challenge during the authentication process.

In both cases the client should start a new verification rather than retrying the same one. The 409 response body carries a human-readable detail explaining which of the two occurred.

4. Check the final verification state

To verify the terminal state of a card verification, call:

GET https://api.astrada.co/card-verifications/3ds/{verificationId}

state will be completed or failed. The exact meanings are in Polling a verification above. When the verification reaches completed, the card subscription transitions from reqSCA to active.

Edge cases

Restart and resume

A cardholder can abandon mid-flow or close the tab and come back later. Both are recoverable.

Start over (HIGHEST). When the cardholder abandons during the holds flow, post to the restart endpoint. This releases any placed holds and supersedes the verification, returning the superseded resource directly in the response. Verification state changes aren't webhook-notified, so there's nothing to listen for. Create a fresh verification next.

POST https://api.astrada.co/card-verifications/3ds/{verificationId}/steps/restart

Resume after a closed tab. When the cardholder re-creates a verification for the same card, POST /card-verifications/3ds returns 409 with the existing verification's id in currentValue. Fetch it with GET /card-verifications/3ds/{verificationId} and dispatch on currentStepId:

Live step at closeWhat to do
stripe-holds-pendingRe-show the disclosure/initiate screen. Nothing was placed yet.
stripe-amount-confirmRe-show amount entry. The attempts counter and the two holds are preserved server-side; the holds are still visible in the cardholder's banking app.
stripe-3dsThe original clientSecret is single-use; do not reuse it. POST restart (releases any open holds), then create a fresh verification.
fingerprint / challengeRe-fetch the step (GET /steps/{id}) and continue the legacy flow.
Returned after 72 hoursAstrada auto-releases HIGHEST holds 72 hours after they were placed; the amounts vanish and there's no API "expired" error. Treat as abandoned: POST restart (safe even if the holds are gone), then re-create.

Mastercard: frictionless decision after fingerprint

For Mastercard HIGHEST, the bank can defer its frictionless decision until after the fingerprint step. When that happens, POST /steps/fingerprint returns the verification body (with currentStepId: "stripe-holds-pending") instead of the usual step body. Same endpoint, two possible shapes. Detect by the presence of currentStepId:

const response = await postFingerprint(verificationId, browserData);
if ("currentStepId" in response) {
  return dispatch(response);            // verification body: bank attested frictionless mid-flow
}
return runLegacyStepOutcome(response.outcome); // step body: stayed at fingerprint / moved to challenge

The cardholder sees the same disclosure → initiate → amount-confirm screens as the create-time frictionless path; the UX does not fork.

Idempotency and retries

  • stripe-callback, place-holds: idempotent. Calling either twice returns the same result (current state, no new holds placed) rather than an error; if the network drops, retry directly.
  • amount-confirm: GET before retrying, not idempotent. A retried call with the same wrong amounts burns another attempt. After an ambiguous network failure, GET /card-verifications/3ds/{verificationId} and route on currentStepId instead of blindly re-POSTing.
  • A bare 400 with { title, detail } and no errorCode always means "this step is no longer valid for the current state". Recover by GET-ing the verification and routing on currentStepId, not by retrying the same call.

Errors

All 4xx/5xx responses are application/problem+json with this envelope:

{
  "title": "Bad Request",
  "detail": "Card verification failed: card was declined.",
  "errorCode": "stripe.generic_decline",
  "category": "soft-decline",
  "retryable": false
}

title mirrors the HTTP status. detail is engineering-readable. Don't surface it to cardholders verbatim. On the Stripe path, declines and auth failures also carry:

  • errorCode: the namespaced key (e.g. stripe.cvc_fail) for cardholder-copy lookup.
  • category: a small, stable remediation enum to branch on: cvc, bank-contact, hard-fraud, soft-decline, transient, infrastructure, auth-failed, auth-canceled, auth-rejected, auth-unsupported, verification-locked.
  • retryable: a per-response UX hint (a few codes carry no flag, see the note below).
  • metadata: present on stripe.amount_confirm_mismatch as { attemptsRemaining }.
🚧

Branch on category, not on the full errorCode list

New stripe.* codes can be added at any time. Treat an unrecognized errorCode as "use a different
card" and branch your cardholder copy on the stable category enum. The exhaustive code list,
per-code HTTP status, and the SDK's cardholder screens live in
Error States & Remediation.

Where failures land:

  • Decline before/at 3DS (stripe.cvc_fail, stripe.generic_decline, stripe.stolen_card, stripe.insufficient_funds, stripe.contact_issuer, …) → 400, on POST /card-verifications/3ds or POST /steps/stripe-callback.
  • 3DS authentication failure (stripe.auth_rejected_by_issuer, stripe.auth_canceled, stripe.auth_unsupported, stripe.try_again_later) → 500 on POST /steps/stripe-callback (the detail is preserved; auth-* codes arrive without a retryable flag: treat auth-failed/auth-canceled as retry, auth-rejected/auth-unsupported as use-another-card).
  • HIGHEST two-hold (stripe.place_holds_declined, stripe.amount_confirm_mismatch, stripe.amount_confirm_locked) → 400 on the respective step.

The full stripe.* / verification.* reference, including the Mastercard/no-tier legacy-path failures that carry detail only (no errorCode), is in Error States & Remediation.

Card enrollment (POST /card-subscriptions)

ValidationHTTP statusAPI message detail
subaccountId is not a valid UUID400Invalid uuid
customerReferenceId is provided but not a valid UUID400Invalid uuid
country is not a valid ISO 3166 Alpha-3 code400Country code must be in ISO 3166 Alpha3 format
Card country of issuance blocked for the subaccount403The card country of issuance (XX) is not supported
expiryMonth is not an integer in the range 1–12400Number must be greater than or equal to 1 / Number must be less than or equal to 12
expiryYear is not a 4-digit year400expiryYear must be in YYYY format
Expiry date is in the past400Expiry date cannot be in the past
pan failed validation (invalid PAN, unsupported network, or BIN check)400Invalid PAN
cvc is not 3 or 4 digits400CVC must be 3 or 4 digits
Card subscription already exists for this card + subaccount409There is already a card subscription for the specified card
Unhandled service error500An unexpected error happened while creating a card subscription

400 validation responses come back as a Bad Request problem+json envelope. Each per-field message in the table above appears in errors[i].detail, with errors[i].instance pointing at the failing path (for example /body/pan).

Card verification: legacy path (POST /card-verifications/3ds/.../steps/challenge)

ValidationHTTP statusAPI message detail
Verification abandoned by the user409Card verification with id=X was abandoned by the user during the authentication process. Please start a new verification.
3DS challenge not completed by the user409The 3DS challenge for Card verification with id=X was not completed by the user during the authentication process. Please start a new verification.
Challenge step failed (3DS provider returned an error)500Card verification step with id=challenge failed for card verification with id=X. 3DS details: '[reason]'. Please start a new verification.

Attempt lockout: when the subaccount has failedAttemptLockout enabled, a card with too many recent hard failures is blocked at POST /card-verifications/3ds (before any provider call), across all networks:

errorCodeHTTPAPI message detail
verification.attempts_locked400Too many failed verification attempts for this card. Try again later. (carries metadata.lockedUntil)
verification.attempts_locked_permanent400This card can no longer be used for verification. Please contact support.

Clear either lock with POST /card-verifications/unlock (body { cardId }, scope subaccounts:write). This is distinct from the HIGHEST per-card lockout (stripe.amount_confirm_locked), which only Astrada can clear. Full rules: Verification Attempt Lockout.

End-to-end skeleton

Every 2xx on a verification returns a verification body. A single dispatch function, called on the create response, drives the whole flow. Each branch handler either terminates or hands the next verification body back to dispatch. api.* are your own HTTP wrappers (auth + JSON; a 4xx/5xx throws with errorCode/category/metadata). ui.* render cardholder screens.

async function startVerification(payload) {
  return dispatch(await api.create(payload)); // POST /card-verifications/3ds
}

async function dispatch(v) {
  if (v.state === "completed") return ui.showSuccess(v);
  if (v.state === "failed")    return ui.showFailure(v);

  switch (v.currentStepId) {
    case "stripe-3ds":            return run3DS(v);
    case "stripe-holds-pending":  return runPlaceHolds(v);     // HIGHEST
    case "stripe-amount-confirm": return runConfirmAmounts(v); // HIGHEST
    case "fingerprint":           return runFingerprint(v);    // legacy + MC HIGHEST mid-flow
    case "challenge":             return runChallenge(v);      // legacy
    default: throw new Error(`unexpected currentStepId: ${v.currentStepId}`);
  }
}

async function run3DS(v) {
  const result = await stripe.handleNextAction({ clientSecret: v.clientSecret });
  const body = result.error
    ? { sdkError: { code: result.error.code ?? result.error.type, message: result.error.message } }
    : {};
  return dispatch(await api.stripeCallback(v.id, body)); // POST /steps/stripe-callback
}

async function runPlaceHolds(v) {
  await ui.showDisclosureAndWaitForConfirm();              // explicit cardholder action required
  return dispatch(await api.placeHolds(v.id));             // POST /steps/place-holds
}

async function runConfirmAmounts(v) {
  while (true) {
    const { amount1, amount2 } = await ui.collectAmounts(); // integer cents, order-insensitive
    try {
      return dispatch(await api.amountConfirm(v.id, { amount1, amount2 }));
    } catch (err) {
      if (err.errorCode === "stripe.amount_confirm_mismatch" && err.metadata?.attemptsRemaining > 0) {
        ui.showInlineMismatch(err.metadata.attemptsRemaining);
        continue;                                          // inline retry on the same screen
      }
      throw err;                                           // terminal: surfaced by your error handler
    }
  }
}

// Mastercard HIGHEST can land here mid-flow: POST /steps/fingerprint may return a verification
// body (holds-pending) instead of a legacy step body. Detect and re-dispatch.
async function runFingerprint(v) {
  const response = await api.postFingerprint(v.id, await ui.collectBrowserData());
  if ("currentStepId" in response) return dispatch(response);
  return runLegacyStepOutcome(response.outcome);
}

Hosting architecture considerations

The Stripe path needs JavaScript running in the cardholder's browser to render the Stripe.js iframe. How hard adoption is depends on how your verification UI is hosted:

  • Iframe embedded in your page: easiest. Drop handleNextAction into the same hook that triggers your existing iframe.
  • Popup window: workable. Load Stripe.js inside the popup and call handleNextAction there.
  • Full-page redirect that form-posts CReq: not supported on the Stripe path (handleNextAction needs the page to stay mounted). Leave these subaccounts on the legacy flow; escalate to your Astrada integration manager.
  • Pure server-rendered, no browser JavaScript: not supported. Keep on legacy; escalate.

Appendix: API quick reference

All requests carry Authorization: Bearer <token> and Content-Type: application/json. Success responses are application/hal+json (201 for create, 200 otherwise) returning a verification body { id, type, state, currentStepId, authenticationFlow, clientSecret?, stripePublishableKey?, _links }. Errors are application/problem+json { title, detail, errorCode?, category?, retryable?, metadata? }.

EndpointScopeRequest bodySuccessful response
POST /card-subscriptionscard-subscriptions:write{ subaccountId, country, expiryMonth, expiryYear, pan, cvc, cardholderName, customerReferenceId? }201 subscription in state: reqSCA. 409 if one already exists (currentValue = existing id).
POST /card-verifications/3dscard-verifications:write{ subaccountId, expiryMonth, expiryYear, pan, cvc, cardholderName }currentStepId: "stripe-3ds" (+ clientSecret, stripePublishableKey), null (frictionless terminal), "stripe-holds-pending" (HIGHEST), or "fingerprint" (legacy).
GET /card-verifications/3ds/{id}card-verifications:read(none)Current verification body. Use after a 409 on re-create to resume.
POST /steps/stripe-callbackcard-verifications:write{} on success; { sdkError: { code, message? } } on Stripe.js failureTerminal for MEDIUM/HIGH; for HIGHEST either terminal or "stripe-holds-pending". Idempotent.
POST /steps/place-holds (HIGHEST)card-verifications:write(none)Advances to "stripe-amount-confirm". stripe.place_holds_declined on bank decline.
POST /steps/amount-confirm (HIGHEST)card-verifications:write{ amount1, amount2 } (integer cents, order-insensitive)Terminal completed on match; stripe.amount_confirm_mismatch (metadata.attemptsRemaining) on miss; stripe.amount_confirm_locked on lockout.
POST /steps/restart (HIGHEST)card-verifications:write(none)Supersedes the verification and releases any placed holds. No webhook fires.
GET /steps/fingerprint · POST /steps/fingerprint (legacy)card-verifications:read · :writePOST: { fingerprintStatus, browserData }Step body with outcome (authenticated / requires-challenge); MC HIGHEST may return a verification body.
GET /steps/challenge · POST /steps/challenge (legacy)card-verifications:read · :writePOST: {}Step body state: completed; 409 if abandoned / not completed.

Consent

What is Cardholder Consent?

Cardholder consent is the approval a cardholder gives to allow their transaction data to be accessed and used by third parties like Astrada. This consent is important for complying with card-network requirements and ensuring data security.

Why We Collect Consent

Contractual requirement from card networks

Card networks mandate obtaining cardholder consent to ensure that transaction data is shared responsibly and ethically.

Through Astrada's APIs and SDK, cardholders can opt in and authorize the sharing of their data. This opt-in process is important for Astrada and our customers to access such data legitimately.

Data security and best practices

Collecting consent ensures that sensitive cardholder data is not accessed or shared inappropriately, adhering to stringent data-security standards.

How We Collect Consent

Astrada initiates the consent collection process when a customer enrolls a card for the first time.

Consent is gathered through a clear and conspicuous request, ensuring the cardholder is fully informed and has the freedom to consent or refuse. The request explains the purpose of data collection and the specifics of how data will be used.

We use consent language approved by card networks to ensure uniformity and compliance (see the Web SDK docs). This language is integrated into our Card Enrollment SDK by default.

Cardholders must agree to network-specific and Astrada-specific terms separately, ensuring clarity and compliance with privacy laws.

Upon receiving affirmative opt-in consent, Astrada verifies the identity of the cardholder to ensure the consent is valid and associated with the correct individual.

We maintain detailed records of consents, including date and time stamps, to comply with legal requirements and for audit purposes.

By integrating with Astrada, our customers ensure that all data is fully compliant with both legal and network requirements.


Did this page help you?