Test Cards & Sandbox Testing

Introduction

Sandbox mode lets you exercise card verification — success, 3DS challenge, declines, and the HIGHEST two-hold flowdeterministically and with no real money movement, against the production API. You enable it per subaccount, then enroll known test cards.

How it works

On a sandbox-enabled subaccount, the verification service routes by card number:

  • A known test card (the matrix below) runs against the verification sandbox (test mode): you
    get real 3DS / decline behavior, deterministically.
  • Any other card runs against live processing, exactly as in production.

Routing is fail-closed: it requires the subaccount to be explicitly sandbox-enabled, and it matches the full card number exactly (never a BIN or prefix). On a normal production subaccount, a test card is not diverted — it goes live and is declined as invalid. A configuration gap degrades to real validation, never to a fake success.

Sandbox applies to every tier, including HIGHEST: the two-hold flow runs as test-mode authorizations, so you can drive it end to end.

Default path vs sandbox

How a card is verified depends on whether the subaccount is sandbox-enabled:

  • Sandbox-enabled (verificationPolicy.sandbox: true) — known test cards route to the
    verification sandbox (for both Visa and Mastercard) for deterministic outcomes (the
    sandbox matrix below). Recommended for testing.
  • Default (non-sandbox) — cards hit the real networks (Visa and Mastercard), exactly as in
    production. A few network test cards exist for basic enrollment checks:
PANBrandDefault-path outcome
4000 0000 0000 1117Visa3DS is bypassed (test-card exemption) — resolves via authenticationFlow: null, not a challenge/frictionless pass
4242 4242 4242 4241VisaInvalid PAN (Luhn check fails)
5156 7637 1936 6465MastercardInvalid PAN (Luhn check fails)
5555 5555 5555 4444MastercardNetwork pre-check fails (rejected at create)
📘

Same card, two contexts

5555 5555 5555 4444 is rejected at create (network pre-check failure) on the default path, but on
a sandbox-enabled subaccount the sandbox returns a frictionless success for it (see the
sandbox matrix below). Test against a sandbox subaccount for deterministic behavior.

Enabling sandbox mode

Set sandbox on the subaccount's verificationPolicy via Update Subaccount:

PATCH /subaccounts/{subaccountId} HTTP/1.1
Authorization: Bearer YOUR_ACCESS_TOKEN
Content-Type: application/json

{
  "verificationPolicy": { "sandbox": true }
}
curl -X PATCH https://api.astrada.co/subaccounts/{subaccountId} \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "verificationPolicy": { "sandbox": true }
  }'
{
  "verificationPolicy": { "sandbox": true }
}

Use a dedicated sandbox subaccount, never a production one — coordinate with your Astrada contact to provision one. Anything other than true (absent, false, null) means live processing. You still call the production API with a token scoped to the sandbox subaccount.

Sandbox test-card matrix

These are the deterministic outcomes on a sandbox-enabled subaccount. All cards use any future expiry (e.g. 12/2034) and any 3-digit CVC (123) unless the row is about CVC. Outcomes are verified against the verification sandbox.

The Product column is the card type the sandbox reports to the enrollment card-type control (blockedCardTypes): a subaccount that blocks CONSUMER cards rejects every consumer PAN below and accepts a COMMERCIAL one — so you can test a consumer-card block end to end.

PANBrandProductScenarioExpected end statefailureReason.code
4242424242424242VisaConsumerSuccess (frictionless / challenge-approved)completed
4000002760003184VisaConsumer3DS challenge required (no frictionless path)stripe-3ds → challenge → completed
4000008400001629VisaConsumer3DS authentication failsfailed
4000000000000101VisaConsumerCVC check failsfailedincorrect_cvc
4000000000000002VisaConsumerGeneric declinefailedgeneric_decline
4000000000000069VisaConsumerExpired cardfailedexpired_card
4000000000009979VisaConsumerStolen card (hard decline)failedstolen_card
4000000000009995VisaConsumerInsufficient fundsfailedinsufficient_funds
4000000000009987VisaConsumerLost card (hard decline)failedlost_card
4000000000000119VisaConsumerProcessing error (transient)failedprocessing_error
4000000000000341VisaConsumerHIGHEST: verification hold declinesfailed at place-holds(hold decline)
4000000000003220VisaCommercial3DS challenge → successstripe-3ds → challenge → completed
5555555555554444MastercardConsumerFrictionless (issuer attests)MEDIUM/HIGH → completed; HIGHEST → two-hold flow → completed
5200828282828210MastercardConsumer3DS challenge → successfingerprintchallengecompleted
2223003122003222MastercardConsumer3DS challenge → authentication failsfailed (challenge POST → 500)
5105105105105100MastercardConsumer3DS challenge → abandonedfailed (challenge POST → 409)
5555558265554449MastercardConsumerNetwork pre-check declined (rejected at create)400 at create — no verification created
5200828282828897MastercardCommercialFrictionless (issuer attests)MEDIUM/HIGH → completed; HIGHEST → two-hold flow → completed
5555552500001001MastercardCommercial3DS challenge → successfingerprintchallengecompleted

