Q29 of 37 · API testing

How do you test idempotency keys (e.g. Stripe-style) in payment APIs?

API testingSeniorapiidempotencypaymentsstripesenior

Short answer

Short answer: Replay the same request with the same Idempotency-Key, assert the second call returns the original result without creating a duplicate. Test variants: same key + different body (should reject), expiry of cached responses, concurrent calls with the same key (should serialise to one resource).

Detail

Idempotency keys are the safety net that lets clients retry safely. Testing them is non-negotiable for payment APIs — a flaky retry that double-charges is a refund, a chargeback, and a customer-trust incident.

The contract (Stripe is the canonical reference):

  1. Client sends POST /charges with header Idempotency-Key: abc123 and a body.
  2. Server stores: key → (status, response) for some TTL (24h is common).
  3. If the same key arrives again with the same body → server returns the original response, no duplicate effect.
  4. If the same key arrives with a different body → server rejects (400).
  5. If the original request is still in flight → server queues / returns a "processing" response to the second.

Tests to write:

1. Basic replay — same request, same key, same outcome:

test('idempotent replay returns original result', async () => {
  const key = randomUUID();
  const body = { amount: 1000, currency: 'usd' };

  const r1 = await request.post('/charges', { headers: { 'Idempotency-Key': key }, data: body });
  const r2 = await request.post('/charges', { headers: { 'Idempotency-Key': key }, data: body });

  expect(r1.status()).toBe(201);
  expect(r2.status()).toBeLessThan(300);
  expect((await r1.json()).id).toBe((await r2.json()).id);

  // Critical: only one charge in the database
  const list = await request.get(`/charges?idempotency_key=${key}`);
  expect((await list.json()).data.length).toBe(1);
});

2. Different body, same key — rejection:

const r1 = await request.post('/charges', { headers: { 'Idempotency-Key': key }, data: { amount: 1000 } });
const r2 = await request.post('/charges', { headers: { 'Idempotency-Key': key }, data: { amount: 9999 } });
expect(r2.status()).toBe(400);
expect((await r2.json()).error.code).toBe('idempotency_key_conflict');

3. Concurrent calls with same key:

const calls = Array.from({ length: 10 }, () =>
  request.post('/charges', { headers: { 'Idempotency-Key': key }, data: body })
);
const results = await Promise.all(calls);
const ids = await Promise.all(results.map(r => r.json()));
expect(new Set(ids.map(b => b.id)).size).toBe(1);  // exactly one charge

4. Key expiry. Issue a request, advance time past the TTL (or wait if the test environment supports it), retry — should be a fresh charge, not the cached one.

5. Failure replay. The first request gets 500 (force via test fixture). Retry with the same key — should retry the operation, not return the cached 500.

6. Cross-resource — the same key on a different endpoint (POST /charges vs POST /refunds) should not collide.

Production-like scenarios to test:

  • Network drop after request sent but before response received.
  • Client retries 3 times rapidly.
  • Long-running async operation; client polls during processing.

Anti-patterns in tests:

  • Testing only the happy replay. The interesting bugs are in the conflict and concurrent paths.
  • Mocking the database / cache layer instead of testing real behaviour. Idempotency is a state-management contract; it needs real state.

The senior signal: knowing the Stripe-style contract from memory, testing the conflict and concurrency paths, and verifying via a separate GET that no duplicate state was created — not just that the response repeated.

// EXAMPLE

test('concurrent retries with same key produce one resource', async () => {
  const key = randomUUID();
  const body = { amount: 1000, currency: 'usd', source: 'tok_test' };

  const responses = await Promise.all(
    Array.from({ length: 5 }, () =>
      request.post('/charges', {
        headers: { 'Idempotency-Key': key },
        data: body,
      })
    )
  );

  const bodies = await Promise.all(responses.map(r => r.json()));
  const ids = new Set(bodies.map(b => b.id));
  expect(ids.size).toBe(1);
});

// WHAT INTERVIEWERS LOOK FOR

Knowing the Stripe-style contract, testing replay + conflict + concurrent paths, verifying via separate GET that only one resource was created, and the production scenarios (network drop, retry storm).

// COMMON PITFALL

Trusting that 'both responses had the same id' means it worked. Two identical responses can come from two database rows. Always verify the underlying state.