Error States & Remediation
Introduction
The canonical reference for every enrollment outcome: how to handle errors, the complete error-code list, what the cardholder sees, and what success looks like. SDK troubleshooting: Unhappy Paths · tier behavior: Verification Risk Tiers.
How to handle errors
Four rules cover every case:
- Branch on
errorCodewhen present — it's the stable machine key (full list below). - Fall back to
category, then HTTP status. Some errors carrycategorywithout an
errorCode; network-path 3DS failures and client errors carry neither. - Never parse or render
detail— it's human-readable, interpolates IDs, can change without
notice, and may contain provider/technical context cardholders shouldn't see. - Log the reference ID. Every SDK error screen footer shows
Reference: <verificationId> | <correlationId>— capture it; it's the fastest path for Astrada
support to trace a failure.
The error body:
| Field | Present | Use |
|---|---|---|
detail | always | display/debug only — never branch on it |
errorCode | sometimes | primary branching key |
category | sometimes | remediation class: cvc · bank-contact · hard-fraud · soft-decline · transient · infrastructure · auth-failed · auth-canceled · auth-rejected · auth-unsupported · verification-locked |
retryable | sometimes | can the cardholder usefully retry now |
metadata | sometimes | e.g. attemptsRemaining on a mismatch/lockout error. Not where a 409's existing-resource id lives — both verification-create and subscription-create 409s put that in currentValue instead (see below) |
Two phases, two catalogs. Errors from creating the subscription (POST /card-subscriptions) use the card_subscription.* namespace (or detail-only for conflict/validation/network pre-check). Errors from verifying the cardholder (POST /card-verifications/3ds + /steps/…) use stripe.* (or detail-only on the network 3DS path).
Error code reference
card_subscription.*, stripe.*, and verification.* are the errorCode namespaces.
During subscription create — card_subscription.*
card_subscription.*These carry title and type (the per-code reference URL) instead of category/retryable.
errorCode | HTTP | Cardholder sees (SDK) |
|---|---|---|
card_subscription.account_blocking_card_type | 403 | "Card type not supported" |
card_subscription.account_blocking_card_funding_type | 403 | "Card funding type not supported" |
card_subscription.subaccount_blocking_card_type | 403 | "Card type not supported" |
card_subscription.subaccount_blocking_card_funding_type | 403 | "Card funding type not supported" |
card_subscription.subaccount_blocking_card_country | 403 | "Card country not supported" |
card_subscription.card_must_be_network_bulk_enrolled | 422 | (no SDK copy — handle in onError, route to your bulk flow) |
card_subscription.sandbox_card_not_allowed | 403 | (sandbox environment only — enroll a sandbox test card) |
Remediation: adjust the subaccount's enrollment controls via PATCH /subaccounts, or use an eligible card. Detail-only outcomes at create: 409 (subscription already exists — currentValue carries the existing id) and 400 (Mastercard pre-check rejected the card, or request validation failed).
During verification — stripe.*
stripe.*Which codes can occur depends on the path (network × tier):
- Visa with a tier set → the full
stripe.*table below. - Mastercard → 3DS always runs the network path (
detail-only failures); the onlystripe.*
codes possible are the three HIGHEST hold codes. - No tier set → network path:
detail-only, neverstripe.*.
errorCode | category | retryable | HTTP | Cardholder sees (SDK) |
|---|---|---|---|---|
stripe.cvc_fail | cvc | yes³ | 400 / 500² | "Security code didn't match" |
stripe.generic_decline | soft-decline | no | 400 / 500² | "Card declined" |
stripe.insufficient_funds | soft-decline | no | 400 / 500² | "Verification failed" |
stripe.expired_card | soft-decline | no | 400 / 500² | "Card expired" |
stripe.stolen_card | hard-fraud | no | 400 / 500² | "Card not eligible" |
stripe.lost_card | hard-fraud | no | 400 / 500² | "Card not eligible" |
stripe.restricted_card | hard-fraud | no | 400 / 500² | "Card not eligible" |
stripe.card_declined_at_3ds | hard-fraud | no | 500 | "Card declined during verification" |
stripe.contact_issuer | bank-contact | no | 400 / 500² | "Contact your bank" |
stripe.try_again_later | transient | yes | 400 / 500 | "Verification temporarily unavailable" |
stripe.auth_failed | auth-failed | —¹ | 500 | "Authentication failed" |
stripe.auth_canceled | auth-canceled | —¹ | 500 | "Verification canceled" |
stripe.auth_rejected_by_issuer | auth-rejected | —¹ | 500 | "Authentication denied by your bank" |
stripe.auth_unsupported | auth-unsupported | —¹ | 500 | "Card doesn't support secure verification" |
stripe.place_holds_declined | soft-decline | yes | 400 | "Couldn't place the holds" (HIGHEST) |
stripe.amount_confirm_mismatch | auth-failed | while metadata.attemptsRemaining > 0 | 400 | "Amounts didn't match" (HIGHEST) |
stripe.amount_confirm_locked | verification-locked | no | 400 | "Verification temporarily blocked" (HIGHEST) |
stripe.unknown | infrastructure | no | 400 / 500² | generic failure screen |
¹ auth-* failures mostly arrive without a wire retryable flag — treat auth-failed/auth-canceled as retry, auth-rejected/auth-unsupported as use-another-card. A newer auth-failed decline path does carry retryable: true on the wire; when present, prefer it over this default.
² Same errorCode/category either way, but the HTTP status depends on when the decline happens: 400 at verification create (rejected immediately); 500 if the decline instead happens after a 3DS challenge has already run. Branch on errorCode, not on the status code.
³ false specifically for a missing-CVC request (empty field) — the same errorCode covers both "CVC provided but wrong" (retryable) and "no CVC provided" (not retryable).
Category without errorCode: a few known declines intentionally carry no copy key — extra hard-fraud variants, velocity (soft-decline, not retryable), rate-limit/connection (transient, retryable). Branch on category; the SDK shows its generic screen.
SDK-side codes (only in onError, never from the API): stripe.js_load_failed ("Payment service unavailable"), stripe.unexpected_state ("Verification incomplete"), stripe.resume_unavailable ("Session expired").
After repeated failures — verification.*
verification.*When a subaccount has failedAttemptLockout enabled, repeated hard failures lock a card across every network. Rules + thresholds: Verification Attempt Lockout.
errorCode | category | retryable | HTTP | Cardholder sees (SDK) |
|---|---|---|---|---|
verification.attempts_locked | verification-locked | no | 400 | "Verification temporarily blocked" |
verification.attempts_locked_permanent | verification-locked | no | 400 | "Verification blocked" |
The temporary code carries metadata.lockedUntil (ISO-8601). Clear either lock with POST /card-verifications/unlock (subaccounts:write). This is distinct from the HIGHEST per-card lockout (stripe.amount_confirm_locked, below), which only Astrada can clear.
Triage a failed verification with failureReason
Most integrations use the SDK, which runs the verification in the cardholder's browser and shows them a failure screen via onError. Your backend never sees those calls — so when a cardholder's card won't enroll, you need your own server-side way to find out why. These read endpoints give you that visibility even when the SDK does the enrolling: look a failure up by a handle the SDK's onError hands you — the verification ID (one verification) or the card ID (all of a card's failures).
GET /card-verifications/3ds?cardId={cardId}&state=failed— the card's failed verifications, each
with itsfailureReason.cardIdis required — this is a per-card view your backend can query any
time. You get thecardId(andsubscriptionId) from the SDK'sonErrorcallback, or from the card
subscription: it's in thecardsubscription.*webhook payload, andGET /card-subscriptions/{id}
returns it next to a_links.verificationslink that points straight at this query.GET /card-verifications/3ds/{verificationId}— a single verification, when you have its id. The SDK
hands it to you directly asverificationIdon theonErrorcallback (and shows it on the failure
screen asReference: <verificationId>); direct-API integrators already hold it from the
POST /card-verifications/3dsresponse.
failureReason is present only when state is failed. category, description, and retryable are always present; issuerMessage, code, and acsReferences appear only when the issuer returned them (typically a frictionless decline).
| Field | Always | Description |
|---|---|---|
category | yes | Stable, provider-agnostic failure category an integrator can branch on without enumerating raw issuer codes. |
description | yes | Human-readable explanation of the failure, safe to relay to the cardholder. |
retryable | yes | Whether the cardholder can retry now and plausibly succeed. |
issuerMessage | no | The issuer's own cardholder-facing message, when provided (frictionless declines only). |
code | no | Raw provider reason code (a 3DS transStatusReason or a Stripe decline_code), surfaced for support escalation. |
acsReferences | no | 3DS ACS reference identifiers, when returned by the issuer — useful when escalating to the issuer. |
category is one of: authentication_declined, authentication_canceled, cvc_check_failed, card_declined, contact_issuer, card_blocked, too_many_attempts, not_supported, temporary_issue, processing_error. Branch on category to route, on retryable to decide whether to offer a retry, and show description (never detail) to the cardholder.
{
"id": "9fab1bea-bc1e-4757-bd47-479422e5983b",
"cardId": "8309b5f8-d5d8-49bb-9001-38bf1bb0f1e4",
"type": "3DS",
"state": "failed",
"authenticationFlow": "frictionless",
"failureReason": {
"category": "authentication_declined",
"description": "The card issuer declined the 3-D Secure authentication.",
"retryable": true,
"issuerMessage": "Your bank declined this verification. Please contact your bank or try another card.",
"code": "05",
"acsReferences": {
"acsTransactionId": "8a880dc0-d2d2-4067-bcb1-b08d1690b26e",
"acsReferenceNumber": "ACME-ACS-2024-0042",
"dsTransactionId": "97267598-fae6-494f-8453-2d3807e0c77f"
}
}
}
Three vocabularies, not one — don't map between themThe same underlying failure gets a different spelling depending which surface you're reading:
the SDK'sonErrorcategoryshown to the cardholder in real time (cvc·soft-decline·
auth-failed…, in the error-code tables above), the durablefailureReason.categoryrecorded on
the verification (listed here, e.g.cvc_check_failed), and — sandbox-only —
Test Cards & Sandbox Testing'sdecline_codecolumn (e.g.incorrect_cvc).
A bad CVC iscvc/cvc_check_failed/incorrect_cvcdepending which one you're looking at.
Don't map any of the three onto another.
What the cardholder sees
The SDK's failure screens, grouped by what the cardholder can do about each — not a restatement of the errorCode/category/retryable semantics above, just the actual screen for each. (At LOW, bank-contact and auth-unsupported are silently bypassed to success — see Verification Risk Tiers.)
Cardholder can fix it
| Category | Screen | Capture |
|---|---|---|
cvc — re-enter the security code (rejected at every tier) | "Security code didn't match" | ![]() |
bank-contact — call the bank, then re-enroll | "Contact your bank" | ![]() |
Card problems — use a different card
errorCode | Screen | Capture |
|---|---|---|
stripe.generic_decline | "Card declined" | ![]() |
stripe.insufficient_funds | "Verification failed" | ![]() |
stripe.expired_card | "Card expired" | ![]() |
hard-fraud (stolen/lost/restricted + no-code variants) | "Card not eligible" — one screen for all, so card status isn't disclosed | ![]() |
3DS authentication didn't complete
category | Screen | Capture |
|---|---|---|
auth-failed | "Authentication failed" | ![]() |
auth-rejected | "Authentication denied by your bank" | ![]() |
auth-canceled | "Verification canceled" | ![]() |
auth-unsupported | "Card doesn't support secure verification" | ![]() |
transient | "Verification temporarily unavailable" | ![]() |
Two special renders: the bank's optional free-form 3DS message is shown verbatim under the standard copy (

