How to use test card numbers in payment testing
To use test card numbers in payment testing, take only the numbers each payment sandbox publishes, check their Luhn digit, prefix, and length in your own code, and trigger every decline, card verification code (CVC), and address verification (AVS) outcome from one decline matrix. The check that catches the costliest defect asserts which system produced each failure.
Where test card defects come from
A checkout that accepts only 16-digit card numbers passes every run with 4242 4242 4242 4242 and rejects American Express at 15 digits, Diners Club at 14, and UnionPay at 19. Stripe publishes a test number for each of those lengths on its testing page (checked on 2026-09-22); when nobody runs them, the first customer with such a card finds the defect and the sale is lost.
Two other defects reach production the same way:
- Every failure becomes "try again". Braintree classifies processor response 2004 (Expired Card) as a hard decline where "the customer will need to use a different payment method", and 2001 (Insufficient Funds) as a soft decline where later attempts may succeed (Braintree authorization responses, checked on 2026-09-22). One generic message sends customers with an expired card into a loop that cannot succeed, and a subscription product retries hard declines at every renewal.
- A real card number in a fixture. A personal card pasted to reproduce a bug, or a production snapshot restored to staging, puts cardholder data in an environment built for test data, which breaks requirement 6.5.5 of PCI DSS unless the environment sits inside the cardholder data environment. Checkout.com writes "Never test using real card numbers or card details in your sandbox environment" (Checkout.com test cards, checked on 2026-09-22), and Stripe's Services Agreement prohibits testing in live mode with real payment method details.
Stripe advises against card numbers in server-side code, even in testing; server-side tests use PaymentMethod tokens such as pm_card_visa_chargeDeclined.
What the standard and the networks fix
The foreword of ISO/IEC 7812-1:2017 (fifth edition, January 2017) records that the issuer identification number (IIN) grew from 6 to 8 digits and the minimum primary account number (PAN) length from 8 to 10; the check digit follows the Luhn formula in normative Annex B (ISO/IEC 7812-1:2017 preview, checked on 2026-09-22). ISO's announcement of 21 November 2016 keeps the maximum PAN length at 19 digits (ISO news).
| Network | Prefix | Length | Network source |
|---|---|---|---|
| Visa | 4 | 16 | Visa BIN FAQ, January 2020, checked on 2026-09-22 |
| Visa V PAY (Europe only) | 4 | 19 in some implementations | Visa BIN FAQ, January 2020, question 7, checked on 2026-09-22 |
| Mastercard | 51 to 55, and 222100 to 272099 | Not stated in the source | Mastercard US merchant 2-series checklist, 2016 per file name (archived 2026-09-11) |
| American Express | 34 or 37 | 15 | American Express Singapore merchant card face guide, undated, checked on 2026-09-22 |
The "4" prefix comes from Visa's undated North America 8-Digit BIN Expansion FAQ for merchants, which states that Visa has no plans to issue BINs beginning with any other number. For Discover, JCB, Diners Club, UnionPay, and Maestro, take prefix and length from your processor's BIN data instead of a hard-coded table.
Procedure
1. Check the number the way the standard does
The function implements Annex B and the table; a network without a stated length returns None and falls back to BIN data. Fixtures are published numbers from Stripe and the Mastercard Gateway test cards (checked on 2026-09-22), and one of them fails the Luhn check on purpose.
def luhn_valid(pan: str) -> bool:
"""ISO/IEC 7812-1 Annex B: double every second digit from the right."""
if not pan.isdigit():
return False
total = 0
for i, ch in enumerate(reversed(pan)):
d = int(ch)
if i % 2 == 1:
d *= 2
if d > 9:
d -= 9
total += d
return total % 10 == 0
def check_digit(partial: str) -> str:
"""Digit to append to `partial` so the full number passes the Luhn check."""
for c in "0123456789":
if luhn_valid(partial + c):
return c
raise ValueError("partial must contain digits only")
def network(pan: str) -> tuple[str, set[int] | None]:
"""Prefixes and lengths stated by the networks; None means: read length from BIN data."""
if pan.startswith("4"):
return "visa", {16, 19} # 19 only for some V PAY cards in Europe
if pan[:2] in ("34", "37"):
return "amex", {15}
six = int(pan[:6]) if len(pan) >= 6 else -1
if 510000 <= six <= 559999 or 222100 <= six <= 272099:
return "mastercard", None
return "unknown", None
if __name__ == "__main__":
fixtures = {
"4242424242424242": True, # Stripe Visa
"4242424242424241": False, # Stripe incorrect_number card, fails Luhn on purpose
"2223003122003222": True, # Stripe Mastercard 2-series
"378282246310005": True, # Stripe American Express, 15 digits
"36227206271667": True, # Stripe Diners Club, 14 digits
"6205500000000000004": True, # Stripe UnionPay, 19 digits
"5000000000000000005": True, # Mastercard Gateway Maestro, 19 digits
}
for pan, expected in fixtures.items():
ok = luhn_valid(pan)
brand, lengths = network(pan)
length_ok = lengths is None or len(pan) in lengths
print(f"{pan:<20} luhn={ok!s:<5} {brand:<10} len={len(pan)} "
f"{'PASS' if ok == expected and length_ok else 'FAIL'}")
print("check digit for 424242424242424:", check_digit("424242424242424"))Save it as cardcheck.py. The run prints PASS for all seven fixtures and check digit 2.
2. Keep real card numbers out of the repository and the logs
Run this guard in continuous integration over fixtures, seed files, and logs. It fails the build on any Luhn-valid digit run missing from the allowlist of published sandbox numbers and prints only the first six and last four digits.
import re
import sys
from pathlib import Path
from cardcheck import luhn_valid
# 13 to 19 digits, optionally grouped by spaces or hyphens, not inside a longer number
CANDIDATE = re.compile(r"(?<![\d-])\d(?:[ -]?\d){12,18}(?![\d-])")
SKIP_DIRS = {".git", "node_modules", ".venv", "dist"}
def load_allowlist(path: str) -> set[str]:
lines = Path(path).read_text(encoding="utf-8").splitlines()
return {line.split("#")[0].strip() for line in lines if line.split("#")[0].strip()}
def scan(root: str, allowed: set[str]) -> list[str]:
findings = []
for file in Path(root).rglob("*"):
if not file.is_file() or SKIP_DIRS.intersection(file.parts):
continue
try:
text = file.read_text(encoding="utf-8", errors="ignore")
except OSError as exc:
findings.append(f"{file}: unreadable ({exc.strerror})")
continue
for lineno, line in enumerate(text.splitlines(), 1):
for match in CANDIDATE.finditer(line):
digits = re.sub(r"[ -]", "", match.group())
if luhn_valid(digits) and digits not in allowed:
masked = digits[:6] + "*" * (len(digits) - 10) + digits[-4:]
findings.append(f"{file}:{lineno}: unlisted Luhn-valid number {masked}")
return findings
if __name__ == "__main__":
root, allowlist = sys.argv[1], sys.argv[2]
problems = scan(root, load_allowlist(allowlist))
print("\n".join(problems) or "no unlisted card numbers")
sys.exit(1 if problems else 0)Expect noise: exactly one of ten possible final digits passes the Luhn check, so about one in ten order numbers or timestamps of card length is flagged, such as 20260922123456. Triage findings with the network prefix; never add a number to the allowlist because it looks like an order ID.
3. Trigger declines by token, name, amount, or expiry
Each sandbox selects the outcome through a different field. Stripe uses the card or token; 1000 is EUR 10.00 in minor units.
POST /v1/payment_intents HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_test_EXAMPLE_NOT_A_KEY
Content-Type: application/x-www-form-urlencoded
amount=1000¤cy=eur&confirm=true&payment_method=pm_card_visa_chargeDeclinedInsufficientFunds&automatic_payment_methods[enabled]=true&automatic_payment_methods[allow_redirects]=neverStripe answers with HTTP 402 and a card error (Stripe errors, checked on 2026-09-22). After a decline, the PaymentIntent returns to requires_payment_method so it can be retried (PaymentIntent lifecycle).
{
"error": {
"type": "card_error",
"code": "card_declined",
"decline_code": "insufficient_funds",
"payment_intent": { "id": "pi_EXAMPLE0001", "status": "requires_payment_method" }
}
}Adyen reads the outcome from holderName or from additionalData.RequestedTestAcquirerResponseCode (Adyen result codes and refusal reasons, checked on 2026-09-22). Its test cards use expiry 03/2030 and CVC 737, sent with the test_ prefix when the client-side encryption is not ready yet (Adyen test card numbers).
POST /v71/payments HTTP/1.1
Host: checkout-test.adyen.com
X-API-Key: EXAMPLE_TEST_API_KEY
Content-Type: application/json
{
"merchantAccount": "ExampleTestMerchant",
"reference": "decline-matrix-A01",
"amount": { "currency": "EUR", "value": 1000 },
"paymentMethod": {
"type": "scheme",
"encryptedCardNumber": "test_4111111145551142",
"encryptedExpiryMonth": "test_03",
"encryptedExpiryYear": "test_2030",
"encryptedSecurityCode": "test_737",
"holderName": "NOT_ENOUGH_BALANCE"
},
"returnUrl": "https://shop.example.com/checkout/return"
}Braintree selects the processor response from the amount: 2000.00 to 2999.99 returns Processor Declined with a response code equal to the amount, and 3000.00 to 3000.99 returns Failed with response 3000 (Braintree testing, checked on 2026-09-22). The Mastercard Gateway simulator selects it from the expiry date: 05/39 returns DECLINED and 08/28 returns TIMED_OUT for its standard test cards. A timeout has an unknown result, so assert a status lookup before any retry (payment idempotency guide).
Authentication and disputes have their own guides: 3DS2 challenge flows and simulating chargebacks.
4. Separate issuer declines from CVC and AVS check results
Stripe's 4000000000000127 is an issuer decline with code incorrect_cvc. Card 4000000000000101 only makes the CVC check fail, and Radar might block the payment depending on your settings. Stripe skips the CVC check when no CVC is sent, so a test without a CVC cannot fail it. Braintree returns CVV response N for CVV 200 and AVS postal code response N for postal code 20000. The Mastercard Gateway returns NO_MATCH for CSC 102 and for billing street "November St". Assert the check result and the rule that acts on it.
5. Run the decline matrix
expected_status is the payment outcome, expected_code the processor or check code, and a blank cell is not asserted. internal_outcome is an example policy of ours, not the processor's classification. Save the block as decline_matrix.csv.
case_id,sandbox,trigger,value,amount,currency,expected_status,expected_code,expected_decline,internal_outcome
S01,stripe,payment_method,pm_card_visa,10.00,EUR,succeeded,,,order paid
S02,stripe,payment_method,pm_card_visa_chargeDeclined,10.00,EUR,card_error,card_declined,generic_decline,ask for another card
S03,stripe,payment_method,pm_card_visa_chargeDeclinedInsufficientFunds,10.00,EUR,card_error,card_declined,insufficient_funds,retry later allowed
S04,stripe,payment_method,pm_card_chargeDeclinedExpiredCard,10.00,EUR,card_error,expired_card,,ask for another card
S05,stripe,payment_method,pm_card_chargeDeclinedIncorrectCvc,10.00,EUR,card_error,incorrect_cvc,,ask to re-enter CVC
S06,stripe,payment_method,pm_card_chargeDeclinedProcessingError,10.00,EUR,card_error,processing_error,,retry once
S07,stripe,payment_method,pm_card_visa_chargeDeclinedVelocityLimitExceeded,10.00,EUR,card_error,card_declined,card_velocity_exceeded,no automatic retry
B01,braintree,amount,2000.00,2000.00,USD,processor_declined,2000,,ask to contact bank
B02,braintree,amount,2001.00,2001.00,USD,processor_declined,2001,,retry later allowed
B03,braintree,amount,3000.00,3000.00,USD,failed,3000,,retry once
B04,braintree,cvv,200,10.00,USD,,N,,apply CVV mismatch rule
B05,braintree,postal_code,20000,10.00,USD,,N,,apply AVS mismatch rule
A01,adyen,holderName,NOT_ENOUGH_BALANCE,10.00,EUR,Refused,,,retry later allowed
A02,adyen,holderName,CARD_EXPIRED,10.00,EUR,Refused,,,ask for another card
A03,adyen,holderName,CVC_DECLINED,10.00,EUR,Refused,,,ask to re-enter CVC
A04,adyen,holderName,AVS_DECLINED,10.00,EUR,Refused,,,ask to check billing address
M01,mastercard_gateway,expiry,05/39,10.00,USD,DECLINED,,,ask for another card
M02,mastercard_gateway,expiry,08/28,10.00,USD,TIMED_OUT,,,query status before retry
M03,mastercard_gateway,csc,102,10.00,USD,,NO_MATCH,,apply CVV mismatch rule
M04,mastercard_gateway,street,November St,10.00,USD,,NO_MATCH,,apply AVS mismatch ruleSave the runner next to decline_matrix.csv. It executes the Stripe rows with a test-mode key and reports every row, including timeouts and non-JSON bodies. Other sandboxes need an adapter returning the same three values.
import csv
import http.client
import json
import os
import sys
import urllib.error
import urllib.parse
import urllib.request
import uuid
from decimal import Decimal
KEY = os.environ.get("STRIPE_TEST_KEY", "")
if not KEY.startswith("sk_test_"):
sys.exit("set STRIPE_TEST_KEY to a test-mode secret key (sk_test_...)")
RUN_ID = uuid.uuid4().hex[:8]
def stripe_attempt(row: dict) -> tuple[str, str, str]:
body = urllib.parse.urlencode({
"amount": int(Decimal(row["amount"]) * 100), # EUR and USD: two decimals
"currency": row["currency"].lower(),
"confirm": "true",
"payment_method": row["value"],
"automatic_payment_methods[enabled]": "true",
"automatic_payment_methods[allow_redirects]": "never",
}).encode()
request = urllib.request.Request(
"https://api.stripe.com/v1/payment_intents",
data=body,
headers={"Authorization": f"Bearer {KEY}",
"Idempotency-Key": f"matrix-{row['case_id']}-{RUN_ID}"},
)
try:
try:
with urllib.request.urlopen(request, timeout=20) as response:
status, raw = response.status, response.read()
except urllib.error.HTTPError as exc: # 4xx and 5xx still carry a body
status, raw = exc.code, exc.read()
except (http.client.HTTPException, OSError) as exc: # timeouts, resets, IncompleteRead
return "no_response", type(exc).__name__, ""
try:
data = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError):
return f"http_{status}_not_json", "", ""
if "error" in data:
error = data["error"]
return error.get("type", ""), error.get("code") or "", error.get("decline_code") or ""
return data.get("status", ""), "", ""
failures = 0
with open("decline_matrix.csv", newline="", encoding="utf-8") as matrix:
for row in csv.DictReader(matrix):
if row["sandbox"] != "stripe":
continue
status, code, decline = stripe_attempt(row)
ok = (status == row["expected_status"] and code == row["expected_code"]
and (not row["expected_decline"] or decline == row["expected_decline"]))
failures += not ok
print(f"{row['case_id']} {'PASS' if ok else 'FAIL'} got {status} {code} {decline}".rstrip())
sys.exit(1 if failures else 0)Pitfalls the sandbox pages do not flag
Expiry triggers expire. The Mastercard Gateway maps 04/27 to EXPIRED_CARD and 08/28 to TIMED_OUT. From May 2027, a client-side check that treats a card as valid through its expiry month rejects 04/27 before any request is sent. A test that asserts only "payment failed" keeps passing while its processor path is dead, and the timeout case follows from September 2028. Assert that the response carries a processor code. The same value also changes meaning between sandboxes: 05/39 is an ordinary future date on Stripe and Checkout.com, which accept any future expiry, and a decline trigger on the Mastercard Gateway.
Amount triggers catch real prices. Braintree applies its amount ranges "regardless of the processing currency", so a JPY 2,500 order, a USD 2,400 annual plan, or any sandbox fixture priced between 2000.00 and 2999.99 declines. Keep happy-path fixtures inside a range Braintree authorises, 0.01 to 1999.99 or 3001.00 to 4000.99, and give each trigger amount its own named case. In subscription billing, renewal retries after a soft decline rerun the same amount.
A Luhn-invalid test card never reaches the server. A client-side Luhn check blocks Stripe's 4242424242424241 before it can return incorrect_number. Test the client check in the browser and your incorrect_number mapping with a recorded response.
Six-digit BIN logic misroutes. Visa's undated North America merchant FAQ states that returned eight-digit BINs can go to new issuers sharing the original six-digit prefix, so it is critical to identify BINs to the eighth digit (at a minimum) or to the nine-digit account range (preferred). The same FAQ says BIN tables purchased off the internet are only approximately 30 percent accurate. Test two synthetic eight-digit BINs under one six-digit prefix with different product types, and assert that the lookup returns the right one.
Checklist before release
| Check | Pass condition |
|---|---|
| Card lengths | 14, 15, 16, and 19 digit sandbox numbers reach the processor |
| Real card numbers | CI guard passes; the allowlist holds only published sandbox numbers |
| Hard declines | Expired card asks for another card, with no automatic retry |
| Soft declines | Insufficient funds allows a later retry under your retry policy |
| Timeouts | TIMED_OUT and no-response cases look up the payment before any retry |
| Failure source | Every failed case carries a processor code or a named local validation error |
| Expiry fixtures | No trigger expiry falls within the next 12 months of the test calendar |
| Amount fixtures | Happy-path amounts avoid every sandbox trigger range |
Our payment flow testing builds this matrix into its test suite for authorisation, capture, and refund across processors, for acquiring and subscription products, with published test cards and synthetic data only.
Related
Standards
Guides
Procedure on h2check.org
Questions
What is the Luhn algorithm?
What is a BIN number in banking?
What is AVS mismatch?
Scope your testing
Send FintechAssuranceLabs your product, segment, money flows, and the standards that need evidence, and get a proposed test scope with its deliverables.
Scope your testing