3DS Cardinal Commerce: Prod vs Test Data

Cardinal Commerce's 3DS integration fails in a specific, maddening way: your test suite is green, you promote to staging, and authentication starts returning FAILURE on cards that should enroll. The root cause is almost never the code — it's that production and test data for Cardinal's 3DS stack differ on at least six dimensions, and most teams only know about two of them (the API key and the endpoint URL). The rest silently corrupt your test results.

The structural problem is that Cardinal's CCA (Consumer Credit Authentication) flow is stateful. A transaction moves through cmpi_lookup, cmpi_authenticate, and an optional cmpi_pa step, and the data contract at each step is different between the sandbox and production environments. JWT signing keys, BIN ranges, enrollment flags, and ACS simulator behavior all change. Treating these environments as "the same but with a different base URL" is the mistake.

By the end of this article you'll know exactly which fields diverge, how to generate synthetic Cardinal-compatible test data without touching production credentials, and how to gate environment-specific payloads in CI so the wrong data never reaches the wrong environment.

Manage All Your AI API Keys in One Place

Securely manage keys for 60+ AI providers in one encrypted vault instead of juggling them across apps.

Learn more

What Actually Differs Between Cardinal Sandbox and Production

Cardinal Commerce operates two distinct environments under the cardinalcommerce.com domain: a sandbox (sometimes called the "centinel" sandbox) and production. The differences are not cosmetic. API credentials (your processorId, apiIdentifier, and JWT signing secret) are environment-scoped and non-interchangeable — using a sandbox JWT against the production endpoint returns a 401, but using a production JWT against the sandbox endpoint returns a 200 with a silently wrong response body. That second failure mode is the dangerous one.

Beyond credentials, the BIN (Bank Identification Number) ranges that trigger enrollment are different. Cardinal's sandbox ships with a fixed set of test BINs (documented in their integration guide) that always return a specific Enrolled value — Y, N, or U — regardless of the issuer. In production, enrollment is live issuer data. If you build assertions around sandbox BIN behavior and then ask when to use test vs production data, Cardinal 3DS is a clean example of where production data in a test environment is not just unnecessary — it actively breaks your test contract because live BINs return unpredictable enrollment states.

Generating Cardinal-Compatible 3DS Test Data Without Production Credentials

The practical goal is a factory that emits valid cmpi_lookup request payloads scoped to the correct environment. The fields that must vary by environment are: jti (JWT ID), iat/exp timestamps, the signing secret, the OrderDetails.OrderNumber, and — critically — the Payment.CardNumber BIN.

import jwt, time, uuid
from faker import Faker

fake = Faker()

CARDINAL_SANDBOX_BINS = ["400010", "400011", "411111", "450875"]
CARDINAL_PROD_BINS = []  # never hardcode; pull from your vault

ENV_SECRETS = {
    "sandbox": "your-sandbox-jwt-secret",
    "production": None,  # injected at runtime from secrets manager
}

def cardinal_lookup_payload(env: str = "sandbox") -> dict:
    if env == "production" and ENV_SECRETS["production"] is None:
        raise EnvironmentError("Production JWT secret must come from secrets manager.")

    bin_prefix = fake.random_element(CARDINAL_SANDBOX_BINS) if env == "sandbox" else _vault_bin()
    card_number = bin_prefix + fake.numerify("##########")  # pad to 16 digits
    now = int(time.time())

    claims = {
        "jti": str(uuid.uuid4()),
        "iat": now,
        "exp": now + 600,
        "ReferenceId": str(uuid.uuid4()),
        "Payload": {
            "OrderDetails": {
                "OrderNumber": fake.bothify("ORD-????-####"),
                "Amount": str(fake.random_int(100, 99999)),
                "CurrencyCode": "840",
            },
            "Consumer": {
                "BillingAddress": {
                    "FirstName": fake.first_name(),
                    "LastName": fake.last_name(),
                    "Address1": fake.street_address(),
                    "City": fake.city(),
                    "State": fake.state_abbr(),
                    "PostalCode": fake.postcode(),
                    "CountryCode": "840",
                },
                "Email1": fake.email(),
                "ShippingAddress": {},
            },
            "Payment": {
                "CardNumber": card_number,
                "ExpirationMonth": str(fake.random_int(1, 12)).zfill(2),
                "ExpirationYear": str(fake.random_int(2025, 2030)),
            },
        },
        "ObjectifyPayload": True,
    }
    return jwt.encode(claims, ENV_SECRETS[env], algorithm="HS256")

