Idempotent requests

Network failures, timeouts, and server errors happen in production. Idempotency keys let you retry failed requests safely without creating duplicate orders, charging customers twice, uploading duplicate files, or sending multiple notifications for the same event. This guide explains how Commerce APIs handle idempotency and how to implement retry logic that protects your customers and your business.


How it works

For an operation that supports idempotency, Commerce assigns an idempotency key to each request. If you don't send one, Commerce generates a key for that individual request. To make a later retry find the original result, send your own stable key in the Idempotency-Key header. Order creation also accepts the same value as request_meta.idempotency_key; if you send both locations, they must match. A successful request replayed with the same key and payload within 24 hours returns the original response without re-executing the operation.

Failed requests are not cached—if a request fails with validation errors, authentication issues, or server errors, you can immediately retry with the same idempotency key after addressing the underlying problem. This eliminates the burden of generating new keys for legitimate retries while still protecting against duplicate successful operations.

Idempotency is enabled operation by operation, not automatically for every mutation. Check the endpoint reference before relying on replay protection. For orders, create, pay, confirm payment, request confirmation, update, finalize, send invoice, and send receipt support idempotent retries. Read-only order lookup and pagination do not.

The idempotency guarantee applies per application—two different applications can use the same idempotency key without conflict. Client-provided keys are case-sensitive and must be between 1 and 255 characters. Commerce recommends constructing keys from stable business identifiers like order_${orderNumber} or payment_${cartId}_${timestamp} rather than generating a fresh random value for each attempt—this makes debugging easier and ensures retries genuinely duplicate the original intent.

Important: Idempotency protects against duplicate execution of the same operation. If you reuse the same key with a different operation payload, Commerce returns idempotency_key_conflict before executing the new operation. Fields inside request_meta are request controls and are excluded from that operation comparison.


When to use idempotency keys

Supply stable idempotency keys for any operation where duplicates would cause problems:

Critical operations (always supply a stable key)

  • Creating orders - Prevents charging customers twice for the same purchase
  • Executing payments - Ensures payment attempts aren't duplicated during retries
  • Scheduling payouts - Avoids sending funds multiple times to the same destination
  • Sending notifications - Prevents customers from receiving duplicate order confirmations or payment receipts

When network failures occur

  • Socket timeouts before receiving a response
  • Connection drops mid-request
  • HTTP 5xx server errors (500, 502, 503, 504)
  • HTTP 429 rate limit errors (safe to retry with backoff)

How to use idempotency keys

Use the Idempotency-Key request header on any operation whose reference marks idempotency as supported. Order creation also accepts request_meta.idempotency_key. The key is a string between 1 and 255 characters that identifies one logical operation. If you send it in both locations, the values must match.

Using idempotency keys

POST
/orders/new
curl https://api.zebo.dev/orders/new \
  -H "Authorization: Bearer $COMMERCE_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_checkout_abc123_1737371200" \
  -d '{
  "request_meta": {
    "idempotency_key": "order_checkout_abc123_1737371200"
  },
  "customer_data": {
    "name": "Akua Mensah",
    "email_address": "[email protected]"
  },
  "finalize": true,
  "line_items": [
    {
      "type": "product",
      "product": {
        "name": "Monthly subscription",
        "price": {
          "currency": "ghs",
          "value": 5000
        },
        "quantity": 1,
        "type": "service"
      }
    }
  ]
}'

The pattern here is to construct idempotency keys from stable business identifiers rather than generating a new random value for each attempt. The checkout_id identifies the specific operation (which cart or session), while the timestamp ensures uniqueness if the same cart is checked out multiple times. If a request times out, retry with the exact same key to retrieve the original response. If you omit the key, Commerce still processes the request with an auto-generated UUIDv7 key, but your next retry will not know that generated key.


Constructing effective keys

Good client-provided idempotency keys are predictable, debuggable, and tied to business events. They should contain enough context to understand what operation they represent when reading logs or investigating issues.

Recommended patterns:

  • order_${cart_id}_${timestamp} - Order creation from shopping cart
  • payment_${order_id}_attempt - Payment execution for an order
  • payout_${batch_id}_${currency} - Scheduled payout for a batch
  • notify_${resource_type}_${resource_id}_${event} - Event-driven notifications
  • refund_${payment_id}_${amount} - Partial or full refund operations

