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 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.*

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)
card_subscription.sandbox_card_not_allowed403(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.*

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 only stripe.*
    codes possible are the three HIGHEST hold codes.
  • No tier set → network path: detail-only, never stripe.*.
errorCodecategoryretryableHTTPCardholder sees (SDK)
stripe.cvc_failcvcyes³400 / 500²"Security code didn't match"
stripe.generic_declinesoft-declineno400 / 500²"Card declined"
stripe.insufficient_fundssoft-declineno400 / 500²"Verification failed"
stripe.expired_cardsoft-declineno400 / 500²"Card expired"
stripe.stolen_cardhard-fraudno400 / 500²"Card not eligible"
stripe.lost_cardhard-fraudno400 / 500²"Card not eligible"
stripe.restricted_cardhard-fraudno400 / 500²"Card not eligible"
stripe.card_declined_at_3dshard-fraudno500"Card declined during verification"
stripe.contact_issuerbank-contactno400 / 500²"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.unknowninfrastructureno400 / 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.*

When a subaccount has failedAttemptLockout enabled, repeated hard failures lock a card across every network. 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.

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 its failureReason. cardId is required — this is a per-card view your backend can query any
    time. You get the cardId (and subscriptionId) from the SDK's onError callback, or from the card
    subscription: it's in the cardsubscription.* webhook payload, and GET /card-subscriptions/{id}
    returns it next to a _links.verifications link 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 as verificationId on the onError callback (and shows it on the failure
    screen as Reference: <verificationId>); direct-API integrators already hold it from the
    POST /card-verifications/3ds response.

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).

FieldAlwaysDescription
categoryyesStable, provider-agnostic failure category an integrator can branch on without enumerating raw issuer codes.
descriptionyesHuman-readable explanation of the failure, safe to relay to the cardholder.
retryableyesWhether the cardholder can retry now and plausibly succeed.
issuerMessagenoThe issuer's own cardholder-facing message, when provided (frictionless declines only).
codenoRaw provider reason code (a 3DS transStatusReason or a Stripe decline_code), surfaced for support escalation.
acsReferencesno3DS 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 them

The same underlying failure gets a different spelling depending which surface you're reading:
the SDK's onError category shown to the cardholder in real time (cvc · soft-decline ·
auth-failed …, in the error-code tables above), the durable failureReason.category recorded on
the verification (listed here, e.g. cvc_check_failed), and — sandbox-only —
Test Cards & Sandbox Testing's decline_code column (e.g. incorrect_cvc).
A bad CVC is cvc / cvc_check_failed / incorrect_cvc depending 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

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
Verification 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). 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:

HTTPWhat it means
400Rejected at creation (card not eligible for a verification), or a step call arrived after the step already finished or was superseded.
409The 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.
500The 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):

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 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 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": "…",
  "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:

  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