failureReason.code is an overloaded field — for Stripe-routed failures like these it's the raw Stripe decline code shown above; for network-3DS failures elsewhere it instead carries a 3DS transStatusReason code. Check failureReason.category first if you're branching on failure type generically.

Under MEDIUM and HIGH the issuer is asked to challenge, so most Visa cards first return currentStepId: "stripe-3ds" and require the 3DS step before reaching the final state. Stolen / lost / fraudulent cards hard-decline at every tier — no tier lets them through.

Mastercard sandbox. Mastercard test cards are served by an in-process emulator — the live Mastercard network is never called. The 3DS challenge is simulated: there is no real issuer challenge screen to render; advance the step by calling POST /steps/challenge and the outcome is fixed by the test card. At HIGHEST, a frictionless Mastercard card (5555…) takes the two-hold path; a passed challenge completes with no holds.

Only test cards enroll in the sandbox environment

In the dedicated sandbox environment, card enrollment accepts only the published test cards in the matrix above. Submitting any other PAN to POST /card-subscriptions is rejected up front, before any network call, with a typed 403:

{
  "title": "Card cannot be enrolled",
  "detail": "This card is not a sandbox test card. In the sandbox environment, only published Astrada test cards can be enrolled. Use a card from the sandbox test-card list: https://docs.astrada.co/docs/sandbox-testing",
  "errorCode": "card_subscription.sandbox_card_not_allowed",
  "type": "https://docs.astrada.co/reference/errors-sandbox_card_not_allowed"
}

This keeps real PANs out of the non-PCI sandbox and turns what used to be a silent downstream failure into a clear, actionable error at the door. The allowlist is matched on the full card number exactly (never a BIN or prefix). This is distinct from the per-subaccount verificationPolicy.sandbox routing above: that controls how a card is verified on the production API; this controls which cards may be enrolled at all in the dedicated sandbox environment — see Sandbox for that environment's other endpoints (transaction simulation, network bulk-feed ingestion).

Call sequence

Point the SDK at the sandbox subaccount and it drives this whole sequence for you. The raw steps below are for direct/automated API testing.

  1. Create the verificationPOST /card-verifications/3ds with a token scoped to the sandbox
    subaccount. CVC is required.

    { "pan": "4242424242424242", "expiryMonth": 12, "expiryYear": 2034, "cvc": "123", "cardholderName": "Sandbox Test", "subaccountId": "<SUBACCOUNT_ID>" }
  2. Branch on the response. When a challenge is required you get currentStepId: "stripe-3ds"
    with a clientSecret and a test-mode stripePublishableKey:

    {
      "id": "…", "currentStepId": "stripe-3ds", "state": "in-progress",
      "clientSecret": "seti_…_secret_…", "stripePublishableKey": "pk_test_…"
    }

    Load Stripe.js with that publishable key and call stripe.handleNextAction({ clientSecret }). A
    frictionless success returns state: "completed" directly (no stripe-3ds step).

  3. Complete the stepPOST /card-verifications/3ds/{verificationId}/steps/stripe-callback
    after handleNextAction resolves, then GET /card-verifications/3ds/{verificationId} to read the
    final state.

Testing the HIGHEST two-hold flow

A sandbox test card has no banking app to read the hold amounts from, so on a sandbox verification the place-holds response includes the amounts:

{ "currentStepId": "stripe-amount-confirm", "sandboxHoldAmounts": { "amount1": 57, "amount2": 88 } }

sandboxHoldAmounts is test-mode only — never present on a live verification, and never on GET. Feed the two values straight into POST /steps/amount-confirm to drive the success path. See HIGHEST Verification for the full flow.

Notes

  • This routes real test cards through the verification sandbox on the production endpoint — only on a
    subaccount flagged for sandbox use. Always use a separate sandbox subaccount.
  • The only behavioral difference on a sandbox verification is that the returned
    stripePublishableKey is the test-mode key, so the SDK drives the challenge against the test
    environment. Endpoints and request/response shapes are otherwise identical to production.
  • The verification response includes authenticationFlow (frictionless | challenge | null if
    3DS didn't run) so you can distinguish a frictionless approval from a completed challenge.

Next steps