Key characteristics:

  • Stable identifiers - Use business IDs (order numbers, cart IDs) for retryable operations
  • Event context - Include what happened (confirmed, failed, shipped)
  • Resource type - Prefix with resource name (order_, payment_, payout_)
  • Timestamp when needed - Add Unix timestamp for operations that can repeat legitimately
  • Length constraint - Keep under 255 characters (Commerce enforces 1-255 character range)

Avoid these patterns:

  • random_uuid() per attempt - Each retry uses a different key, so it cannot replay the original response
  • request_${counter} - Counter state is hard to maintain across retries
  • ${timestamp}_only - Doesn't identify what operation is being attempted
  • order - Not unique, will incorrectly return cached response

Retry behavior and timing

When you retry a successful request with an idempotency key, Commerce returns the original response without executing the operation again. The replay uses the original HTTP status, original response body, and replay-safe response headers. Failed requests are not cached, so you can retry immediately with the same key after fixing validation errors, authentication issues, or waiting out transient server problems.

What gets cached:

  • Successful responses only - 2xx status codes with full response body are stored for 24 hours
  • Failed requests are not cached - 4xx and 5xx errors can be retried immediately with the same key
  • In-progress requests are protected - if the first request is still executing, a retry with the same key returns idempotency_key_in_progress
  • Payload conflicts are rejected - same key plus different operation payload returns idempotency_key_conflict

In-progress retries:

Commerce reserves an idempotency key before executing a mutation, then stores the successful response after the mutation completes. If a retry arrives while the first request is still running, Commerce returns 409 Conflict with idempotency_key_in_progress. Do not change the key for that retry. Keep retrying the same request with exponential backoff so the retry can replay the original response once it completes.

In rare cases, a worker can stop after reserving the key but before storing the successful response or releasing the reservation. Commerce treats that reserved key as in progress to avoid duplicate side effects. If idempotency_key_in_progress continues for several minutes on the same key, contact support with the endpoint, approximate request time, and key value.

If idempotency storage is temporarily unavailable, Commerce returns 503 Service Unavailable with idempotency_storage_unavailable. Retry the same request with the same key later; do not switch to a new key for the retry.

Cache duration:

Commerce stores successful responses for 24 hours from the original request. After 24 hours, the same idempotency key can be reused for a genuinely new operation. This 24-hour window is sufficient for most use cases while allowing keys to naturally expire for legitimate new operations.

Retry strategy:

Implement exponential backoff when retrying failed requests—don't hammer the API with rapid retries. Start with a 1-second delay and double it after each failure:

  1. First retry: wait 1 second
  2. Second retry: wait 2 seconds
  3. Third retry: wait 4 seconds
  4. Fourth retry: wait 8 seconds
  5. Fifth retry: wait 16 seconds
  6. Give up or alert support after 5 attempts

This approach gives transient issues time to resolve while avoiding API rate limits. For HTTP 429 rate limit errors, respect the Retry-After header instead of using exponential backoff.


Idempotency key requirements

Commerce generates a UUIDv7 key when you don't provide one. If you do provide an idempotency key, Commerce validates it before processing the request to ensure it meets system constraints:

Length constraints:

  • Minimum: 1 character
  • Maximum: 255 characters
  • Keys outside this range return a 400 Bad Request error

Format requirements:

  • Case-sensitive - Order_123 and order_123 are different keys
  • No special encoding - Use plain ASCII or UTF-8, no URL encoding required
  • No whitespace inside the key - Leading and trailing spaces are trimmed, then whitespace or control characters are rejected

Uniqueness scope:

  • Per application - Different applications can use the same key without conflict
  • Per endpoint - Same key can be used for different operations (order vs. payment)
  • Time-bound - Keys expire after 24 hours and can be reused

If you provide an invalid idempotency key (too long or improper format), Commerce returns an error before attempting to process the request—no operation is executed, and you can immediately retry with a corrected key. If you omit the key, Commerce generates a valid UUIDv7 key automatically.


Was this page helpful?