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, atHIGHEST,"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 atLOW/MEDIUM/HIGHand 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:
| Scope | Grants |
|---|---|
card-subscriptions:write | POST /card-subscriptions (create the subscription) |
card-subscriptions:read | GET /card-subscriptions/{id} (read subscription state) |
card-verifications:write | POST /card-verifications/3ds and every /steps/... POST |
card-verifications:read | GET /card-verifications/3ds/{id} (poll verification state) |
subaccounts:write | PATCH /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}`);
}
}currentStepId | state | What it means | Next action | Path |
|---|---|---|---|---|
null | completed | Frictionless success. Verification done. | Terminal. Do not call any /steps/... endpoint. | all |
"stripe-3ds" | in-progress | 3DS challenge required. Response also carries clientSecret + stripePublishableKey. | Run stripe.handleNextAction(clientSecret), then POST /steps/stripe-callback. | Stripe (MEDIUM/HIGH/HIGHEST) |
"stripe-holds-pending" | in-progress | Issuer went frictionless; HIGHEST falls back to the two-hold second factor. | Disclose, then POST /steps/place-holds. | HIGHEST only |
"stripe-amount-confirm" | in-progress | Two holds placed; cardholder enters both amounts. | Collect {amount1, amount2} (integer cents), then POST /steps/amount-confirm. | HIGHEST only |
"fingerprint" | in-progress | Legacy 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-progress | Legacy 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 astate: "failed"bodyA
/steps/...POST (and the create call) never returns200withstate: "failed". A failure
comes back as a problem+json error envelope (4xx/5xx) carryingerrorCode/category. See
Errors. The terminalstate: "failed"only appears on aGETpoll of the
verification (for example after the abandon sweep). Branch on the HTTP status first; readstate
from aGET.
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 value | Meaning |
|---|---|
in-progress | Verification is still pending a step. Continue or wait. |
completed | Verification succeeded. Card is enrolled. |
failed | Verification 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 typeSuccess responses are
application/hal+json: every verification body carries_links.selfand
_links.steps(plus_links.currentStepwhen a step is pending). The examples below elide_links
for brevity except where the link is the point. Error responses areapplication/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.
| Status | state | Meaning | Next action |
|---|---|---|---|
201 | reqSCA | The subscription was created and a 3DS verification is required. | Proceed to Start card verification. |
409 | none | An 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 fullstatetaxonomy.
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
clientSecretis single-use and short-livedPass it to
handleNextActionpromptly; 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 ofsdkErroris authoritative for failureIf you forward an
sdkError, the backend forces the verification to FAILED even if the upstream
re-check reports the intent assucceeded. 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-holdsNo 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.57 → 57) 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(lowercasesandUrl) instead of the spec-canonicalthreeDSMethodURL. 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
outcome valuesAfter a successful POST, the fingerprint step's outcome field tells you whether a challenge is still needed:
outcome | Meaning | Next step |
|---|---|---|
authenticated | Frictionless success. Verification state becomes completed. | Done. Card is enrolled. |
requires-challenge | Additional 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 hereFor Mastercard HIGHEST, the bank can defer its frictionless decision until after fingerprint. When
that happens,POST /steps/fingerprintreturns a verification body with
currentStepId: "stripe-holds-pending"instead of the usual step body. Detect with
"currentStepId" in responseand 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/restartResume 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 close | What to do |
|---|---|
stripe-holds-pending | Re-show the disclosure/initiate screen. Nothing was placed yet. |
stripe-amount-confirm | Re-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-3ds | The original clientSecret is single-use; do not reuse it. POST restart (releases any open holds), then create a fresh verification. |
fingerprint / challenge | Re-fetch the step (GET /steps/{id}) and continue the legacy flow. |
| Returned after 72 hours | Astrada 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 challengeThe 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 oncurrentStepIdinstead of blindly re-POSTing.- A bare
400with{ title, detail }and noerrorCodealways means "this step is no longer valid for the current state". Recover byGET-ing the verification and routing oncurrentStepId, 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 onstripe.amount_confirm_mismatchas{ attemptsRemaining }.
Branch oncategory, not on the fullerrorCodelistNew
stripe.*codes can be added at any time. Treat an unrecognizederrorCodeas "use a different
card" and branch your cardholder copy on the stablecategoryenum. 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, onPOST /card-verifications/3dsorPOST /steps/stripe-callback. - 3DS authentication failure (
stripe.auth_rejected_by_issuer,stripe.auth_canceled,stripe.auth_unsupported,stripe.try_again_later) → 500 onPOST /steps/stripe-callback(the detail is preserved;auth-*codes arrive without aretryableflag: treatauth-failed/auth-canceledas retry,auth-rejected/auth-unsupportedas 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)
POST /card-subscriptions)| Validation | HTTP status | API message detail |
|---|---|---|
subaccountId is not a valid UUID | 400 | Invalid uuid |
customerReferenceId is provided but not a valid UUID | 400 | Invalid uuid |
country is not a valid ISO 3166 Alpha-3 code | 400 | Country code must be in ISO 3166 Alpha3 format |
| Card country of issuance blocked for the subaccount | 403 | The card country of issuance (XX) is not supported |
expiryMonth is not an integer in the range 1–12 | 400 | Number must be greater than or equal to 1 / Number must be less than or equal to 12 |
expiryYear is not a 4-digit year | 400 | expiryYear must be in YYYY format |
| Expiry date is in the past | 400 | Expiry date cannot be in the past |
pan failed validation (invalid PAN, unsupported network, or BIN check) | 400 | Invalid PAN |
cvc is not 3 or 4 digits | 400 | CVC must be 3 or 4 digits |
| Card subscription already exists for this card + subaccount | 409 | There is already a card subscription for the specified card |
| Unhandled service error | 500 | An unexpected error happened while creating a card subscription |
400 validation responses come back as a
Bad Requestproblem+json envelope. Each per-field message in the table above appears inerrors[i].detail, witherrors[i].instancepointing at the failing path (for example/body/pan).
Card verification: legacy path (POST /card-verifications/3ds/.../steps/challenge)
POST /card-verifications/3ds/.../steps/challenge)| Validation | HTTP status | API message detail |
|---|---|---|
| Verification abandoned by the user | 409 | Card verification with id=X was abandoned by the user during the authentication process. Please start a new verification. |
| 3DS challenge not completed by the user | 409 | The 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) | 500 | Card 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:
errorCode | HTTP | API message detail |
|---|---|---|
verification.attempts_locked | 400 | Too many failed verification attempts for this card. Try again later. (carries metadata.lockedUntil) |
verification.attempts_locked_permanent | 400 | This 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
handleNextActioninto the same hook that triggers your existing iframe. - Popup window: workable. Load Stripe.js inside the popup and call
handleNextActionthere. - Full-page redirect that form-posts CReq: not supported on the Stripe path (
handleNextActionneeds 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? }.
| Endpoint | Scope | Request body | Successful response |
|---|---|---|---|
POST /card-subscriptions | card-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/3ds | card-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-callback | card-verifications:write | {} on success; { sdkError: { code, message? } } on Stripe.js failure | Terminal 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 · :write | POST: { 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 · :write | POST: {} | 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.
Updated 6 days ago