stripe.card_declined_at_3ds (
Infrastructure & recovery
| Situation | Screen | Capture |
|---|---|---|
Verification scripts blocked (stripe.js_load_failed, SDK-side) | "Payment service unavailable" | ![]() |
| Unrecognized/unexpected failure (catchall) | "Verification incomplete — try again" | ![]() |
| Refresh mid-challenge, session intact | challenge re-mounts, cardholder finishes | ![]() |
Refresh mid-challenge, session expired (stripe.resume_unavailable) | "Couldn't resume" + start over | ![]() |
Re-enrolling a card with an in-progress verification returns 409 with the existing verification's id in metadata — fetch it and resume at currentStepId (the SDK does this automatically).
Card locked — too many attempts
When failedAttemptLockout is enabled, a card that crosses the failure thresholds is blocked at the create step (Verification Attempt Lockout). The two tiers show distinct screens — wait-and-retry vs contact-your-provider:
errorCode | Screen | Capture |
|---|---|---|
verification.attempts_locked — temporary, auto-clears at metadata.lockedUntil | "Verification temporarily blocked" | ![]() |
verification.attempts_locked_permanent — permanent, clear with POST /card-verifications/unlock | "Verification blocked" | ![]() |
Default-path (network 3DS) failures — no errorCode
errorCodeWhen 3DS runs on the network rail — Mastercard 3DS at every tier, and Visa with no tier set — those step failures carry detail only. The network's own error codes are never returned as structured fields (at most a short provider fragment inside the 3DS details: '…' text). Your code can only branch on HTTP status — the detail sub-cases below are shown for log-reading/support triage, not as something your integration can distinguish or act on differently:
| HTTP | What it means |
|---|---|
| 400 | Rejected at creation (card not eligible for a verification), or a step call arrived after the step already finished or was superseded. |
| 409 | The verification is already terminal and cannot be completed — either the cardholder abandoned mid-authentication, or the challenge window closed before it completed. Start a new verification; retrying this one will 400. |
| 500 | The 3DS step failed (terminal). |
The SDK shows its generic failure screen for terminal failures — no per-cause copy on this path:

HIGHEST verification errors
The two-hold second factor's codes (flow: HIGHEST Verification):
errorCode | Remediation |
|---|---|
stripe.amount_confirm_mismatch | Retry while metadata.attemptsRemaining > 0 (2 per hold set). |
stripe.amount_confirm_locked | Locked after repeated failed sessions — contact Astrada to clear. |
stripe.place_holds_declined | Hold couldn't authorize — retry or use another card. |
Distinct from the attempt lockout
stripe.amount_confirm_lockedis the HIGHEST-only two-hold lockout (per card, cleared only by
Astrada). The opt-in cross-network throttle for the other tiers usesverification.attempts_locked
and you clear it yourself — see Verification Attempt Lockout.

Hold expiry: unconfirmed holds eventually fail the verification (next GET returns state: failed; holds void automatically). No special code — re-enroll to start fresh.
Integration (client) errors
Integration faults surface in the SDK's onError with type: "client" and detail only. Fix the integration — see Installation for the token contract:
detail (examples — not contractual) | Fix |
|---|---|
| "Timeout while waiting for token…within the allowed time window (5s)." | getAccessToken must resolve within the configured window — default 5 s; raise it with the optional getAccessTokenTimeoutMs (ms) option on openForm. |
| "Access token is malformed…" / "…not a valid JWT token." | Pass the raw JWT from Authentication. |
| "Verification state is 'failed'…" / "There is already an ongoing verification…" | Stale/conflicting verification — start a new enrollment. |
Success states
On the API — state: completed, currentStepId: null, and authenticationFlow reports how 3DS resolved: challenge, frictionless, or null (3DS didn't run).
In the SDK — the success screen plus the onSuccess callback:
{
"subscriptionId": "…",
"cardId": "…",
"enrollmentGuidance": { "availableEnrollmentMethods": ["network-bulk"] },
"authenticationFlow": "challenge"
}
If the program is bulk-eligible, the success screen says so and availableEnrollmentMethods includes network-bulk. onCancel fires (no payload) if the cardholder closes the form before finishing.
On your backend — don't rely on the browser:
- Webhooks (recommended) —
cardsubscription.created/cardsubscription.updated
(Webhooks); the subscriptionstateis the outcome:active,reqSCA
(3DS still pending),failed-to-create,deactivated,expired. - Poll —
GET /card-verifications/3ds/{verificationId}untilcompleted/failed. - SDK callback —
onSuccessfor immediate UX; confirm server-side with 1 or 2.
Testing error paths
Every state above is reproducible with sandbox test cards — see Test Cards & Sandbox Testing.
















