Overview
MockCard is a zero-setup REST API for writing real payment tests without a sandbox. Generate Luhn-valid mock cards across four networks — Visa, MasterCard, RuPay, and Amex — and simulate every payment outcome your code needs to handle: approvals, declines, 3DS challenges, network timeouts, and edge-case failure modes like webhook race conditions and delivery failures.
Every response includes a webhook_event object showing the exact payload a real gateway would POST to your server. Supply a webhook_url and the event is delivered asynchronously with HMAC-SHA256 signing, automatic retries (up to 3 attempts), and optional race condition simulation — so you can test your idempotency logic before it breaks in production.
| Property | Value |
|---|---|
| Base URL | https://www.mockcard.io |
| Protocol | HTTPS / HTTP |
| Format | JSON (application/json) |
| Authentication | None — no API key required |
| Webhook retries | Up to 3 attempts · exponential backoff (0 s → 2 s → 4 s) |
| Race condition testing | simulate_race: true fires a duplicate delivery ~1 s later |
Want to see it before you code?
The live Playground lets you fire a real API request, inspect the JSON response, and watch the webhook event arrive — all in your browser.
Quick Start
Send a single POST request. No API key, no sign-up, no SDK required.
curl -X POST https://www.mockcard.io/api/v1/generate \
-H "Content-Type: application/json" \
-d '{
"brand": "visa",
"scenario": "success",
"amount": 1000,
"currency": "inr"
}'POST /api/v1/generate
The core endpoint. Returns a mock card and a simulated webhook event. For error scenarios the HTTP status reflects the gateway outcome (402, 504) and the card field is absent from the response.
Request body
| Field | Type | Default | Description |
|---|---|---|---|
| brand | string | visa | Card network. One of: visa · mastercard · rupay · amex |
| scenario | string | success | Test scenario (see Scenarios section below) |
| gateway | string | stripe | Response format to simulate. stripe (default) or razorpay — shapes both the error body and webhook payload to match that gateway's wire format. |
| amount | integer | 1000 | Amount in smallest currency unit (e.g. paise) |
| currency | string | inr | ISO 4217 currency code (3 chars) |
| webhook_url Pro | string? | null | If provided, the event is POSTed here after the response is sent. Must be https:// (or http://localhost for local testing). Silently ignored on free plans. |
| simulate_race Pro | boolean | false | Fire the webhook twice ~1 s apart with the same event id — tests idempotency handling. Requires a webhook_url. |
Response — 201 Created (success scenario)
{
"status": "success",
"payment_intent_id": "pi_mock_x7y8z9a0b1c2",
"scenario": "success",
"card": {
"brand": "visa",
"card_number": "4539578763621486",
"masked_number": "••••••••••••1486",
"expiry_month": "07",
"expiry_year": "2028",
"cvv": "382",
"cardholder_name": "PRIYA PATEL",
"luhn_valid": true
},
"webhook_event": {
"id": "evt_mock_a1b2c3d4e5f6",
"object": "event",
"type": "payment_intent.succeeded",
"created": 1741708800,
"livemode": false,
"data": {
"object": {
"id": "pi_mock_x7y8z9a0b1c2",
"object": "payment_intent",
"amount": 1000,
"currency": "inr",
"status": "succeeded",
"payment_method_details": {
"type": "card",
"card": { "brand": "visa", "last4": "1486", "exp_month": "07", "exp_year": "2028" }
}
}
},
"delivery": null
}
}Response — 402 / 504 (error scenarios)
{
"error": {
"code": "card_declined",
"message": "Your card has insufficient funds.",
"decline_code": "insufficient_funds"
},
"webhook_event": {
"id": "evt_mock_d4e5f6g7h8i9",
"object": "event",
"type": "payment_intent.payment_failed",
"created": 1741708800,
"livemode": false,
"data": {
"object": {
"id": "pi_mock_j0k1l2m3n4o5",
"object": "payment_intent",
"amount": 1000,
"currency": "inr",
"status": "requires_payment_method",
"last_payment_error": {
"code": "card_declined",
"decline_code": "insufficient_funds",
"message": "Your card has insufficient funds.",
"type": "card_error"
}
}
},
"delivery": null
}
}Top-level response fields
| Field | Type | Description |
|---|---|---|
payment_intent_id | string | Your transaction reference — store this in your DB. pi_mock_xxx for Stripe, pay_mock_xxx for Razorpay. Matches webhook_event.data.object.id (Stripe) or webhook_event.payload.payment.entity.id (Razorpay). |
log_id | integer | null | MockCard's internal log row — use it to correlate support requests. |
webhook_event | object | Full event payload that was (or would be) delivered to your webhook_url. Shape depends on gateway. |
card | object | absent | Present only on 201 success responses. See card fields below. |
error | object | absent | Present on all non-2xx responses. Shape depends on gateway (see Error Codes section). |
Response fields — card object
| Field | Type | Description |
|---|---|---|
| brand | string | Card network (visa · mastercard · rupay · amex) |
| card_number | string | Full 15 or 16-digit PAN, Luhn-valid |
| masked_number | string | PAN with all but last 4 replaced by • |
| expiry_month | string | Two-digit month (01–12) |
| expiry_year | string | Four-digit year (current year + 1 to + 5) |
| cvv | string | 3-digit CVV (4-digit CID for Amex) |
| cardholder_name | string | Uppercase cardholder name |
| luhn_valid | boolean | Always true — confirms Luhn checksum passes |
Card Brands
BIN prefixes follow the official IIN ranges. Luhn check digit is computed and appended so every generated number passes real gateway validation.
| Brand | Enum value | BIN prefixes | Card length |
|---|---|---|---|
| Visa | visa | 4 | 16 |
| MasterCard | mastercard | 51–55, 2221–2720 | 16 |
| RuPay | rupay | 60, 65, 81, 82 | 16 |
| Amex | amex | 34, 37 | 15 |
Test Scenarios
Pass any of the following values as the scenario field. The API returns the appropriate HTTP status, error payload, and webhook event for each. Webhook event types shown are for gateway: stripe (default) — see Gateway Simulation for Razorpay equivalents.
Standard scenarios
| Scenario | Enum value | HTTP status | Stripe event type | Description |
|---|---|---|---|---|
| Success | success | 201 | payment_intent.succeeded | Approved — valid card returned |
| Insufficient Funds | insufficient_funds | 402 | payment_intent.payment_failed | Card declined: no funds |
| Do Not Honor | do_not_honor | 402 | payment_intent.payment_failed | Generic issuer decline |
| Expired Card | expired_card | 402 | payment_intent.payment_failed | Card past its expiry date |
| Incorrect CVV | incorrect_cvv | 402 | payment_intent.payment_failed | Security code mismatch |
| 3DS Challenge | 3ds_challenge | 402 | payment_intent.requires_action | Bank redirect required |
| Network Timeout | network_timeout | 504 | payment_intent.payment_failed | 10 s gateway lag, then 504 |
Chaos scenarios Pro
These scenarios simulate edge cases that production systems fail to handle — and that no sandbox exposes. Available on Pro plans only.
| Scenario | Enum value | HTTP status | Stripe event type | Description |
|---|---|---|---|---|
| 3DS Abandoned | 3ds_abandoned | 402 | payment_intent.canceled | User closes the 3DS modal — intent is cancelled, payment_intent.canceled fires immediately |
| Limbo | limbo | 201 | payment_intent.succeeded | Payment captured, 201 returned — but webhook is intentionally never delivered. Tests reconciliation logic. |
| Latency | latency | 201 | payment_intent.succeeded | Payment succeeds, webhook delayed 9 s — triggers timeout-then-retry double-processing bugs. |
Gateway Simulation
Add "gateway": "razorpay"to any request and MockCard returns the response and webhook payload in Razorpay's exact wire format — including their error envelope, payment entity structure, and signature header. No Razorpay account, no sandbox key, no webhook config needed.
The card data is always MockCard's own format (same across all gateways). Only the webhook_event shape and error body change.
Stripe vs Razorpay — at a glance
| Property | Stripe (default) | Razorpay |
|---|---|---|
| payment_intent_id prefix | pi_mock_xxx | pay_mock_xxx |
| Success event | payment_intent.succeeded | payment.captured |
| Failure event | payment_intent.payment_failed | payment.failed |
| 3DS event | payment_intent.requires_action | payment.authorized |
| Error envelope | error.code + decline_code | error.code + reason + source + step + metadata.payment_id |
| Signature header | X-MockCard-Signature: sha256=… | X-Razorpay-Signature: <hex> |
| Event field | X-MockCard-Event | X-Razorpay-Event-Type |
Razorpay error codes
| Scenario | code | reason | source | step |
|---|---|---|---|---|
| insufficient_funds | BAD_REQUEST_ERROR | insufficient_balance | customer | payment_authorization |
| do_not_honor | BAD_REQUEST_ERROR | card_declined | customer | payment_authorization |
| expired_card | BAD_REQUEST_ERROR | card_expired | customer | payment_initiation |
| incorrect_cvv | BAD_REQUEST_ERROR | incorrect_cvc | customer | payment_initiation |
| network_timeout | GATEWAY_ERROR | gateway_timeout | internal | payment_authorization |
| 3ds_abandoned | BAD_REQUEST_ERROR | authentication_failed | customer | payment_authentication |
Verifying Razorpay webhook signatures
Razorpay uses plain HMAC-SHA256 hex — no sha256= prefix. Check the X-Razorpay-Signature header instead of X-MockCard-Signature.
import { createHmac } from "crypto";
// Razorpay uses plain HMAC-SHA256 hex — no "sha256=" prefix
function verifyRazorpayWebhook(
rawBody: string, // req.body as raw string (express.text() middleware)
signature: string, // X-Razorpay-Signature header
secret: string // your WEBHOOK_SECRET env var
): boolean {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
return expected === signature;
}
app.post("/webhook", express.text({ type: "*/*" }), (req, res) => {
const sig = req.headers["x-razorpay-signature"] as string;
if (!verifyRazorpayWebhook(req.body, sig, process.env.WEBHOOK_SECRET!)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body);
const payment = event.payload.payment.entity;
if (event.event === "payment.captured") {
// fulfil order — payment.id is your transaction reference
await db.orders.fulfill(payment.id);
}
res.status(200).send("ok");
});Webhook Events
Every API response includes a webhook_event field showing the exact payload that would be sent to your server. If you supply a webhook_url, the event is delivered as a background HTTP POST after the API responds — it does not block your request.
Event types
| Scenario | Event type | PaymentIntent status | Notes |
|---|---|---|---|
| success | payment_intent.succeeded | succeeded | payment_method_details.card populated with brand + last4 |
| insufficient_funds | payment_intent.payment_failed | requires_payment_method | last_payment_error.decline_code = insufficient_funds |
| do_not_honor | payment_intent.payment_failed | requires_payment_method | last_payment_error.decline_code = do_not_honor |
| expired_card | payment_intent.payment_failed | requires_payment_method | last_payment_error.decline_code = expired_card |
| incorrect_cvv | payment_intent.payment_failed | requires_payment_method | last_payment_error.decline_code = incorrect_cvc |
| 3ds_challenge | payment_intent.requires_action | requires_action | Not a decline — next_action.redirect_to_url present; last_payment_error is null |
| network_timeout | payment_intent.payment_failed | requires_payment_method | last_payment_error.code = gateway_timeout, type = api_error |
Sample payloads — Stripe
Exact JSON body POSTed to your webhook_url when gateway: stripe (default).
// Headers
// X-MockCard-Event: payment_intent.succeeded
// X-MockCard-Signature: sha256=a3f9c12b8e1d…
{
"id": "evt_mock_a3f9c12b8e1d",
"type": "payment_intent.succeeded",
"created": 1741843200,
"data": {
"object": {
"id": "pi_mock_7b2e4d9a1c3f",
"object": "payment_intent",
"amount": 1000,
"currency": "inr",
"status": "succeeded",
"payment_method_details": {
"card": {
"brand": "visa",
"last4": "1486",
"exp_month": "07",
"exp_year": "2028"
}
},
"last_payment_error": null,
"next_action": null
}
}
}Sample payloads — Razorpay
Same scenarios with gateway: razorpay. All decline scenarios share the same shape — only error_reason differs.
// Headers
// X-Razorpay-Event-Type: payment.captured
// X-Razorpay-Signature: a3f9c12b8e1d… (plain HMAC-SHA256 hex — no "sha256=" prefix)
{
"entity": "event",
"account_id": "acc_mock_f9e8d7c6b5a4",
"event": "payment.captured",
"contains": ["payment"],
"payload": {
"payment": {
"entity": {
"id": "pay_mock_7b2e4d9a1c3f",
"entity": "payment",
"amount": 1000,
"currency": "INR",
"status": "captured",
"method": "card",
"card": {
"id": "card_mock_x1y2z3a4b5c6",
"entity": "card",
"name": "PRIYA PATEL",
"network": "Visa",
"last4": "1486",
"expiry_month": "07",
"expiry_year": "2028"
}
}
}
},
"created_at": 1741843200
}Delivery reliability
MockCard retries failed deliveries up to 3 attempts with exponential backoff (0 s → 2 s → 4 s). Your endpoint should return a 2xx status to acknowledge receipt. Any 4xx/5xx or connection error triggers the next retry.
| Attempt | Delay | Behaviour |
|---|---|---|
| 1 | immediate | First delivery |
| 2 | +2 s | Retry on any 4xx/5xx or network error |
| 3 | +4 s | Final retry — outcome written to api_logs |
Race condition simulation
Set simulate_race: true to fire an identical duplicate delivery ~1 second after the first. Both requests carry the same event id — exactly what happens when a real gateway retries a timed-out HTTP request. Your handler must deduplicate on the event id (e.g. a DB unique constraint or a Redis SET NX check) to avoid double-processing.
Delivery headers
| Header | Stripe | Razorpay |
|---|---|---|
Content-Type | application/json | application/json |
Event header | X-MockCard-Event: payment_intent.succeeded | X-Razorpay-Event-Type: payment.captured |
Signature header | X-MockCard-Signature: sha256=<hex> | X-Razorpay-Signature: <hex> (no prefix) |
Verifying the signature
Compute HMAC-SHA256 over the raw request body bytes using your WEBHOOK_SECRET and compare to the X-MockCard-Signature header. Always use a constant-time comparison to prevent timing attacks.
import { createHmac } from "crypto";
// Call this BEFORE json.parse() — sign the raw body string, not the object
function verifyWebhook(
rawBody: string, // req.body as raw string (use express.text() middleware)
signature: string, // X-MockCard-Signature header
secret: string // your WEBHOOK_SECRET env var
): boolean {
const expected =
"sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex");
return expected === signature;
}
// Express.js example
app.post("/webhook", express.text({ type: "*/*" }), (req, res) => {
const sig = req.headers["x-mockcard-signature"] as string;
if (!verifyWebhook(req.body, sig, process.env.WEBHOOK_SECRET!)) {
return res.status(400).send("Invalid signature");
}
const event = JSON.parse(req.body);
if (event.type === "payment_intent.succeeded") {
// fulfil order…
}
res.status(200).send("ok");
});Error Codes
All non-2xx responses use a consistent { error: { code, message, decline_code? } } envelope so you can handle every case with a single switch statement.
| code | decline_code | HTTP status | Description |
|---|---|---|---|
| card_declined | insufficient_funds | 402 | No funds available |
| card_declined | do_not_honor | 402 | Generic issuer decline |
| expired_card | expired_card | 402 | Card past expiry |
| incorrect_cvc | incorrect_cvc | 402 | CVV mismatch |
| authentication_required | — | 402 | 3DS redirect needed |
| gateway_timeout | — | 504 | Upstream timeout (10 s lag) |
| validation_error | — | 422 | Invalid request body field |
Failure Scenarios
Real payment flows break in ways that happy-path tests never catch. MockCard exposes four classes of failure — visit the Chaos Simulator to see live payloads, then reproduce them in your CI using the parameters below.
1 · Webhook Race Condition Pro
A real gateway may deliver the same webhook twice when its first HTTP attempt times out before your server responds. Both carry the same event id, arriving ~1 s apart. Without deduplication, a double-delivery double-credits the user.
| How to trigger | What to fix |
|---|---|
Add simulate_race: true + a webhook_url. Works with any scenario. | Reject any event whose id was already processed — DB UNIQUE constraint on event_id or Redis SET NX. |
2 · 3DS Abandonment Pro
User closes the 3DS modal before completing authentication. The gateway fires payment_intent.canceled immediately — your app must cancel the order rather than leaving it in pending.
| How to trigger | What to fix |
|---|---|
| 3ds_abandoned — fires the canceled event directly, no manual interaction needed. | Handle payment_intent.canceled (Stripe) or payment.failed with reason=authentication_failed (Razorpay) — mark the order as cancelled, not just pending. |
3 · The Limbo State Pro
Payment is captured and a 201 is returned — but the webhook is intentionally never delivered. The order stays pending with no automated recovery path. Tests your reconciliation / polling logic.
| How to trigger | What to fix |
|---|---|
| limbo — returns 201 success with a valid card, silences the webhook entirely. | Build a reconciliation job that polls for captured payments with no matching fulfilled order and re-queues or flags them for manual review. |
4 · Latency Storm Pro
Payment succeeds instantly, but webhook delivery is delayed 9 s per attempt. On default Nginx / ALB timeout configs (10 s), your server closes the connection and MockCard retries. If the retry succeeds your handler runs twice for the same event.
| How to trigger | What to fix |
|---|---|
| latency — returns 201 immediately, delays all webhook delivery attempts by 9 s. | Make your handler idempotent and deduplicate on event id — the same event arriving twice after a timeout must be a no-op. |