The CARDINAL_SANDBOX_BINS list is the load-bearing piece. Cardinal's sandbox ACS simulator routes enrollment decisions by BIN prefix, so 400010 always returns Enrolled=Y and 400011 always returns Enrolled=N. Pinning specific BINs to specific test scenarios — rather than generating random card numbers — is what makes assertions deterministic. Generation of a full suite of 500 lookup JWTs with this factory takes under 2 seconds; the previous approach of copying Postman collection examples by hand took 20+ minutes and introduced drift within a sprint.

For the cmpi_authenticate step, the payload must include the TransactionId returned by the lookup response and the Payload (the ACS response). In sandbox, Cardinal's ACS simulator returns a canned PAResStatus of Y, N, A, or U depending on the BIN. Capture these in a fixture map keyed by BIN so your authenticate-step factory can reconstruct the expected chain without a live network call. This is also where optional field null-collapse bites teams — Cardinal's authenticate response omits EciFlag entirely when PAResStatus=N, and assertions that expect an empty string instead of a missing key fail silently.

Environment gating in CI should be explicit, not inferred. A .env.test that sets CARDINAL_ENV=sandbox and a GitHub Actions step that asserts CARDINAL_ENV != production before any test job runs is a one-line safeguard that has saved more than one team from an accidental production charge during a pipeline run.

# .github/workflows/test.yml (relevant step)
- name: Assert Cardinal env is sandbox
  run: |
    if [ "$CARDINAL_ENV" = "production" ]; then
      echo "ERROR: CARDINAL_ENV must not be 'production' in CI."
      exit 1
    fi

Where Cardinal 3DS Test Data Goes Wrong in Practice

The most common mistake is copying a production JWT from a support ticket into a test fixture. It works once — the sandbox endpoint accepts it if the algorithm matches — but the exp claim is already expired, and the ReferenceId is now a used transaction ID in Cardinal's production ledger. The test passes locally because the sandbox doesn't validate exp strictly in some configurations, then fails in CI where the clock is UTC and strict mode is on. The fix is generating JWTs programmatically with a fresh iat/exp on every test run, never storing them as static fixtures. For teams managing multiple credential sets, versioning your test data artifacts alongside your code prevents the "which key goes with which environment" archaeology problem.

A second failure mode is not accounting for the 3DS2 vs 3DS1 fallback path in test data design. Cardinal's sandbox can return either protocol version depending on the BIN and the BrowserInfo fields present in the payload. Teams that only test the 3DS2 happy path miss the cmpi_pa (payer authentication) step that fires on 3DS1 fallback — and that step has a different response schema. Generate test cases for both paths explicitly; don't assume sandbox will always return 3DS2.

Myths About Production Data in Cardinal 3DS Test Environments

Myth 1: "Real card numbers make tests more realistic." They don't — they make tests a compliance liability. Cardinal's production BINs return live issuer enrollment data, which is non-deterministic and changes without notice. A BIN that enrolled yesterday may return U (unavailable) today because the issuer's ACS is down. Tests built on live BIN behavior are not tests; they're polls. Use the documented sandbox BINs for deterministic enrollment states. If you need to verify behavior with a wider BIN range, use a synthetic BIN generator constrained to the Luhn-valid test ranges Cardinal documents, not real card data. The question of whether test data can flow into production has a sharp answer here: Cardinal transaction IDs generated in sandbox are scoped to sandbox — they will not resolve in production, and the reverse is equally true.

Myth 2: "Masking production Cardinal payloads is enough to make them safe for test environments." Masking the PAN and email satisfies PII requirements, but the TransactionId and ReferenceId from a production transaction are still live references in Cardinal's system. Replaying them in sandbox can cause duplicate-transaction errors or, worse, Cardinal's fraud scoring to flag the merchant account. Masking production data is necessary but not sufficient for Cardinal payloads — you also need to replace all Cardinal-issued identifiers with freshly generated UUIDs before the payload is safe to use in any non-production context.

Cardinal Commerce 3DS test data is harder than it looks because the environment boundary is not just a URL swap — it's credentials, BIN ranges, protocol version behavior, and identifier scoping, all at once. Start by locking your factory to sandbox BINs, generating JWTs programmatically, and adding an explicit CI guard against CARDINAL_ENV=production. From there, Cardinal's own integration guide (section "Test Card Numbers") is the authoritative source for BIN-to-enrollment-state mappings — keep it pinned next to your factory code.

Note: This article is for informational purposes only and is not a substitute for professional advice. If you need guidance on specific situations described in this article, consider consulting a qualified professional.

Understanding how systems actually work is the first step toward navigating them effectively.

Browse all articles