# 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](https://docs.astrada.co/docs/unhappy-paths) · tier behavior: [Verification Risk Tiers](https://docs.astrada.co/docs/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:**

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

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`](https://docs.astrada.co/reference/errors-account_blocking_card_type) | 403 | "Card type not supported" |
| [`card_subscription.account_blocking_card_funding_type`](https://docs.astrada.co/reference/errors-account_blocking_card_funding_type) | 403 | "Card funding type not supported" |
| [`card_subscription.subaccount_blocking_card_type`](https://docs.astrada.co/reference/errors-subaccount_blocking_card_type) | 403 | "Card type not supported" |
| [`card_subscription.subaccount_blocking_card_funding_type`](https://docs.astrada.co/reference/errors-subaccount_blocking_card_funding_type) | 403 | "Card funding type not supported" |
| [`card_subscription.subaccount_blocking_card_country`](https://docs.astrada.co/reference/errors-subaccount_blocking_card_country) | 403 | "Card country not supported" |
| [`card_subscription.card_must_be_network_bulk_enrolled`](https://docs.astrada.co/reference/errors-card_must_be_network_bulk_enrolled) | 422 | _(no SDK copy: handle in `onError`, route to your bulk flow)_ |
| [`card_subscription.sandbox_card_not_allowed`](https://docs.astrada.co/reference/errors-sandbox_card_not_allowed) | 403 | _(sandbox environment only: enroll a [sandbox test card](https://docs.astrada.co/docs/sandbox-testing))_ |

Remediation: adjust the subaccount's [enrollment controls](https://docs.astrada.co/docs/card-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](https://docs.astrada.co/docs/verification-risk-tiers)):

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

| `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` | none¹ | 500 | "Authentication failed" |
| `stripe.auth_canceled` | `auth-canceled` | none¹ | 500 | "Verification canceled" |
| `stripe.auth_rejected_by_issuer` | `auth-rejected` | none¹ | 500 | "Authentication denied by your bank" |
| `stripe.auth_unsupported` | `auth-unsupported` | none¹ | 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.*`

When a subaccount has `failedAttemptLockout` enabled, repeated hard failures lock a card across **every** network. Rules + thresholds: [Verification Attempt Lockout](https://docs.astrada.co/docs/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 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).

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

# 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, only the actual screen for each.

## 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 |
| --- | --- | --- |
| 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](https://docs.astrada.co/docs/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`

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:

| 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](https://docs.astrada.co/docs/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_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](https://docs.astrada.co/docs/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](https://docs.astrada.co/docs/web-sdk) 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](https://docs.astrada.co/docs/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:

```json
{
  "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](https://docs.astrada.co/docs/webhooks-1)); the subscription `state` is the outcome: `active`, `reqSCA` (3DS still pending), `failed-to-create`, `deactivated`, `expired`. A `bank-feed` enrollment (a card created from a completed bank link) is born `active` with no 3DS step (`reqSCA` never applies), so distinguish it by `enrollmentType`.
2. **Poll**: `GET /card-verifications/3ds/{verificationId}` until `completed` / `failed`.
3. **SDK callback**: `onSuccess` 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](https://docs.astrada.co/docs/sandbox-testing).
