Payment Gateway API Testing: Engineer's Guide

Payment gateway API testing checks your integration, not the provider's internal system. Run it in the gateway's sandbox with documented test cards. Verify duplicate requests with idempotency keys, signed and repeated webhooks, declines, refunds, 3DS states, and settlement records. Keep real card data and live secret keys out of tests, and reconcile each accepted payment to your order, ledger, refund, and payout records.
If you would rather not write these by hand, Qodex writes API tests from your spec and runs them on every pull request. See Qodex API testing.
The vendor behaviour here comes from Stripe's documentation, read 20 September 2026 and rechecked 22 September 2026: testing, idempotent requests, webhooks, and payout reconciliation. The same workflow applies to other gateways that offer a sandbox and signed events; check each one's documentation.
What to cover, where, and what evidence to keep. Production gets read-only checks only.
| Risk | Local stub | Provider sandbox | Assertion | Evidence to keep |
|---|---|---|---|---|
| Success | Yes | Yes | One payment, correct minor units and currency | Request id, payment id, order id |
| Duplicate request | Yes | Yes | Same key returns the first result, one payment | Idempotency key, both responses |
| Client timeout | Yes | No | Retry with the same key creates nothing new | Attempt log with timings |
| Decline | Yes | Yes, decline test card | Error code and decline code, no order fulfilled | Error body, order state |
| 3DS | Yes | Yes, authentication test card | requires_action then succeeded or retryable | Status transitions |
| Webhook tampering | Yes | No | Rejected before parsing, ledger untouched | Rejected payload hash, reason |
| Duplicate webhook | Yes | Yes, resend from dashboard | Processed once, same ledger row | Event id, handler result |
| Out-of-order webhook | Yes | No | Final state identical either way | Both orderings in the test log |
| Partial refund | Yes | Yes | Refund total never passes the charge | Refund ids and amounts |
| Failed refund | Yes | Yes, failure test path | refund.failed handled, funds accounted | Event payload, ledger entry |
| Reconciliation gap | Yes | Yes | Net ledger equals the settled batch | Report file, join keys |
What payment gateway API testing proves
The gateway is someone else's running system. You are not testing whether Stripe can authorise a card. You are testing the part you wrote and can break. That is the adapter that builds requests and reads responses, the state machine that moves an order from pending to paid to refunded, and the webhook receiver with its ledger. The rule that decides when a customer gets the goods is yours too.
That boundary keeps the suite honest. A test asserting the provider returns a given authorisation code is testing the provider. One asserting your code reacts correctly to it is testing you. Write the second kind.
Start with four questions:
Does one customer action produce one payment? Retries, refreshes and double clicks are how duplicate charges appear.
Does money in your ledger match money at the gateway? Partial refunds, fees and disputes included.
Does a hostile or repeated event change your state? A webhook endpoint is a public route anyone can call.
Does a failure leave the customer somewhere sensible? A decline is an outcome, not an exception to swallow.
For the wider strategy around ledgers, permissions, auditability, and financial controls, see our financial software testing guide.
Choose the right test environment
Three places a payment test can run, answering different questions.
A local stub you start inside the test. A small HTTP server that speaks the shape of the gateway API and returns outcomes you choose. It is deterministic and runs offline, so it can run on every pull request. It proves your logic, not that the provider still behaves the way your stub pretends.
The provider sandbox. Stripe describes a sandbox as an environment where you create objects without moving real money, and recommends a separate general sandbox for a new integration so settings and data stay isolated from live mode. Sandbox keys start with pk_test_, rk_test_ and sk_test_ (Stripe API keys, read 20 September 2026). This is where you prove the request shape, the documented test cards and the webhook contract.
Production. Read-only checks only: a status query, a report pull, a monitor that creates no payment. Stripe's instruction is short: "Don't use real card details" (Stripe testing, read 20 September 2026).
Two traps before planning the suite. Stripe warns that card-level state can carry into a later test using the same card, and advises a different test card per independent end-to-end scenario (Stripe testing, read 20 September 2026). Sandbox webhook retries are not live retries either: three times over a few hours in a sandbox, against up to three days with exponential backoff in live mode (Stripe webhooks, read 20 September 2026).
Place the stub and the sandbox at different layers, as explained in the guide to API automation testing. The stub runs on every commit; the sandbox suite is smaller, slower, and runs on merge or nightly.
A runnable local test: payment, webhook and refund
Here is a suite that, in our run, finished in under a second on Node 26.9.0. It needs nothing else: no package.json, no install, no network, no keys. Everything comes from node:test, node:assert/strict, node:http, node:crypto, node:fs/promises, node:os and node:path. The webhook receiver appends each verified event to a small inbox file in your temp folder before it answers, which stands in for the database table or queue a real app would use.
Make an empty folder, save the three blocks below into payment-gateway.test.mjs in the order they appear, then run:
node --test payment-gateway.test.mjs
The file holds three things: a stub gateway, an HTTP server started inside the test on a random port; a client standing in for your adapter; and a webhook receiver with a ledger. Outcomes are chosen by a fixture token. pm_ok succeeds, pm_declined is refused, pm_3ds needs an authentication step, and pm_slow answers after the client has stopped waiting. Those names are ours, not gateway values.
The webhook secret is generated fresh on every run with crypto.randomBytes, so there is no fixed secret to copy into an application by accident. The signature check uses timingSafeEqual rather than string equality, which keeps the comparison time independent of how much of the signature an attacker guessed right.
Part 1 of 3, the stub gateway:
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
import { appendFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// A local stub of a card gateway. It is not Stripe. It copies the behaviour
// your adapter has to survive: idempotency keys, declines, an authentication
// step, partial refunds and signed events.
const WEBHOOK_SECRET = 'whsec_local_' + randomBytes(24).toString('hex');
function makeGateway() {
const intents = new Map();
const refunds = new Map();
const idempotency = new Map();
let created = 0;
function createIntent(body) {
created += 1;
const id = 'pi_' + String(created).padStart(6, '0');
if (body.payment_method === 'pm_declined') {
return {
code: 402,
payload: { error: { code: 'card_declined', decline_code: 'generic_decline', intent: null } }
};
}
const status = body.payment_method === 'pm_3ds' ? 'requires_action' : 'succeeded';
const intent = {
id,
status,
amount: body.amount,
currency: body.currency,
order_id: body.order_id,
amount_refunded: 0
};
intents.set(id, intent);
return { code: status === 'succeeded' ? 200 : 200, payload: intent };
}
function authenticate(id, outcome) {
const intent = intents.get(id);
if (!intent) return { code: 404, payload: { error: { code: 'resource_missing' } } };
if (intent.status !== 'requires_action') {
return { code: 409, payload: { error: { code: 'intent_not_awaiting_action' } } };
}
if (outcome === 'fail') {
intent.status = 'requires_payment_method';
return { code: 402, payload: { error: { code: 'authentication_failed' }, intent } };
}
intent.status = 'succeeded';
return { code: 200, payload: intent };
}
function createRefund(body) {
const intent = intents.get(body.payment_intent);
if (!intent || intent.status !== 'succeeded') {
return { code: 404, payload: { error: { code: 'resource_missing' } } };
}
const remaining = intent.amount - intent.amount_refunded;
if (body.amount > remaining) {
return { code: 422, payload: { error: { code: 'refund_exceeds_charge', remaining } } };
}
intent.amount_refunded += body.amount;
const id = 're_' + String(refunds.size + 1).padStart(6, '0');
const refund = { id, payment_intent: intent.id, amount: body.amount, status: 'succeeded' };
refunds.set(id, refund);
return { code: 200, payload: refund };
}
const server = http.createServer((req, res) => {
let raw = '';
req.on('data', (chunk) => { raw += chunk; });
req.on('end', () => {
const body = raw ? JSON.parse(raw) : {};
const key = req.headers['idempotency-key'];
const send = (result, delayMs = 0) => {
if (key) idempotency.set(key, { raw, result });
setTimeout(() => {
res.writeHead(result.code, { 'content-type': 'application/json' });
res.end(JSON.stringify(result.payload));
}, delayMs);
};
if (key && idempotency.has(key)) {
const stored = idempotency.get(key);
if (stored.raw !== raw) {
res.writeHead(409, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: { code: 'idempotency_key_in_use' } }));
return;
}
res.writeHead(stored.result.code, { 'content-type': 'application/json' });
res.end(JSON.stringify(stored.result.payload));
return;
}
const auth = req.url.match(/^\/payment_intents\/(pi_\d+)\/authenticate$/);
if (req.method === 'POST' && req.url === '/payment_intents') {
// pm_slow answers after the client has already given up waiting.
send(createIntent(body), body.payment_method === 'pm_slow' ? 300 : 0);
return;
}
if (req.method === 'POST' && auth) {
send(authenticate(auth[1], body.outcome));
return;
}
if (req.method === 'POST' && req.url === '/refunds') {
send(createRefund(body));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: { code: 'unknown_route' } }));
});
});
return { server, intents, refunds, count: () => created };
}
Part 2 of 3, the client, the signer and the webhook receiver:
// The merchant adapter under test.
function makeClient(base) {
return async function call(path, body, { key, timeoutMs = 2000 } = {}) {
const headers = { 'content-type': 'application/json' };
if (key) headers['idempotency-key'] = key;
const res = await fetch(base + path, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeoutMs)
});
return { code: res.status, payload: await res.json() };
};
}
// The webhook receiver under test.
function sign(rawBody, secret, timestamp) {
const mac = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
return `t=${timestamp},v1=${mac}`;
}
// A durable inbox: every verified event is appended to a file before the
// receiver answers. Swap in your database table or queue in a real app.
function fileStore(path = join(tmpdir(), 'webhook-inbox-' + randomBytes(4).toString('hex') + '.ndjson')) {
return { path, append: (rawBody) => appendFile(path, rawBody + '\n') };
}
function makeReceiver(secret, { toleranceSeconds = 300, store = fileStore() } = {}) {
const ledger = new Map();
const seen = new Set();
function verify(rawBody, header, nowSeconds) {
const parts = Object.fromEntries(String(header).split(',').map((p) => p.split('=')));
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp)) return { ok: false, reason: 'malformed_header' };
if (Math.abs(nowSeconds - timestamp) > toleranceSeconds) return { ok: false, reason: 'stale_timestamp' };
const expected = Buffer.from(
createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex'),
'utf8'
);
const given = Buffer.from(String(parts.v1 || ''), 'utf8');
if (given.length !== expected.length) return { ok: false, reason: 'bad_signature' };
if (!timingSafeEqual(given, expected)) return { ok: false, reason: 'bad_signature' };
return { ok: true };
}
async function receive(rawBody, header, nowSeconds = Math.floor(Date.now() / 1000)) {
const checked = verify(rawBody, header, nowSeconds);
if (!checked.ok) return { code: 400, reason: checked.reason };
await store.append(rawBody); // stored durably before any 200 goes back
const event = JSON.parse(rawBody);
if (seen.has(event.id)) return { code: 200, reason: 'duplicate_ignored' };
seen.add(event.id);
const row = ledger.get(event.order_id) || { paid: 0, refunded: 0, fulfilled: false };
if (event.type === 'payment.succeeded') {
row.paid = event.amount;
row.fulfilled = true;
}
if (event.type === 'refund.succeeded') {
row.refunded += event.amount;
}
ledger.set(event.order_id, row);
return { code: 200, reason: 'processed' };
}
return { receive, ledger };
}
function listen(server) {
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`));
});
}
Part 3 of 3, the tests:
test('payment gateway adapter', async (t) => {
const gateway = makeGateway();
const base = await listen(gateway.server);
const call = makeClient(base);
t.after(() => gateway.server.close());
await t.test('a good card creates one succeeded payment', async () => {
const res = await call('/payment_intents', {
amount: 4500, currency: 'usd', payment_method: 'pm_ok', order_id: 'ord_1'
}, { key: 'ord_1-attempt-1' });
assert.equal(res.code, 200);
assert.equal(res.payload.status, 'succeeded');
assert.equal(res.payload.amount, 4500);
});
await t.test('the same key and body returns the first result, not a second charge', async () => {
const before = gateway.count();
const res = await call('/payment_intents', {
amount: 4500, currency: 'usd', payment_method: 'pm_ok', order_id: 'ord_1'
}, { key: 'ord_1-attempt-1' });
assert.equal(res.code, 200);
assert.equal(res.payload.id, 'pi_000001');
assert.equal(gateway.count(), before);
});
await t.test('the same key with a changed amount is rejected', async () => {
const res = await call('/payment_intents', {
amount: 9900, currency: 'usd', payment_method: 'pm_ok', order_id: 'ord_1'
}, { key: 'ord_1-attempt-1' });
assert.equal(res.code, 409);
assert.equal(res.payload.error.code, 'idempotency_key_in_use');
});
await t.test('a client timeout followed by a retry does not double charge', async () => {
const before = gateway.count();
await assert.rejects(
call('/payment_intents', {
amount: 2000, currency: 'usd', payment_method: 'pm_slow', order_id: 'ord_2'
}, { key: 'ord_2-attempt-1', timeoutMs: 50 })
);
const retry = await call('/payment_intents', {
amount: 2000, currency: 'usd', payment_method: 'pm_slow', order_id: 'ord_2'
}, { key: 'ord_2-attempt-1' });
assert.equal(retry.payload.status, 'succeeded');
assert.equal(gateway.count(), before + 1);
});
await t.test('two concurrent submits of the same attempt create one payment', async () => {
const before = gateway.count();
const body = { amount: 3300, currency: 'usd', payment_method: 'pm_ok', order_id: 'ord_7' };
const [a, b] = await Promise.all([
call('/payment_intents', body, { key: 'ord_7-attempt-1' }),
call('/payment_intents', body, { key: 'ord_7-attempt-1' })
]);
assert.equal(a.payload.id, b.payload.id);
assert.equal(gateway.count(), before + 1);
});
await t.test('a declined card returns a code and creates no payment', async () => {
const before = gateway.intents.size;
const res = await call('/payment_intents', {
amount: 1500, currency: 'usd', payment_method: 'pm_declined', order_id: 'ord_3'
}, { key: 'ord_3-attempt-1' });
assert.equal(res.code, 402);
assert.equal(res.payload.error.code, 'card_declined');
assert.equal(res.payload.error.decline_code, 'generic_decline');
assert.equal(gateway.intents.size, before);
});
await t.test('an authentication step moves requires_action to succeeded', async () => {
const started = await call('/payment_intents', {
amount: 7000, currency: 'usd', payment_method: 'pm_3ds', order_id: 'ord_4'
}, { key: 'ord_4-attempt-1' });
assert.equal(started.payload.status, 'requires_action');
const done = await call(`/payment_intents/${started.payload.id}/authenticate`, { outcome: 'pass' });
assert.equal(done.code, 200);
assert.equal(done.payload.status, 'succeeded');
});
await t.test('a failed authentication leaves the payment retryable', async () => {
const started = await call('/payment_intents', {
amount: 7000, currency: 'usd', payment_method: 'pm_3ds', order_id: 'ord_5'
}, { key: 'ord_5-attempt-1' });
const failed = await call(`/payment_intents/${started.payload.id}/authenticate`, { outcome: 'fail' });
assert.equal(failed.code, 402);
assert.equal(failed.payload.intent.status, 'requires_payment_method');
});
await t.test('partial refunds add up and an over-refund is rejected', async () => {
const paid = await call('/payment_intents', {
amount: 5000, currency: 'usd', payment_method: 'pm_ok', order_id: 'ord_6'
}, { key: 'ord_6-attempt-1' });
const first = await call('/refunds', { payment_intent: paid.payload.id, amount: 1500 });
assert.equal(first.code, 200);
const second = await call('/refunds', { payment_intent: paid.payload.id, amount: 3500 });
assert.equal(second.code, 200);
const third = await call('/refunds', { payment_intent: paid.payload.id, amount: 100 });
assert.equal(third.code, 422);
assert.equal(third.payload.error.code, 'refund_exceeds_charge');
assert.equal(third.payload.error.remaining, 0);
});
});
test('webhook receiver', async (t) => {
const now = Math.floor(Date.now() / 1000);
const body = (event) => JSON.stringify(event);
const paid = body({ id: 'evt_1', type: 'payment.succeeded', order_id: 'ord_1', amount: 4500 });
await t.test('a valid signature is accepted and fulfils the order', async () => {
const r = makeReceiver(WEBHOOK_SECRET);
assert.equal((await r.receive(paid, sign(paid, WEBHOOK_SECRET, now), now)).code, 200);
assert.equal(r.ledger.get('ord_1').fulfilled, true);
});
await t.test('a tampered body is rejected', async () => {
const r = makeReceiver(WEBHOOK_SECRET);
const header = sign(paid, WEBHOOK_SECRET, now);
const tampered = paid.replace('4500', '1');
const res = await r.receive(tampered, header, now);
assert.equal(res.code, 400);
assert.equal(res.reason, 'bad_signature');
assert.equal(r.ledger.size, 0);
});
await t.test('a signature from another secret is rejected', async () => {
const r = makeReceiver(WEBHOOK_SECRET);
const other = 'whsec_local_' + randomBytes(24).toString('hex');
assert.equal((await r.receive(paid, sign(paid, other, now), now)).reason, 'bad_signature');
});
await t.test('a stale timestamp is rejected', async () => {
const r = makeReceiver(WEBHOOK_SECRET);
const old = now - 3600;
assert.equal((await r.receive(paid, sign(paid, WEBHOOK_SECRET, old), now)).reason, 'stale_timestamp');
});
await t.test('the event is stored before the 200 goes back', async () => {
const stored = [];
const r = makeReceiver(WEBHOOK_SECRET, { store: { append: async (raw) => { stored.push(raw); } } });
assert.equal((await r.receive(paid, sign(paid, WEBHOOK_SECRET, now), now)).code, 200);
assert.deepEqual(stored, [paid]);
});
await t.test('a redelivered event is processed once', async () => {
const r = makeReceiver(WEBHOOK_SECRET);
const header = sign(paid, WEBHOOK_SECRET, now);
assert.equal((await r.receive(paid, header, now)).reason, 'processed');
assert.equal((await r.receive(paid, header, now)).reason, 'duplicate_ignored');
assert.equal(r.ledger.get('ord_1').paid, 4500);
});
await t.test('events arriving out of order settle to the same ledger row', async () => {
const refunded = body({ id: 'evt_2', type: 'refund.succeeded', order_id: 'ord_1', amount: 1500 });
const forwards = makeReceiver(WEBHOOK_SECRET);
await forwards.receive(paid, sign(paid, WEBHOOK_SECRET, now), now);
await forwards.receive(refunded, sign(refunded, WEBHOOK_SECRET, now), now);
const backwards = makeReceiver(WEBHOOK_SECRET);
await backwards.receive(refunded, sign(refunded, WEBHOOK_SECRET, now), now);
await backwards.receive(paid, sign(paid, WEBHOOK_SECRET, now), now);
assert.deepEqual(forwards.ledger.get('ord_1'), backwards.ledger.get('ord_1'));
assert.equal(backwards.ledger.get('ord_1').refunded, 1500);
});
});
test('reconciliation', async (t) => {
await t.test('net settled amount matches the ledger', async () => {
const now = Math.floor(Date.now() / 1000);
const r = makeReceiver(WEBHOOK_SECRET);
const events = [
{ id: 'evt_10', type: 'payment.succeeded', order_id: 'ord_a', amount: 4500 },
{ id: 'evt_11', type: 'payment.succeeded', order_id: 'ord_b', amount: 2000 },
{ id: 'evt_12', type: 'refund.succeeded', order_id: 'ord_b', amount: 500 },
{ id: 'evt_12', type: 'refund.succeeded', order_id: 'ord_b', amount: 500 }
];
for (const event of events) {
const raw = JSON.stringify(event);
await r.receive(raw, sign(raw, WEBHOOK_SECRET, now), now);
}
const net = [...r.ledger.values()].reduce((sum, row) => sum + row.paid - row.refunded, 0);
const payoutFromGateway = 6000;
assert.equal(net, payoutFromGateway);
});
});
Twenty tests, passing on Node 26.9.0. The default reporter prints a line per test; TAP prints the same run as a summary:
node --test --test-reporter=tap payment-gateway.test.mjs | tail -8
# tests 20
# suites 0
# pass 20
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 440.462
The suite also checks the tests have teeth. Delete the two comparison lines in the verify function, so the receiver returns ok for any header. Two tests then fail, the tampered body and the signature from another secret, and the run reports 17 passed and 3 failed (the two tests plus the webhook group that holds them).
The stub proves merchant logic. It says nothing about whether the provider still accepts the request you build, which is what a second, smaller suite is for. It takes its key from the environment, prints no key, and skips itself when the variable is absent. We ran it without a key, so the output below shows the skip, not a live sandbox call. Save it as stripe-sandbox.test.mjs:
import test from 'node:test';
import assert from 'node:assert/strict';
const KEY = process.env.STRIPE_TEST_SECRET_KEY;
test('sandbox contract: a documented test card is accepted', { skip: !KEY && 'STRIPE_TEST_SECRET_KEY is not set' }, async () => {
assert.ok(KEY.startsWith('sk_test_'), 'refusing to run against a key that is not a sandbox key');
const form = new URLSearchParams({
amount: '4500',
currency: 'usd',
'payment_method_data[type]': 'card',
'payment_method_data[card][token]': 'tok_visa',
confirm: 'true',
'automatic_payment_methods[enabled]': 'true',
'automatic_payment_methods[allow_redirects]': 'never',
'metadata[order_id]': 'ord_smoke_1'
});
const res = await fetch('https://api.stripe.com/v1/payment_intents', {
method: 'POST',
headers: {
authorization: `Bearer ${KEY}`,
'content-type': 'application/x-www-form-urlencoded',
'idempotency-key': 'ord_smoke_1-attempt-1'
},
body: form
});
const payload = await res.json();
assert.equal(res.status, 200);
assert.equal(payload.status, 'succeeded');
assert.equal(payload.metadata.order_id, 'ord_smoke_1');
});
Run it the same way. Without the variable it reports one skipped test and no failures:
node --test --test-reporter=tap stripe-sandbox.test.mjs | tail -8
# tests 1
# suites 0
# pass 0
# fail 0
# cancelled 0
# skipped 1
# todo 0
# duration_ms 105.778625
tok_visa is one of the test tokens Stripe's testing page lists, and the interactive equivalent is 4242 4242 4242 4242 with any future expiry and any three-digit CVC (Stripe testing, read 22 September 2026). Keep this suite small: each test costs a round trip and shares state with everything else in that sandbox.
Test idempotency and retries
An idempotency key is how you say "this is the same attempt, not a new one". Stripe saves the status code and body of the first request for a key, whether it succeeded or failed, and returns that saved result to later requests with the same key. Keys run up to 255 characters, apply to POST requests, and can be removed once they are at least 24 hours old. The layer compares incoming parameters with the original request and errors when they differ (Stripe idempotent requests, read 20 September 2026).
Four tests cover it, and the suite above has all four.
Same key, same body. The second call returns the first payment id. Assert the payment count, not just the response.
Same key, changed body. Rejected. A checkout that lets the customer edit the amount and resubmit under the old key is a bug caught here.
Timeout then retry. Abort the first request before the response arrives, retry with the same key, assert one payment exists. This is the case that produces real duplicate charges: the client never learned the first attempt worked.
Two concurrent submits. Fire both without awaiting the first. One wins, the other returns the stored result or a conflict, and the count stays at one.
The key has to be stable and derived from the attempt, not from the clock. Order id plus attempt number survives a process restart. A fresh UUID per HTTP call is the same as having no key at all.
Test webhooks as hostile input
Your webhook route is a public HTTPS endpoint, so treat every request as untrusted until the signature checks out. Stripe verifies with three inputs: the unmodified raw body, the Stripe-Signature header, and the endpoint's signing secret. Its libraries default to a five-minute tolerance between the signed timestamp and the current time, and the secret differs between the test and live version of one endpoint (Stripe webhooks, read 20 September 2026).
The raw body detail bites people. A framework that parses JSON and hands you an object has already thrown away the bytes that were signed. Capture the raw buffer before any body parser touches it, and sign those bytes in the test too.
Cases worth a test each.
Valid signature. Accepted, and the ledger row moves.
Wrong secret. Rejected with 400, nothing written.
Tampered body. Change the amount by one unit, keep the header. Rejected.
Stale timestamp. Sign correctly with a timestamp an hour old. Rejected, which stops a captured request being replayed later.
Duplicate event id. Processed once. Stripe says endpoints might receive the same event more than once and recommends logging processed event ids (Stripe webhooks, read 20 September 2026).
Reordering. Stripe's wording: "Stripe doesn't guarantee the delivery of events in the order that they're generated" (Stripe webhooks, read 20 September 2026). Deliver a refund event before the payment event and assert the ledger lands where the forward order left it.
Slow handler. Persist or enqueue the event durably, then acknowledge with 2xx, then do the slow work from the queue. Stripe's guidance is to return 2xx quickly, before logic that could time out, and to process events with an asynchronous queue (Stripe webhooks, read 22 September 2026). Acknowledging before the event is stored risks losing it if the process dies.
Two limits matter when planning environments. Stripe supports up to 16 registered webhook endpoints, which must be publicly reachable HTTPS URLs, and its webhooks support TLS 1.2 and 1.3 only (Stripe webhooks, read 20 September 2026). The CLI forwards events to a local address in development.
The signature check is authentication, so it belongs with your other API authentication tests. Our REST API security guide covers the surrounding controls.
Cover declines and error paths
Stripe lists three reasons a payment fails: issuer declines, blocked payments, and invalid API calls. Its errors can carry a decline code alongside the error code, and invalid calls typically do not produce a payment in the dashboard (Stripe declines and Stripe error codes, read 20 September 2026). Those three are different bugs on your side, so give them separate tests.
Issuer decline. The generic decline test card is 4000 0000 0000 0002, which returns the card_declined error code and the generic_decline decline code (Stripe testing, read 20 September 2026). Assert the order stays unpaid, nothing is fulfilled, and the customer sees a message they can act on.
Blocked payment. Fraud rules refuse a charge the card would have accepted. Handle it differently: no retry prompt, and the reason stays out of the customer-facing text.
Invalid API call. A missing field or a bad currency is your bug. Assert it surfaces in your own monitoring, not as a payment failure blamed on the customer's card.
Rate limit. A stub that returns 429 twice, then succeeds, proves your client backs off.
Provider timeout. The ambiguous case: you do not know whether the payment happened. The safe answer is the idempotent retry, which is why those tests come first.
Write the customer-facing message into the assertion. A decline rendered as "Something went wrong" sends the customer back to the same card. One that says the bank refused it prompts for another card. Our guide to API testing covers negative-path tests on any endpoint.
Test refunds and 3DS state transitions
Refunds have more states than the happy path suggests. Stripe supports partial refunds, and a refund can be pending or fail after it was accepted, with refund.failed as the event to handle and a failure_reason field giving the cause (Stripe refunds, read 22 September 2026). Stripe also says its processing fees from the original transaction are not returned, and a refund might incur a fee (Stripe refunds, read 20 September 2026). Read its pricing page for figures rather than hard-coding one into a test.
The refund tests to keep.
Full refund. Order moves to refunded, and fulfilment reverses if that is your rule.
Two partial refunds. Say the charge is 5000 minor units: refund 1500 then 3500, and the remaining balance is zero.
One unit too many. Rejected, order untouched. The suite above asserts a 422 and a remaining balance of zero.
Failed refund. Handle refund.failed. The money came back to your balance, the customer did not get it, and somebody has to know.
3D Secure adds an authentication step to a card transaction. Stripe says Strong Customer Authentication under PSD2 in the EEA, plus similar rules in the UK, India, Japan and Australia, might require 3DS for card payments (Stripe 3D Secure, read 20 September 2026). Its test cards include 4000 0025 0000 3155, which requires authentication for off-session payments unless you set it up first (Stripe testing, read 20 September 2026).
Four states are worth covering: no challenge, a challenge passed, one failed, and one nobody answers. The failure path matters for where it leaves the payment. Stripe says a failed attempt returns the PaymentIntent to requires_payment_method so it can be retried. Later refunds and disputes affect the charge while the intent can stay succeeded (Stripe payment status, read 20 September 2026).
The same page is clear about fulfilment: do not handle it on the client, because a customer can leave the page after paying and before your code runs. Watch payment_intent.succeeded instead. The test is not "the redirect returned", it is "the event arrived and the order shipped".
Reconcile orders, payments, refunds and payouts
Reconciliation catches what the other tests miss. It asks one question: does the money your system thinks it took match the money the gateway settled?
It needs a join key, set up before you need it. Send your order id as metadata on the gateway object at creation time. Stripe's itemized report downloads can include custom metadata on those transactions. Its payout reconciliation report matches the payouts arriving in your bank account with the batches of payments and other transactions they relate to (Stripe payout reconciliation, read 20 September 2026).
What the assertions look like.
Compare minor units, never floats. Store 4500, not 45.00. A currency with a different exponent finds any rounding you left in.
Compare currency too. Two rows with the same number in different currencies are not a match.
Net out refunds first. Payments minus refunds, per order, then summed.
Account for fees, disputes and pending funds separately. They explain a gap, they do not close it.
Assert the unmatched set is empty. A gateway payment with no order, or the reverse, is the finding.
The last test in the suite above is a small version of this. It replays four events, one a redelivery, and asserts the net ledger total equals the settled batch. Run the real version against a sandbox on a schedule, over a fixed window, and keep the report file.
What never to do against production
Stripe's instruction is one line: "Don't use real card details." The same page adds that its Services Agreement prohibits live-mode testing with real payment method details (Stripe testing, read 22 September 2026). Four rules follow from it.
No live secret keys in fixtures, test CI variables or a developer's machine. Sandbox keys carry the test prefixes, so a wrong key is easy to assert against, as the sandbox suite above does.
No load tests against a testing environment. Stripe says you might hit rate limits, and points to its load testing guidance instead (Stripe testing, read 20 September 2026).
No replaying captured live payment requests. They hold real customer data, and PCI DSS applies to entities that store, process or transmit cardholder data or sensitive authentication data (PCI Security Standards Council, read 20 September 2026). A passing suite is evidence for an audit, never proof of compliance.
No fulfilment from a client redirect. A security bug as much as a correctness one.
A check after release stays read-only: query a status, pull a report, watch a queue depth.
Payment gateway API testing checklist
A release gate for a pull request template.
One customer action produces one payment: same key and body, same key and changed body, timeout then retry, two concurrent submits.
Declines, fraud blocks, invalid calls, rate limits and timeouts each have a test and their own customer-facing outcome.
Signatures verified against raw bytes, with tests for a wrong secret, a tampered body and a stale timestamp.
Duplicate and out-of-order events leave the same ledger state.
The handler stores or enqueues the event durably, then returns 2xx, before any slow work.
Full, partial, over-limit and failed refunds are covered, with totals asserted in minor units.
3DS covers no challenge, pass, fail and no answer; fulfilment follows the event, not the redirect.
Your order id is attached as metadata on every gateway object.
A reconciliation job compares net ledger totals with the settled batch and reports what is unmatched.
No live keys, no real card details and no load tests in any environment the suite touches.
The local suite runs on every pull request; the sandbox suite runs on merge or nightly and skips without its key.
The short version
Point the tests at your own code. A local stub gives fast, deterministic coverage of idempotency, declines, refunds, signatures and ordering; a small sandbox suite proves the provider contract holds. Keep real cards and live keys out of both, and let reconciliation tell you the books and the gateway agree.
Frequently Asked Questions
What is payment gateway API testing?
Testing your integration with a card gateway through its API: the requests your code builds, the responses it reads, the events it receives, and the order and ledger state it writes. It proves your side handles duplicates, declines, authentication, refunds and settlement, and it does not test the gateway's own authorisation engine.
Should payment tests use mocks or the gateway sandbox?
Both, at different layers. A local stub is deterministic and offline, so it runs on every commit and covers awkward cases like a client timeout or an out-of-order event. A sandbox suite proves the request shape and event contract, and stays small because each test costs a round trip.
Which Stripe test card simulates a successful payment or a decline?
For an interactive success, Stripe documents 4242 4242 4242 4242 with any future expiry and any three-digit CVC. Its generic decline card is 4000 0000 0000 0002, returning the card_declined error code with the generic_decline decline code (Stripe testing, read 20 September 2026).
How do you test idempotency without creating duplicate charges?
Against a local stub, so no charge exists to duplicate. Send the same key twice with the same body and assert the second call returns the first payment id while the count stays at one. Then send that key with a changed amount and assert it is rejected.
How do you test Stripe webhook signatures locally?
Sign the raw bytes in the test and post them to your handler, as the suite on this page does. Generate the secret per run rather than committing one. Then check the negatives: a wrong secret, a changed body, a timestamp outside the five-minute tolerance Stripe's libraries default to (Stripe webhooks, read 20 September 2026).
Which 3DS states should a payment integration cover?
Four: no challenge, a challenge passed, one failed, and one nobody answers. A failed attempt returns the PaymentIntent to requires_payment_method so it can be retried, and fulfilment follows the payment_intent.succeeded event, not a client redirect (Stripe payment status, read 20 September 2026).
How do you test partial and failed refunds?
Refund a charge in two parts and assert the totals in minor units, then attempt one unit more than the remaining balance and assert it is refused. For failures, handle refund.failed and check the ledger records money returned to your balance (Stripe refunds, read 20 September 2026).
Should you ever test a payment gateway in production?
Only read-only checks: a status query, a report pull, a monitor that creates no payment. Stripe's testing page says not to use real card details, and that its Services Agreement prohibits live-mode testing with them (Stripe testing, read 22 September 2026).





