20 min read · 9 self-checks · Updated June 2026

Non-functional · CTAL-TA

Idempotency Testing

Verify that operations can be safely retried without side effects. A customer shouldn't be charged twice if a payment request is retried. An order shouldn't be created twice if a network request times out and is resent. Test that your system is resilient to retries.

Senior ISTQB CTAL-TA

1 The Hook

It is the morning of a big sale and a customer is checking out on a Kiwi retailer's app on patchy mobile data near the Auckland ferry terminal. They tap “Pay now”. The request reaches the server, the card is charged, the order is created — and then the response never makes it back across the flaky connection. The spinner just sits there. The customer, seeing nothing happen, taps “Pay now” again.

If the payment endpoint is not idempotent, that second tap charges the card a second time and creates a duplicate order. The customer is now $180 out of pocket for one pair of shoes, the warehouse is about to ship two, and the support team's Monday is ruined. The bug is not in the payment logic — that worked perfectly both times. The bug is that the system treated an honest retry as a brand-new request.

This is exactly what idempotency testing guards against. Networks drop responses, users double-tap, and message queues redeliver. A well-built system recognises the retry, returns the result of the first attempt, and charges the card once. Idempotency testing is how you prove it actually does that — before a customer proves it does not.

2 The Rule

An operation is idempotent if performing it many times has the same effect as performing it once — so for any operation that changes state, send the identical request twice (same idempotency key) and assert the side effect happens exactly once and the same response comes back both times.

3 The Analogy

Analogy

Tagging on with an AT HOP card.

You tap your AT HOP card on the reader getting onto an Auckland bus. The reader beeps and charges you. If you are not sure it registered and tap again a second later, a well-designed reader knows it is the same tag-on for the same trip and does not charge you a second fare — it just shows the same balance. The single “board this bus” outcome happens once, no matter how many nervous taps you give it. A badly designed reader that charged every tap would punish anyone who double-checked.

Idempotency is that “same tag-on, charge once” behaviour. The idempotency key is the equivalent of the reader recognising it is still the same trip. Idempotency testing is deliberately tapping twice and confirming you were only charged once.

💬
Senior Engineer Insight

Every team I have worked with writes the idempotency cache entry after the payment succeeds. That is exactly backwards. The window between "check the cache — key not seen" and "write the cache — key stored" is where the duplicate charge lives. Under concurrent load, two requests with the same key both pass the check before either one writes the result, and both proceed to charge the card. The safe pattern is to insert the key into the database before processing — using a unique constraint as the lock — and only then call the gateway. One of the two concurrent requests will hit a constraint violation; the other wins and processes. I have seen this race condition survive code review, QA, and staging for months, then surface on a Black Friday sale. Spray a concurrent test — two threads, same key, same millisecond — at your idempotency endpoint before you ship.

Senior engineer insight

The moment that changed how I think about idempotency: I realised we were testing it wrong. We sent the same request twice sequentially and checked we got the same response — but the real production failure always happened concurrently, two threads hitting the same endpoint within 50ms of each other. Sequential testing gives you false confidence; you need a concurrent spray test to expose the race in the idempotency check itself.

Once I switched to firing two parallel requests with the same key and asserting only one side effect in the database, we found three undiscovered bugs in a single sprint — all in code that had passed existing QA.

Most common mistake: writing the idempotency cache entry after the operation completes. That leaves a window where two concurrent requests both pass the “key not seen” check and both proceed to charge the card. Insert the key into the store before processing — use a unique constraint as the mutex.

From the field

We were building an EFTPOS retry layer for a NZ retailer — if the PIN pad timed out, the POS terminal automatically retried the transaction. Looked fine in testing: identical response both times, one charge in the gateway logs. We shipped. Two weeks later, support calls started coming in: customers charged twice at certain stores.

The bug was in the gateway’s sandbox vs. production difference: sandbox deduplicated silently, production deduplicated only if the retry arrived within 30 seconds. Our test waited 5 seconds between calls. Real PIN pad timeouts on busy networks took 45–90 seconds — right outside the deduplication window.

The lesson that generalises: always test idempotency at the TTL boundary, not just with instant retries. Find out the actual retry timeout your client uses, and test a retry that lands just outside the idempotency window as well as inside it. The edge of the window is where production incidents live.

What is idempotency?

An operation is idempotent if calling it multiple times with the same input produces the same result and has no additional side effects beyond the first call. In simpler terms: it's safe to retry.

