Designing Idempotent Workflows with Specs

Designing Idempotent Workflows with Specs
Daniel Marsh · Spec-first engineering notes

Idempotency is a product requirement, not an implementation detail. When a payment request times out, the spec — not the engineer — should decide whether retrying is safe. Most specs say nothing about this, and the result is duplicate charges, lost transactions, and incident postmortems that all trace back to one missing section. This guide covers how to spec idempotency keys, write retry-safe acceptance criteria, and classify which operations need idempotency guarantees.

Published on 2026-03-15 · ✓ Updated 2026-03-20 · 7 min read · Author: Daniel Marsh · Review policy: Editorial Policy

Idempotency is a product requirement, not an implementation detail

When a payment request times out after 30 seconds, the client does not know whether the server processed the charge before the connection dropped. Retry without idempotency, and you charge the customer twice. Do not retry, and you risk a lost transaction. This is a product decision — what should happen? — and it belongs in the spec, not in an engineer's head during implementation.

Most teams discover they have no idempotency policy at the worst possible moment: a production incident involving duplicate side effects. A spec that was written before implementation would have forced this conversation before any code existed.

Classifying retry-safe vs. non-retry-safe operations

The first thing the spec needs to establish is which operations are naturally idempotent and which require explicit idempotency mechanisms. This classification drives implementation decisions across the entire service.

## Idempotency classification

Naturally idempotent (safe to retry without a key):
  GET    /v1/orders/{id}        — read-only, no side effects
  PUT    /v1/orders/{id}        — replaces resource, same result on repeat
  DELETE /v1/orders/{id}        — deleting an already-deleted resource returns 404 (OK)

Requires Idempotency-Key header:
  POST   /v1/charges            — creates a financial transaction
  POST   /v1/orders             — creates an order with inventory reservation
  POST   /v1/shipments          — triggers external fulfillment provider

Not retry-safe, no idempotency supported:
  POST   /v1/notifications/send — each call sends a separate message (by design)
  POST   /v1/audit-log          — append-only, duplicates are meaningful

This table is reviewable. When a new endpoint is added, it must be placed in one of these categories before implementation begins.

Operation TypeRetry Safe?Idempotency Key Required?Example
Read (GET)AlwaysNoGET /users/:id
Create (POST)No — causes duplicatesYesPOST /v1/charges
Full update (PUT)Yes — same resultRecommendedPUT /users/:id
Partial update (PATCH)Depends on operationYes for counters/statePATCH /orders/:id/status
Delete (DELETE)Yes — already goneNoDELETE /sessions/:id
Side-effect triggerNo — sends email/chargesYesPOST /notifications/send

Specifying idempotency keys

For operations that require an idempotency key, the spec must define: the header name, the format requirements, the deduplication window, the behavior on key reuse with a different body, and the behavior when the key expires.

## Idempotency-Key specification

Header: Idempotency-Key
Format: UUID v4 (client-generated)
Required on: POST /v1/charges, POST /v1/orders, POST /v1/shipments
If absent: return 400 with error code MISSING_IDEMPOTENCY_KEY

Deduplication behavior:
  - Keys are deduplicated for 24 hours from first successful request.
  - If a key is presented within 24 hours of a successful response, return
    the original response with status 200 (not 201, regardless of original).
  - If the same key is presented while the original request is still processing,
    return 409 with error code IDEMPOTENCY_IN_PROGRESS.
  - If the same key is presented with a different request body, return 422
    with error code IDEMPOTENCY_KEY_REUSE.
  - Keys older than 24 hours are expired; a new request will be processed.

The Idempotency-Key is NOT stored permanently. It is a deduplication window,
not a permanent audit trail.

OpenAPI documentation for idempotent endpoints

The idempotency key requirement must be in the OpenAPI spec, not just in a prose policy document. Without it, generated clients will not include the header, and contract tests will not verify the behavior:

