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:

  1. Branch on errorCode when present — it's the stable machine key (full list below).
  2. Fall back to category, then HTTP status. Some errors carry category without an errorCode; network-path 3DS failures and client errors carry neither.
  3. 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.
  4. 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:

FieldPresentUse
detailalwaysdisplay/debug only — never branch on it
errorCodesometimesprimary branching key
categorysometimesremediation class: cvc · bank-contact · hard-fraud · soft-decline · transient · infrastructure · auth-failed · auth-canceled · auth-rejected · auth-unsupported · verification-locked
retryablesometimescan the cardholder usefully retry now
metadatasometimese.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.*

These carry title and type (the per-code reference URL) instead of category/retryable.

errorCodeHTTPCardholder sees (SDK)
card_subscription.account_blocking_card_type403"Card type not supported"
card_subscription.account_blocking_card_funding_type403"Card funding type not supported"
card_subscription.subaccount_blocking_card_type403"Card type not supported"
card_subscription.subaccount_blocking_card_funding_type403"Card funding type not supported"
card_subscription.subaccount_blocking_card_country403"Card country not supported"
card_subscription.card_must_be_network_bulk_enrolled422(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.*

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 only stripe.* codes possible are the three HIGHEST hold codes.
  • No tier set → network rail: detail-only, never stripe.*.
errorCodecategoryretryableHTTPCardholder sees (SDK)
stripe.cvc_failcvcyes400"Security code didn't match"
stripe.generic_declinesoft-declineno400"Card declined"
stripe.insufficient_fundssoft-declineno400"Verification failed"
stripe.expired_cardsoft-declineno400"Card expired"
stripe.stolen_cardhard-fraudno400"Card not eligible"
stripe.lost_cardhard-fraudno400"Card not eligible"
stripe.restricted_cardhard-fraudno400"Card not eligible"
stripe.card_declined_at_3dshard-fraudno400"Card declined during verification"
stripe.contact_issuerbank-contactno400"Contact your bank"
stripe.try_again_latertransientyes400 / 500"Verification temporarily unavailable"
stripe.auth_failedauth-failed—¹500"Authentication failed"
stripe.auth_canceledauth-canceled—¹500"Verification canceled"
stripe.auth_rejected_by_issuerauth-rejected—¹500"Authentication denied by your bank"
stripe.auth_unsupportedauth-unsupported—¹500"Card doesn't support secure verification"
stripe.place_holds_declinedsoft-declineyes400"Couldn't place the holds" (HIGHEST)
stripe.amount_confirm_mismatchauth-failedwhile metadata.attemptsRemaining > 0400"Amounts didn't match" (HIGHEST)
stripe.amount_confirm_lockedverification-lockedno400"Verification temporarily blocked" (HIGHEST)
stripe.unknowninfrastructureno400generic 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.*

When a subaccount has failedAttemptLockout enabled, repeated hard failures lock a card across every network (Visa/Stripe + Mastercard/TNS). Rules + thresholds: Verification Attempt Lockout.

errorCodecategoryretryableHTTPCardholder sees (SDK)
verification.attempts_lockedverification-lockedno400"Verification temporarily blocked"
verification.attempts_locked_permanentverification-lockedno400"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

CategoryScreenCapture
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

errorCodeScreenCapture
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

categoryScreenCapture
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

SituationScreenCapture
Stripe scripts blocked (stripe.js_load_failed, SDK-side)"Payment service unavailable"
Unrecognized/unexpected failure (catchall)"Verification incomplete — try again"
Refresh mid-challenge, session intactchallenge 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:

errorCodeScreenCapture
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

When 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:

HTTPSituationdetail looks like (examples — not contractual)
400Card rejected when creating the verification"Could not create a card verification because the card is not supported."
400Step already finished or superseded"Card verification step with id=… not in in-progress state anymore"
409Cardholder abandoned mid-authentication"Card verification with id=… was abandoned by the user…"
409Challenge not completed yet — still finishable; complete the step again"The 3DS challenge for Card verification with id=… was not completed…"
500The 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):

errorCodeRemediation
stripe.amount_confirm_mismatchRetry while metadata.attemptsRemaining > 0 (2 per hold set).
stripe.amount_confirm_lockedLocked after repeated failed sessions — contact Astrada to clear.
stripe.place_holds_declinedHold couldn't authorize — retry or use another card.
📘

Distinct from the attempt lockout

stripe.amount_confirm_locked is the HIGHEST-only two-hold lockout (per card, cleared only by Astrada). The opt-in cross-network throttle for the other tiers uses verification.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 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 APIstate: 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:

  1. Webhooks (recommended)cardsubscription.created / cardsubscription.updated (Webhooks); the subscription state is the outcome: active, reqSCA (3DS still pending), failed-to-create, deactivated, expired.
  2. PollGET /card-verifications/3ds/{verificationId} until completed / failed.
  3. SDK callbackonSuccess for 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.

Related