The classic example: pressing an elevator button. Press once, the elevator comes. Press again, the elevator still comes (you didn't call two elevators). The operation is idempotent.

Non-idempotent example: withdrawing money from an ATM. Withdraw $100 once and your balance drops by $100. Withdraw $100 again and it drops another $100. The operation is not idempotent; retrying causes duplication.

Why idempotency matters

In distributed systems, network failures happen. A request might be sent but the response lost. The client doesn't know if the request succeeded, so it retries. If your system isn't idempotent:

  • The same payment is processed twice (customer charged twice).
  • An order is placed twice (duplicate orders).
  • An email is sent twice (spam).
  • A resource is created twice (duplicate records in database).

Idempotency is not just a nice property; it's essential for system reliability. Modern APIs (Stripe, AWS, Google Cloud) guarantee idempotency for critical operations. Your system should too.

Exactly-once semantics: Distributed systems strive for "exactly-once delivery" — the operation happens exactly once, even if the request is sent multiple times or if services fail and retry. Idempotency is how you achieve this.

Idempotent operations: safe to retry

Idempotent vs non-idempotent operations
OperationIdempotent?Why
GET /users/123 Yes Reading data doesn't change anything. Calling it 10 times returns the same data.
PUT /users/123 {name: "Alice"} Yes Updating (or replacing) always sets the same state. Retrying doesn't change the result.
DELETE /users/123 Yes First delete removes the resource. Second delete has no effect (resource already gone). Result is the same.
POST /orders (create order) No Creates a new resource each time. Two POSTs create two orders.
POST /payments (charge card) No Each call charges the card again. Two POSTs charge twice.
POST /emails (send email) No Sends a new email each time. Two POSTs send two emails.

Making non-idempotent operations safe: idempotency keys

Many operations are inherently non-idempotent (payments, orders, emails). You can't change POST's semantics. Instead, implement idempotency keys: unique identifiers that let the server recognize a retry and avoid duplicate processing.

How idempotency keys work

  1. Client generates a unique identifier (UUID) for the request: Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
  2. Client sends the request with this key: POST /payments with Idempotency-Key header
  3. Server receives the request. Before processing, it checks: "Have I seen this key before?"
  4. If no: process the payment, store the key and response in a cache/database, return the result.
  5. If yes: return the cached response from the first request without processing again.
  6. Network failure? Client retries with the same key. Server recognizes it and returns the same cached response.

Implementation strategies

Cache-based: Store (key → response) in Redis or in-memory cache with a TTL. Fast, good for high-throughput systems.

// Pseudocode: Redis-based idempotency
POST /payments {amount: 100}
Header: Idempotency-Key: 550e8400...

// Server-side logic
key = request.headers['Idempotency-Key']
cached = redis.get(key)
if cached:
  return cached  // Return cached response

// Process payment
result = process_payment(request.body)

// Cache the result for future retries
redis.setex(key, 3600, result)  // Cache for 1 hour
return result

Database-based: Store (key → response) in a database table. Slower but persistent across restarts. Useful for critical operations.

-- Table: idempotent_requests
CREATE TABLE idempotent_requests (
  idempotency_key UUID PRIMARY KEY,
  operation_type VARCHAR,
  request_body JSONB,
  response_body JSONB,
  status_code INT,
  created_at TIMESTAMP
);

Client-side generation

The client must generate a stable, unique key. Using a random UUID each time defeats the purpose.

// Good: stable key based on operation details
idempotencyKey = SHA256(userId + orderId + amount + timestamp)

// Better: client generates once and stores it
idempotencyKey = generateUUID()
localStorage.save('idempotency_key', idempotencyKey)

// Bad: random UUID each time (no deduplication)
idempotencyKey = generateRandomUUID()  // Different each retry!

Idempotency key design

  • Unique: Each request should have a different key. Reusing keys across different operations causes collisions.
  • Stable: The same logical operation (same user, same order, same payment) should use the same key. This allows safe retries.
  • Immutable after creation: Once a key is sent to the server, don't change it. Changing it on retry breaks deduplication.
  • Reasonable lifetime: How long should the server remember a key? Cache for hours or days (not forever). Old requests can be garbage collected.

Testing idempotency: strategies and assertions

Basic idempotency test: send request twice, get same response

@Test
void payment_isIdempotent() {
  String idempotencyKey = UUID.randomUUID().toString();

  // First request
  PaymentResponse response1 = chargeCard(
    amount: 100,
    idempotencyKey: idempotencyKey
  );
  assertEquals(200, response1.status);
  assertEquals("approved", response1.paymentStatus);
  String transactionId1 = response1.transactionId;

  // Retry with same key
  PaymentResponse response2 = chargeCard(
    amount: 100,
    idempotencyKey: idempotencyKey
  );

  // Assertion 1: same response
  assertEquals(response1.status, response2.status);
  assertEquals(response1.paymentStatus, response2.paymentStatus);

  // Assertion 2: same transaction ID (not a new charge)
  assertEquals(transactionId1, response2.transactionId);
}

Verify side effects don't duplicate

@Test
void orderCreation_isIdempotent() {
  String idempotencyKey = UUID.randomUUID().toString();

  // First request: create order
  OrderResponse response1 = createOrder(
    customerId: 123,
    items: [product1, product2],
    idempotencyKey: idempotencyKey
  );
  int orderId = response1.orderId;

  // Verify order was created
  Order order = database.getOrder(orderId);
  assertNotNull(order);
  assertEquals(2, order.items.size());

  // Retry with same key
  OrderResponse response2 = createOrder(
    customerId: 123,
    items: [product1, product2],
    idempotencyKey: idempotencyKey
  );

  // Assertion 1: same order ID
  assertEquals(orderId, response2.orderId);

  // Assertion 2: only ONE order in database (not two)
  List orders = database.getOrdersByCustomer(123);
  assertEquals(1, orders.size());

  // Assertion 3: verify only one payment record
  List payments = database.getPaymentsByOrder(orderId);
  assertEquals(1, payments.size());
}

Test error scenarios with idempotency

@Test
void payment_isIdempotent_evenWithRetryAfterError() {
  String idempotencyKey = UUID.randomUUID().toString();

  // First request: fails
  stubPaymentAPI_toReturn(500);
  assertThrows(ServerException.class, () -> {
    chargeCard(amount: 100, idempotencyKey: idempotencyKey);
  });

  // Fix the server, then retry
  stubPaymentAPI_toReturn(200);
  PaymentResponse response = chargeCard(
    amount: 100,
    idempotencyKey: idempotencyKey
  );

  // Payment should succeed on retry, not charge twice
  assertEquals("approved", response.paymentStatus);
}

Real examples: payment, order, and email idempotency

Payment processing with Stripe (industry standard)

Stripe requires idempotency_key on all charge requests. If the request times out or fails, the client retries with the same key. Stripe recognizes it and returns the result of the first attempt.

// Stripe JavaScript SDK
const charge = await stripe.paymentIntents.create(
  {
    amount: 10000,  // $100.00
    currency: 'nzd',
    description: 'Order #456',
  },
  {
    idempotencyKey: 'order-456-' + Date.now(),
  }
);

// Retry on timeout: same idempotency key
// Stripe returns the same charge, doesn't charge twice
const charge2 = await stripe.paymentIntents.create(
  {...},
  {
    idempotencyKey: 'order-456-' + storedTimestamp,  // Same key
  }
);

Order creation with duplicate detection

// POST /orders
@PostMapping("/orders")
public OrderResponse createOrder(
  @RequestBody OrderRequest request,
  @RequestHeader("Idempotency-Key") String key
) {
  // Check cache first
  OrderResponse cached = idempotencyCache.get(key);
  if (cached != null) {
    return cached;
  }

  // Process order
  Order order = new Order(request);
  order = database.save(order);

  // Charge customer
  Payment payment = paymentService.charge(
    amount: order.total,
    idempotencyKey: key + "-payment"
  );
  order.setPaymentId(payment.id);
  order = database.save(order);

  // Cache the response
  OrderResponse response = new OrderResponse(order);
  idempotencyCache.put(key, response, Duration.ofHours(1));

  return response;
}

Email sending idempotency

@Test
void emailSending_isIdempotent() {
  String idempotencyKey = "send-confirmation-order-" + orderId;

  // First send
  EmailResponse response1 = sendConfirmationEmail(
    to: "user@example.com",
    orderId: orderId,
    idempotencyKey: idempotencyKey
  );
  assertEquals("sent", response1.status);

  // Retry
  EmailResponse response2 = sendConfirmationEmail(
    to: "user@example.com",
    orderId: orderId,
    idempotencyKey: idempotencyKey
  );

  // Verify: only one email was actually sent to user
  List sent = emailService.getEmailsSentTo("user@example.com");
  assertEquals(1, sent.size());
  assertEquals("order confirmation", sent.get(0).type);
}

Distributed systems: retries and exactly-once semantics

In distributed systems, multiple services communicate over the network. Network partitions and service failures are inevitable:

  • Service A sends a request to Service B. The request arrives and Service B processes it successfully. But the response is lost.
  • Service A times out waiting for the response. It assumes failure and retries.
  • Without idempotency, Service B processes the same request twice.

Idempotency keys, combined with coordinated state, achieve "exactly-once" delivery: the operation happens exactly once despite retries and network failures.

Database-level idempotency: unique constraints

Databases can enforce idempotency via unique constraints:

-- Prevent duplicate orders from same customer
CREATE TABLE orders (
  id UUID PRIMARY KEY,
  customer_id INT NOT NULL,
  idempotency_key VARCHAR UNIQUE NOT NULL,
  total DECIMAL,
  created_at TIMESTAMP
);

-- Unique constraint: same customer can't place two orders with same key
CREATE UNIQUE INDEX idx_customer_idempotency
ON orders(customer_id, idempotency_key);

-- Now if two requests arrive simultaneously with the same key,
-- the database enforces: only one order is created.
-- The second request gets a unique constraint violation,
-- which the application converts to "already processed, returning cached result".

API design for idempotency

If you're designing APIs, build idempotency in from the start:

  • Require idempotency keys on all mutation operations: POST, PATCH, DELETE. (GET is already idempotent.)
  • Document the header: "Clients MUST send an Idempotency-Key header. Requests with the same key return the cached response."
  • Return the same response: If a retry arrives, return the exact same HTTP status and body as the first request.
  • Cache long enough: Cache for at least hours. Stripe caches for 24 hours. This covers network timeouts and client-side retries.
  • Return a hint: Optionally include a header like Idempotency-Replay: true to signal the client that this is a cached response, not a fresh request.

Message queues and event streaming: idempotency at scale

Message queues (Kafka, RabbitMQ) deliver messages at-least-once, not exactly-once. Consumers must handle duplicates:

// Kafka consumer: handle duplicate messages
consumer.subscribe(["payments.created"]);
for (record : consumer.poll()) {
  String idempotencyKey = record.headers["idempotency-key"];

  // Check: have we processed this message before?
  if (processedMessages.contains(idempotencyKey)) {
    logger.info("Duplicate message, skipping");
    continue;  // Skip processing
  }

  // Process the message
  processPayment(record);

  // Remember we processed it
  processedMessages.add(idempotencyKey);
}

Context guide

How the right level of idempotency testing effort changes based on project context.

Context Priority Why
Payment or financial systems (Harbour Bank, Pacific Bank, Stripe-integrated NZ e-commerce) Essential A duplicate charge is a direct financial harm. RBNZ and PCI-DSS audit trails require proof of exactly-once processing. Missing idempotency here is a production incident waiting to happen.
Government benefit or welfare systems (Benefits NZ, CoverNZ, Revenue NZ) Essential Duplicate benefit payments, duplicate tax filings, or double-enrolled CoverNZ claims create compliance breaches and recovery costs. Connectivity at regional Work and Income offices makes real-world retries frequent.
Transport and licensing portals (TransitNZ, TransitNZ licence renewals) High Licence renewal endpoints process fees and create official records; a duplicate submission charges twice and creates a confusing duplicate record. Mobile browser retries on patchy rural connections make this scenario realistic.
Microservice event-driven platforms (Spark, Pacific Air internal APIs using Kafka or SQS) High At-least-once message delivery guarantees duplicate events. Each consumer service must deduplicate independently; a failure to do so cascades silently across multiple downstream services.
SaaS platforms with transactional email or notifications (onboarding flows, confirmation emails) Medium Duplicate welcome emails or notifications erode trust and look unprofessional. Worth testing for user-facing email triggers; lower priority for purely internal notifications where duplicates are invisible to end users.
Internal read-only reporting or analytics tools Low Read-only GET endpoints are inherently idempotent; there are no side effects to duplicate. Idempotency testing adds no value here — spend the time on your mutation endpoints instead.

Trade-offs

What you gain and what you give up when you choose idempotency testing.

Advantage Disadvantage Use instead when…
Catches duplicate-charge bugs before they reach production — the most costly class of API defect in payment-critical NZ systems. Requires test infrastructure to query the database directly; a pure HTTP-response assertion gives false confidence and must be discouraged on the team. The endpoint is read-only (GET) — inherent idempotency means there is nothing to duplicate; write API contract tests instead.
Surfaces race conditions in the idempotency check itself (the "check-then-act" window) that sequential testing never reveals. Concurrent spray tests are harder to write and maintain than sequential tests, and can be flaky if timing is not carefully controlled in CI. The system is a single-process batch job with no retry logic — use unit tests for business logic correctness; concurrent idempotency testing is theoretical overhead.
Validates the entire side-effect chain (database row count, payment ledger, outbound email queue) — the most thorough form of integration-level assertion available. Test setup is heavier than standard API tests: you need a real or realistic database, a way to query it after each call, and mocked or sandboxed downstream services (e.g. Windcave sandbox). You are in early prototyping with no retry mechanism yet — defer until the retry path is actually built; premature idempotency tests break on every API shape change.
Acts as a living specification of the API's deduplication contract, making the behaviour explicit and regression-testable when the caching or key-management logic is refactored. TTL boundary testing (retrying after the idempotency window expires) requires time-manipulation in the test environment, which adds complexity and is often skipped — leaving the most dangerous edge case uncovered. The platform guarantees exactly-once delivery end-to-end (e.g. certain managed cloud event buses) — verify the platform's own documentation and rely on that guarantee rather than duplicating coverage.

Enterprise reality

How idempotency testing changes when 200–300 developers are shipping to production simultaneously across 10+ squads — and a duplicate payment means a regulatory breach, not just a support ticket.

  • At small-team scale, idempotency testing is a manual check: a tester fires two requests in Postman and eyeballs the response. At Harbour Bank or Pacific Bank, where dozens of squads share a single payments platform, this is automated in the deployment pipeline — every state-changing endpoint runs a concurrent spray test (two threads, same key, same millisecond) as a hard gate before merge. No exemptions for “low-risk” endpoints; the risk classification is owned by the platform team, not the feature squad.
  • The Privacy Act 2020 and the NZISM (NZ Information Security Manual) create a compliance obligation that manual testing cannot satisfy at scale. When Revenue NZ or Benefits NZ process millions of transactions, audit evidence of idempotency must be machine-generated and traceable to a specific test run — not a tester’s sign-off in a spreadsheet. Expect to produce JUnit XML or Allure reports that map idempotency test results to specific API version tags, stored for at least seven years under the Public Records Act.
  • Tooling decisions harden fast at volume. The dominant stack in NZ enterprise payments is Pact for consumer-driven contract tests (with idempotency scenarios baked into the consumer contract), WireMock for fault injection at the integration layer, Toxiproxy for TTL boundary testing under realistic network conditions, and Postgres unique constraints as the last-resort database lock. Redis TTLs alone are treated as insufficient — persistent DB deduplication tables are the standard for anything touching PCI DSS scope.
  • Cross-squad coordination is the hardest part. With 10+ squads sharing a Kafka cluster, each squad owns the idempotency contract for its own consumer — but nobody owns the end-to-end deduplication chain. In practice, a platform QA lead maintains a shared “idempotency ownership register” that maps each event type to the squad responsible for deduplication, and a quarterly cross-squad test day replays a set of known duplicate events end-to-end to confirm nothing has regressed. Without this, each squad’s tests pass and the duplicate still reaches the downstream ledger.

What I would do

Professional judgment — when to reach for idempotency testing, when to skip it, and what to watch for.

Scenario 1 — Payment gateway retry at Harbour Bank merchant services
Situation

I am joining an Harbour Bank merchant services integration where the POS terminal auto-retries a payment request after a 30-second PIN pad timeout. The team has sequential idempotency tests already, but they only run one request then the retry — no concurrency, and they assert only on the HTTP response body.

I would

First add DB-level assertions to the existing tests: count the payment rows after both calls and assert exactly one. Then add a concurrent spray test — two threads sending the same key within 50ms of each other — to expose the race window in the idempotency check. Finally, test a retry that arrives at 31 seconds (just past the 30-second timeout the team configured) and assert whether the system treats it as a new charge or a retry. That TTL boundary is exactly where production incidents live, and it is almost never in the existing test suite.

Scenario 2 — Benefit payment retries at Benefits NZ Work and Income
Situation

Benefits NZ is modernising its Jobseeker Support payment disbursement API. A regional Work and Income office on a congested WAN link sometimes loses the response after the backend has already processed the payment. The case manager's system retries the request the next business day. The development team says idempotency is handled by a Redis cache with a 60-minute TTL.

I would

Immediately raise the TTL as a risk item. A next-business-day retry from a regional office could easily be 18+ hours after the original request — well outside a 60-minute cache. I would test a retry at 61 minutes (mock time) and confirm whether the system re-processes the payment. If it does, the TTL must be extended to at least 48 hours, or the deduplication logic shifted from Redis to a persistent database table with a unique constraint on the idempotency key and payment period. I would also verify the financial ledger (not just the API response) shows a single debit after both calls — a compliance requirement under the Public Finance Act, not just a quality preference.

Scenario 3 — Skipping idempotency tests on an internal analytics API
Situation

LandNZ has an internal reporting API that aggregates land title search results for a nightly BI dashboard. The API is called once per night by a single batch job; there is no retry logic, no message queue, and the endpoint only reads from a read replica — it never writes. A junior tester asks whether we should add idempotency tests to the backlog.

I would

Decline, and explain why: a read-only, no-retry, single-caller endpoint has no side effects to duplicate. Writing an idempotency test here gives false confidence (any test will pass trivially) while consuming sprint capacity that should go to the title registration API — which does write state and does accept retries. I would note this decision in the risk log with the rationale, so the next tester who asks gets a considered answer rather than coverage debt re-appearing on every sprint. If LandNZ ever adds retry logic or writable endpoints to the batch job, revisit.

The bottom line: Idempotency testing earns its cost when retries are real, state changes are durable, and the cost of duplication exceeds the cost of the test. In NZ, that means payment gateways, government benefit APIs, and any Kafka consumer touching financial records — not read-only dashboards or prototype endpoints without retry logic.

Best practices

Idempotency is not optional for critical operations. If your operation involves money, orders, or state changes, implement idempotency. Network failures will happen. Make your system resilient.

  • Use universally unique identifiers (UUIDs) for keys. Ensure keys are unique across all requests. A sequential ID is not sufficient if multiple clients are generating keys.
  • Test both happy path and failure scenarios. Test retries after network errors, after partial failures, after timeouts. Idempotency matters most when things go wrong.
  • Implement proper cache invalidation. Don't cache forever. Set a reasonable TTL (hours or days) and clean up old entries to prevent memory leaks.
  • Log idempotency key with every request. This helps debugging when customers report duplicate charges or orders. You can trace "was this request retried?"
  • Coordinate idempotency keys across the call chain. If Service A calls Service B which calls Service C, propagate the idempotency key through all calls. This ensures end-to-end exactly-once semantics.
  • Test with chaos engineering. Inject random failures (timeouts, 500s) into your system and verify it remains idempotent. Tools like Gremlin or Chaos Toolkit help.

5 Industry Reality

🏭 What you actually encounter on the job
  • Idempotency is often bolted on after the first production incident. Most teams don't implement idempotency keys until a customer gets charged twice at 2am on a Friday. The spec said "it's fine, retries are rare" — and then a flaky AWS region proved otherwise. You'll frequently join projects where only the most-complained-about endpoints have keys, and everything else is a ticking clock.
  • The idempotency key TTL is almost always wrong. Stripe caches keys for 24 hours. Internally, most teams cache for 30 minutes because "nobody retries that late" — until they do. Batch jobs that restart after a weekend outage, mobile apps that retry after the phone comes out of flight mode, delayed Kafka redelivery: real-world retry windows are longer than developers assume. Expect to push back on TTLs that are too short.
  • Legacy codebases rarely have idempotency at the service boundary. You will test a microservice whose HTTP layer is idempotent but whose downstream Kafka producer sends a duplicate event anyway. The duplicate is consumed by another service with no deduplication logic. Finding these cross-service gaps — not just single-endpoint retests — is where senior testers earn their reputation.
  • NZ payment gateways (Windcave, Paymark) have their own idempotency semantics. They may call the header something different, have their own key format requirements, or silently discard duplicates without telling your service. Testing against a real sandbox — not just mocking — is the only way to know the gateway actually deduplicated and didn't quietly double-process.
  • Database unique constraints are your safety net, not your primary defence. Textbook implementations put the idempotency check in application code. In reality, the unique constraint on idempotency_key in Postgres is what saves you when the application layer has a race condition under load. Test both: the happy-path key lookup AND what happens when two requests with the same key arrive within milliseconds of each other.

6 When to Use It — and When Not To

⚡ Decision guide

✓ Use it when

  • The operation creates or modifies financial records — payments, refunds, credit adjustments. Double-processing has direct monetary harm and is the clearest business case for idempotency testing.
  • The system crosses a network boundary that can time out — external payment gateways, insurance APIs, Revenue NZ's myIR integrations, or third-party logistics providers. Timeouts guarantee retries; retries demand idempotency.
  • A message queue (Kafka, SQS, RabbitMQ) is the transport layer. All these guarantee at-least-once delivery. Test that your consumer handles the same message arriving twice without creating duplicate records.
  • The operation triggers a real-world action that cannot be reversed — sends an email, fires an SMS, creates a record in an external government system. Duplicates here are embarrassing at best, legally problematic at worst.
  • The frontend can retry (mobile apps with poor connectivity, single-page apps that re-submit on reconnect, automated payment retries). If the client can send the same request twice, you need to test that the server handles it safely.

✗ Skip it when

  • The endpoint is read-only (GET). GET is inherently idempotent; writing an explicit idempotency test for it adds noise without value. Spend that time on your POST and PATCH endpoints instead.
  • The operation has no durable side effects — a pure in-memory computation, a health-check endpoint, a search that reads from a cache. There's nothing to duplicate, so there's nothing to test.
  • The system is a truly internal, synchronous, single-caller API where retries are architecturally impossible. If the only caller is a batch job that never retries and runs in a single process, idempotency testing is theoretical — still worth noting in your risk log, but not worth test-time now.
  • You are in early exploratory or prototype stages before any retry logic exists. Test idempotency when the retry mechanism is actually built, not before. Premature idempotency tests just break when the API shape changes.
  • A fully managed platform already guarantees exactly-once delivery end-to-end (rare, but some cloud-native setups do). Verify the guarantee in the platform docs first; if it's real, a custom test is redundant.

7 Best Practices

✓ What experienced testers do
  • Always assert on the database, not just the HTTP response. Two identical 201 responses can hide a double charge. Query the DB directly: count the order rows, payment rows, and email-send records. The side-effect count is the ground truth.
  • Test with the same key arriving concurrently, not just sequentially. Fire two requests with the same key at the same millisecond using parallel threads or async calls. Race conditions in the idempotency check only show up under concurrent load — sequential tests miss them entirely.
  • Cover the half-failure scenario: first attempt partially succeeded before crashing. Stub the downstream payment gateway to succeed but have the order-service crash before writing the idempotency cache. Retry with the same key and assert the card was charged exactly once. This is the most dangerous real-world failure mode.
  • Verify the TTL boundary. If the idempotency cache expires after 60 minutes, send a request, wait (or mock time) past the TTL, then resend. Assert the system does NOT treat it as a retry — a new payment should be processed, and the old key should be treated as expired. This boundary is almost never tested.
  • Propagate the key through the entire call chain. If Service A calls Service B which calls Service C, each downstream call needs a derived key (e.g. key + "-payment", key + "-email"). Test that a retry of the top-level endpoint doesn't re-trigger any leg of the chain.
  • Test with a mismatched request body on the same key. Some APIs reject a retry where the body has changed (Stripe does this). Test what your system does: does it return a 422 Unprocessable Content, silently ignore the body change and return the cached response, or process the new body? Know the contract and assert the actual behaviour.
  • Log the idempotency key on every request and use it in bug reports. When a customer reports a double charge, "show me the idempotency key in your logs for that request" is the fastest way to diagnose whether it was a genuine duplicate or two separate purchases. Set this logging up and verify it in your test.
  • Test message-queue consumers separately from HTTP endpoints. An HTTP endpoint may be idempotent but the Kafka consumer that processes the same event may not be. Write a test that delivers the same Kafka message twice to your consumer and asserts the downstream database record was created exactly once.
  • Pair idempotency testing with chaos testing. Use a tool like Toxiproxy (or WireMock's fault injection) to randomly drop responses mid-request, then confirm the retry results in exactly one outcome. Chaos is the environment idempotency was built for — test in it.
  • Include idempotency in your API contract tests. If you use Pact or OpenAPI contract testing, include an idempotency scenario in the consumer contract so breaking changes to the server's deduplication logic are caught at the contract boundary, not in production.

8 Common Misconceptions

❌ Myth: "If the HTTP response is the same both times, the operation was idempotent."

Reality: Two identical 200 OK responses prove nothing about what happened in the database or downstream. The server could have charged the card twice, sent two emails, and created two orders — and still returned the same response body both times. Idempotency is about side effects, not response shape. Always query the database and check downstream records to verify the operation actually happened only once.

❌ Myth: "Using a UUID as the idempotency key is enough to prevent duplicates."

Reality: A UUID is only as useful as how it's managed. If the client generates a new UUID on every retry attempt — a common copy-paste mistake — the server sees a different key each time and processes every request as fresh. The UUID must be generated once per logical operation and reused across all retry attempts. The mechanism is only as strong as the key management on the client side, which is why testing client behaviour under retry conditions is just as important as testing the server's deduplication logic.

❌ Myth: "Idempotency only matters for payment endpoints — our app doesn't take money, so we don't need it."

Reality: Any operation with a durable side effect needs idempotency if retries are possible. Creating user accounts, enrolling students in a course, submitting a government form, sending a welcome email, provisioning a cloud resource — all of these can cause real harm if duplicated. A Kiwi fintech might not take card payments directly, but a double-submitted CoverNZ claim, a duplicate Revenue NZ filing, or a second "account approved" email to a customer can cause compliance issues and erode trust just as badly as a double charge.

4 Now You Try

Three graded exercises — spot, fix, then build. Write your answer, run it for AI feedback, then compare to the model answer.

🔍 Exercise 1 of 3 — Spot: which operations are idempotent?

For each operation in a Kiwi banking app, say whether it is idempotent as-is, and if not, what makes it dangerous to retry. (a) GET /accounts/123/balance; (b) POST /transfers (move $500 to another account); (c) PUT /profile {phone: "021..."}; (d) POST /payments/billpay (pay a power bill).

Show model answer
(a) GET balance — Idempotent. Reading data changes nothing; calling it 10 times returns the same value with no side effects.

(b) POST transfer — NOT idempotent as-is. Each call moves another $500. A retry after a lost response moves the money twice. Make it safe with an idempotency key so the server recognises the retry and performs the transfer once.

(c) PUT profile — Idempotent. PUT sets the resource to a given state; applying "phone = 021..." twice leaves the same final state. Retrying is safe.

(d) POST billpay — NOT idempotent as-is. Each call pays the bill again, so a retry double-pays the power company. Needs an idempotency key (or a server-side duplicate check) so the bill is paid exactly once.

The pattern: reads (GET) and full replacements (PUT) are naturally idempotent; creates/charges (POST) usually are not and need an idempotency key.
🔧 Exercise 2 of 3 — Fix: repair a broken idempotency key

A developer added an idempotency key to a payment client, but customers are still being double-charged on retry. The code is below. Find the bug and fix it, and explain why the current version fails.

function payOnce() {
  var key = generateRandomUUID(); // new key each call
  return chargeCard({ amount: 9000, idempotencyKey: key });
}
// On timeout, the client simply calls payOnce() again.

What is wrong, and the corrected approach:

Show model answer
Why it still double-charges: the key is generated fresh inside payOnce() on every call, so the retry sends a DIFFERENT idempotency key. The server has no way to recognise the second request as a retry of the first — it looks like a brand-new payment, so it charges again. A random key per attempt defeats the entire purpose.

The fix: generate the key ONCE for the logical operation and reuse the SAME key on every retry.

  // Generate once, before the first attempt
  var key = generateUUID();
  localStorage.save('paymentKey', key);

  function payWithRetry() {
    return chargeCard({ amount: 9000, idempotencyKey: key }); // same key every time
  }
  // On timeout, call payWithRetry() again — same key, so the server returns the first result.

Key design rules this satisfies: the key is stable across retries, immutable once sent, and unique per logical operation. The server stores key -> response and returns the cached response for any repeat of that key.
🏗️ Exercise 3 of 3 — Build: design the idempotency test

A food-delivery app's POST /orders endpoint is meant to be idempotent via an Idempotency-Key header. Design a test that proves an order placed during a flaky connection is not duplicated. List the steps and, crucially, the assertions — including what you check in the database, not just the HTTP response.

Show model answer
Test name: orderCreation_isIdempotent_onRetry

Setup: a known customer; generate ONE idempotency key for the order.

Steps:
1. Send POST /orders with the items and the Idempotency-Key header. Capture response1.
2. Send the IDENTICAL request again with the SAME Idempotency-Key (simulating a retry after a lost response).

Assertions on the HTTP responses:
- response1 status is success (e.g. 201).
- response2 has the same status and the SAME orderId as response1 (not a new order).

Assertions on the database / side effects (the important part):
- Exactly ONE order exists for that idempotency key / customer (count == 1, not 2).
- Exactly ONE payment record for that order (the card was charged once).
- Only ONE confirmation email/notification was queued.

A failure-scenario variant worth adding: make the first attempt fail mid-way (stub the payment service to return 500), then retry with the same key after "fixing" the service. Assert the order is created and the card charged exactly once — idempotency matters most when the first attempt half-failed. Checking the DB side effects, not just the HTTP status, is what separates a real idempotency test from a shallow one.

Why teams fail here

  • Testing only sequential retries and missing the concurrent-request race — two threads hitting the same idempotency key within milliseconds is how production double-charges actually happen, and sequential tests never catch it.
  • Asserting on the HTTP response body alone — two identical 201 responses can hide two database rows, two payments, and two outbound emails; only querying the store proves one side effect occurred.
  • Setting the idempotency TTL based on what developers expect retry windows to be, not what they actually are in production — mobile apps reconnecting after flight mode, batch jobs restarting after a weekend outage, and Kafka redelivery can all exceed a 30-minute cache window.
  • Testing the HTTP layer as idempotent but leaving the downstream Kafka producer or email service without deduplication — a clean HTTP response does not mean the event emitted downstream was consumed only once.

Key takeaway

Idempotency testing is not about sending the same request twice and checking the response — it is about sending the same request twice and checking the database, the ledger, the email queue, and every downstream side effect to prove the operation happened exactly once.

How this has changed

The field moved. Here is how Idempotency Testing evolved from its origins to current practice.

Pre-2010

Idempotency is a mathematical and API design concept but not a recognised testing technique. Developers assume correct idempotent behaviour; testers verify happy-path outcomes. Double-submission and retry bugs are common and often discovered in production.

2010

REST API design principles popularised by Roy Fielding's dissertation gain widespread adoption. HTTP PUT, DELETE, and GET are defined as idempotent; POST is not. Idempotency becomes an explicit API design criterion, creating testable expectations.

2014

Stripe introduces idempotency keys for payment APIs. The pattern — clients supply a unique key so retries return the same result without creating duplicates — becomes standard for payment and financial APIs. Testing idempotency keys becomes a required test category for payment integrations.

2018

Distributed systems complexity increases retry complexity. Kafka consumer groups, SQS message processing, and microservice event handling all require idempotency testing. Chaos engineering experiments surface idempotency failures under real network conditions.

Now

Idempotency testing is a first-class concern for any API that handles financial transactions, state changes, or operations that must not be duplicated. NZ payment systems (EFTPOS, bank transfer APIs) require idempotency testing as a regulatory expectation for operational resilience.

Interview Questions

What NZ hiring managers ask about Idempotency Testing — and what strong answers look like.

Why is idempotency important in payment APIs, and how do you test it?

Strong answer: Idempotency ensures that retrying a request (due to network failure, timeout, or client error) does not create duplicate operations. For payments, a non-idempotent retry means a customer is charged twice. To test: send a payment request with an idempotency key, receive a success response, then send the identical request again with the same key. Verify the second response returns the same result as the first (HTTP 200 with the same transaction ID) without creating a second charge. Also test: the same request with a different key should create a new charge; a different request with the same key should be rejected.

Junior/Mid

A retry storm hits your API after a brief outage. How does idempotency protect users, and what do you need to test to verify that protection?

Strong answer: During a retry storm, clients that timed out all retry simultaneously. Without idempotency, each retry creates a new operation — users could be charged or enrolled many times. With idempotency keys, the server recognises a retry and returns the original result without re-executing. To verify: simulate a network partition between client and server (client sends, server processes but client never receives 200), then retry with the same key. Verify the server returns the original result without re-processing. Also test key expiry: what happens when a key is retried after the idempotency window expires? And verify that the idempotency store is consistent under concurrent requests with the same key.

Senior/Lead

Self-Check

Click each question to reveal the answer.

Q1: In one sentence, what does it mean for an operation to be idempotent?

Performing it many times with the same input has the same effect as performing it once — the same result and no additional side effects beyond the first call — so it is safe to retry.

Q2: Why do GET, PUT, and DELETE tend to be idempotent while POST usually is not?

GET only reads. PUT replaces a resource with a given state, so repeating it lands on the same state. DELETE removes the resource; a second delete has no further effect. POST typically creates a new resource or triggers an action (a charge, an email), so each call produces another one — which is why POST needs an idempotency key.

Q3: What are the four design rules for a good idempotency key?

Unique (a different key per distinct operation), stable (the same logical operation uses the same key across retries), immutable once sent (do not change it on retry, or deduplication breaks), and stored for a reasonable lifetime (cache key→response for hours or days, then garbage-collect). A fresh random key per attempt defeats the whole mechanism.

Q4: Beyond comparing the two HTTP responses, what must an idempotency test assert?

It must check the side effects in the system — that only ONE order/record was created, only ONE payment was taken, only ONE email was sent. Two identical 200 responses can still hide a double charge if the server processed the request twice; verifying the database and downstream effects is what proves real idempotency.

Q5: Why is the failure-and-retry scenario the most important one to test?

Idempotency matters most precisely when something went wrong — a timeout, a 500, a lost response — because that is when clients retry. Testing a retry after a first attempt that half-failed (e.g. the charge succeeded but the response was lost) is where duplicate processing actually happens in production, so it is the scenario most worth covering.

Q6: Your team is testing the Benefits NZ benefit payment system. A Jobseeker Support payment is submitted on a Tuesday and the response is lost due to a connectivity issue at a regional Work and Income office. The same request is retried automatically on Wednesday. What should you verify in your test, and why is checking the database more important than checking the HTTP response?

A: Verify that exactly one payment record was created for that client and payment period in the database, that the financial ledger shows a single debit of the correct amount, and that any downstream notifications (e.g. the client’s letter or MySupervisor alert) were triggered only once. Checking the HTTP response alone is insufficient because Benefits NZ’s backend could return an identical 200 OK on both attempts while having processed the payment twice — a compliance and audit breach. The side-effect count in the database is the ground truth of whether idempotency was honoured.

Q7: What is the key difference between idempotency testing and retry testing, and when does a project need both?

A: Retry testing verifies that the client correctly re-sends a failed request — the right number of times, with the right backoff, under the right error conditions. Idempotency testing verifies that the server handles those repeated requests without duplicating side effects. A project needs both when the system has retry logic AND state-changing endpoints: for example, an TransitNZ licence renewal portal that auto-retries on timeout must be tested to confirm the client retries correctly (retry testing) AND that the backend only creates one renewal record and charges the fee once (idempotency testing). Passing one does not imply passing the other.

Q8: A developer on a KiwiSaver contribution processing project says: “We don’t need idempotency keys — our database has a unique constraint on the member ID and contribution period, so duplicates are impossible.” What is wrong with this reasoning and how do you respond?

A: The unique constraint prevents duplicate records for the same member and period, but it does not handle mid-request failures or concurrent retries safely. If two identical requests arrive within milliseconds of each other, the application-layer idempotency check may not have run yet and both could race to insert — one succeeds, one hits the constraint and returns a confusing error to the client, which may then retry again. Additionally, if a single contribution can legitimately be processed more than once in a period (e.g. a top-up), the constraint would incorrectly block valid transactions. A proper idempotency key scoped to the specific request — not just member and period — gives you deduplication logic the constraint cannot provide, while the constraint remains a useful safety net underneath it.

Q9: When should you skip writing an idempotency test for a POST endpoint, even if it modifies state?

A: Skip it when retries are architecturally impossible for that endpoint: for example, a strictly synchronous internal service called by a single batch job that never retries and runs in one process with no message queue involved. Also skip it if you are in early prototyping before any retry mechanism is built — the test will break every time the API shape changes and adds no value until the retry path is real. A useful heuristic: if you cannot draw a realistic path where the same request arrives twice, there is nothing to test. Document the reasoning in your risk log rather than writing a test that gives false confidence.