paths:
  /v1/charges:
    post:
      operationId: createCharge
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
            format: uuid
          description: |
            Client-generated UUID. Requests with the same key within 24 hours
            return the original response without re-processing the charge.
      responses:
        '201':
          description: Charge created successfully.
        '400':
          description: Missing or malformed Idempotency-Key header.
        '409':
          description: Request with this key is already being processed.
        '422':
          description: Key reused with a different request body.

Acceptance criteria for idempotent endpoints

Idempotency acceptance criteria test the retry scenarios explicitly. These are the cases that production load testing or a real network timeout will hit:

- Given a POST /v1/charges request with Idempotency-Key: "key-abc-123"
  When the request succeeds and the same key is sent again within 24 hours
  Then the response status is 200 (not 201)
   And the response body is identical to the original charge response
   And no duplicate charge is created in the payment processor

- Given a POST /v1/charges request where the server crashes after committing
  When the client retries with the same Idempotency-Key within 24 hours
  Then the server returns 200 with the original charge response
   And the charge appears exactly once in the billing record

- Given a POST /v1/charges with Idempotency-Key: "key-xyz"
  When the server receives the same key with a different amount
  Then the server returns 422 with error code IDEMPOTENCY_KEY_REUSE
   And no new charge is created

- Given a POST /v1/charges request with no Idempotency-Key header
  When the server receives the request
  Then the server returns 400 with error code MISSING_IDEMPOTENCY_KEY

These criteria are testable before the endpoint ships and can be converted directly into integration tests. They prevent the "let's see what happens when we retry" conversation from occurring in production.

Specifying idempotency across multi-step workflows

Some workflows involve multiple sequential API calls that together constitute a single logical operation: create order, reserve inventory, charge payment, trigger fulfillment. If any step fails, the client needs to know which steps completed and which need to be retried.

The spec for a multi-step workflow should define a workflow-level idempotency key that spans all steps, plus individual step idempotency keys for the steps that have external side effects:

## Checkout workflow idempotency

Workflow Idempotency-Key covers:
  POST /v1/orders             (creates order, reserves inventory)
  POST /v1/charges/{order_id} (charges payment)

If POST /v1/charges fails with 500, the client may retry using:
  - The same Workflow Idempotency-Key
  - Status endpoint: GET /v1/orders/{id}/status
    Returns: {order_id, payment_status, inventory_status, fulfillment_status}

Clients should check status before retrying to avoid double-charging.

What the spec must say about duplicate detection storage

Implementation teams need to know how idempotency keys are stored and for how long, because this affects infrastructure choices. The spec should state this as a requirement, not leave it to the implementor to decide:

## Idempotency key storage requirements

Keys must be stored in a distributed cache (e.g., Redis) shared across
all instances of the service. Local in-memory storage is not acceptable.

Key TTL: 24 hours from the time of the first successful response.

The stored value must include:
  - The original response status code
  - The original response body
  - The original request body hash (for key reuse detection)
  - The timestamp of the original request

If the cache is unavailable, the endpoint must return 503 with
error code IDEMPOTENCY_STORE_UNAVAILABLE. It must NOT fall back to
processing the request without idempotency checking.

The last point is critical. An endpoint that silently processes requests when the idempotency store is down defeats the entire purpose. The spec must name this as a hard requirement before implementation begins.

Testing idempotency in the CI pipeline

Idempotency tests must be in the contract test suite, not just in the unit test suite. Unit tests can verify that the deduplication logic works in isolation. Contract tests verify that the real endpoint behaves correctly when retried with the same key.

# Schemathesis stateful test for idempotency
schemathesis run openapi/api.yaml \
  --base-url http://localhost:3000 \
  --stateful=links \
  --checks all \
  --hypothesis-settings max_examples=50

For more targeted idempotency testing, write explicit Pact interactions that describe the retry scenario from the consumer's perspective. The provider's Pact verification will confirm that the retry returns the original response.

Common idempotency spec mistakes

Keywords: idempotency · idempotency keys · retry-safe operations · designing idempotent workflows with specs · API design

Editorial note

This article covers Designing Idempotent Workflows with Specs for software delivery teams. Examples are illustrative engineering scenarios, not legal, tax, or investment advice.