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 anerrorCode; 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, the existing resource id on a 409 |
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) |
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 rail (network × tier):
- Visa with a tier set → Stripe rail: the full table below.
- Mastercard → 3DS always runs the network rail (
detail-only failures); the onlystripe.*codes possible are the three HIGHEST hold codes. - No tier set → network rail:
detail-only, neverstripe.*.
errorCode | category | retryable | HTTP | Cardholder sees (SDK) |
|---|---|---|---|---|
stripe.cvc_fail | cvc | yes | 400 | "Security code didn't match" |
stripe.generic_decline | soft-decline | no | 400 | "Card declined" |
stripe.insufficient_funds | soft-decline | no | 400 | "Verification failed" |
stripe.expired_card | soft-decline | no | 400 | "Card expired" |
stripe.stolen_card | hard-fraud | no | 400 | "Card not eligible" |
stripe.lost_card | hard-fraud | no | 400 | "Card not eligible" |
stripe.restricted_card | hard-fraud | no | 400 | "Card not eligible" |
stripe.card_declined_at_3ds | hard-fraud | no | 400 | "Card declined during verification" |
stripe.contact_issuer | bank-contact | no | 400 | "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 | generic failure screen |
¹ auth-* failures arrive without a wire retryable flag — treat auth-failed/auth-canceled
as retry, auth-rejected/auth-unsupported as use-another-card.
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 (Visa/Stripe + Mastercard/TNS). 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.
What the cardholder sees
The SDK's failure screens, by category. (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 (
), and a challenge that succeeds but declines at finalization
shows stripe.card_declined_at_3ds (
).
Infrastructure & recovery
| Situation | Screen | Capture |
|---|---|---|
Stripe 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). Branch on HTTP status:
| HTTP | Situation | detail looks like (examples — not contractual) |
|---|---|---|
| 400 | Card rejected when creating the verification | "Could not create a card verification because the card is not supported." |
| 400 | Step already finished or superseded | "Card verification step with id=… not in in-progress state anymore" |
| 409 | Cardholder abandoned mid-authentication | "Card verification with id=… was abandoned by the user…" |
| 409 | Challenge not completed yet — still finishable; complete the step again | "The 3DS challenge for Card verification with id=… was not completed…" |
| 500 | The 3DS step failed (terminal) | "Card verification step with id=… failed… 3DS details: '…'" |
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_lockedand 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 in < 5 s. |
| "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": "…",
"enrollmentGuidance": { "availableEnrollmentMethods": ["network-bulk"] },
"authenticationFlow": "challenge"
}
If the program is bulk-eligible, the success screen says so and availableEnrollmentMethods
includes network-bulk. Also: onCancel (closed before finishing, no payload) and onClose
(dismissed the success screen).
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.
















