Learn how to use idempotency keys to safely retry API requests without duplicating operations.
What is Idempotency?
Idempotency is the property of an API operation where making the same request multiple times produces the same result as making it once. This is critical for payment processing and order management, where duplicate operations can result in double charges or duplicate orders.
DEUNA APIs support idempotency, allowing you to safely retry requests without concern for accidentally generating the same operation twice.
Idempotency is entirely optional but strongly recommended for any operation that creates or modifies resources (e.g., payments, orders, refunds).
How It Works
When you send a request with an idempotency key:
- First request — DEUNA processes the request normally and stores the response (status code + payload) associated with your idempotency key.
- Subsequent requests with the same key — DEUNA returns the stored response from the original request without reprocessing it.
- Different key — DEUNA treats it as a new, independent request.
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │────────▶│ DEUNA API │────────▶│ Provider │
│ │ │ │ │ │
│ Request │ │ Check key: │ │ Process │
│ + Key │ │ New? Process │ │ payment │
│ │ │ Exists? Return│ │ │
│ │◀────────│ cached resp │◀────────│ Response │
└──────────┘ └──────────────┘ └──────────────┘Supported HTTP Methods
Idempotency keys are supported for the following HTTP methods:
| HTTP Method | Idempotency Support | Notes |
|---|---|---|
POST | ✅ Supported | Required for payment creation, order creation, refunds |
GET | ⚪ Not needed | GET requests are naturally idempotent |
PUT | ⚪ Not needed | PUT requests are naturally idempotent |
PATCH | ⚪ Not needed | PATCH requests are naturally idempotent |
DELETE | ⚪ Not needed | DELETE requests are naturally idempotent |
Idempotency keys are primarily used withPOSTrequests, since these are the ones that create new resources.
Implementation Guide
Step 1: Generate an Idempotency Key
Generate a unique key for each distinct operation. We recommend using UUID v4 format.
// JavaScript — Generate a UUID v4 idempotency key
const { v4: uuidv4 } = require('uuid');
const idempotencyKey = uuidv4();
// Example output: "f47ac10b-58cc-4372-a567-0e02b2c3d479"# Python — Generate a UUID v4 idempotency key
import uuid
idempotency_key = str(uuid.uuid4())
# Example output: "f47ac10b-58cc-4372-a567-0e02b2c3d479"
Important: Each unique business operation must have its own idempotency key. Do NOT reuse keys across different operations.
Step 2: Include the Header in Your Request
Add the X-Idempotency-Key header to your API request.
curl -X POST https://api.deuna.io/v1/orders \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: f47ac10b-58cc-4372-a567-0e02b2c3d479" \
-d '{
"order": {
"order_id": "order-12345",
"currency": "USD",
"items_total_amount": 5000,
"total_amount": 5000
}
}'// JavaScript — POST request with idempotency key using fetch
const idempotencyKey = uuidv4();
const response = await fetch('https://api.deuna.io/v1/orders', {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey,
},
body: JSON.stringify({
order: {
order_id: 'order-12345',
currency: 'USD',
items_total_amount: 5000,
total_amount: 5000,
},
}),
});
const data = await response.json();
console.log(data);# Python — POST request with idempotency key using requests
import requests
import uuid
idempotency_key = str(uuid.uuid4())
response = requests.post(
'https://api.deuna.io/v1/orders',
headers={
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotency_key,
},
json={
'order': {
'order_id': 'order-12345',
'currency': 'USD',
'items_total_amount': 5000,
'total_amount': 5000,
}
}
)
print(response.json())Step 3: Implement Retry Logic
When a request fails due to network issues or timeouts, retry with the same idempotency key to avoid duplicate operations.
// JavaScript — Retry logic with idempotency
async function makeIdempotentRequest(url, payload, maxRetries = 3) {
const idempotencyKey = uuidv4(); // Generate ONCE per operation
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotencyKey, // Same key on every retry
},
body: JSON.stringify(payload),
});
if (response.ok) {
return await response.json();
}
// Don't retry on client errors (4xx) except 408, 429
if (response.status >= 400 && response.status < 500
&& response.status !== 408 && response.status !== 429) {
throw new Error(`Client error: ${response.status}`);
}
console.log(`Attempt ${attempt} failed (${response.status}). Retrying...`);
} catch (error) {
if (attempt === maxRetries) throw error;
console.log(`Attempt ${attempt} failed: ${error.message}. Retrying...`);
}
// Exponential backoff: 1s, 2s, 4s
await new Promise(resolve => setTimeout(resolve, Math.pow(2, attempt - 1) * 1000));
}
}# Python — Retry logic with idempotency
import time
import uuid
import requests
def make_idempotent_request(url, payload, max_retries=3):
idempotency_key = str(uuid.uuid4()) # Generate ONCE per operation
for attempt in range(1, max_retries + 1):
try:
response = requests.post(
url,
headers={
'Authorization': 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json',
'X-Idempotency-Key': idempotency_key, # Same key on every retry
},
json=payload,
timeout=30,
)
if response.ok:
return response.json()
# Don't retry on client errors (4xx) except 408, 429
if 400 <= response.status_code < 500 \
and response.status_code not in (408, 429):
raise Exception(f"Client error: {response.status_code}")
print(f"Attempt {attempt} failed ({response.status_code}). Retrying...")
except requests.exceptions.RequestException as e:
if attempt == max_retries:
raise
print(f"Attempt {attempt} failed: {e}. Retrying...")
# Exponential backoff: 1s, 2s, 4s
time.sleep(2 ** (attempt - 1))Best Practices
✅ Do's
- Generate a unique key per operation — Use UUID v4 or a similar globally unique identifier.
- Reuse the same key for retries — When retrying a failed request, always use the same idempotency key.
- Store the key alongside the operation — Persist the key in your database so you can retry if needed.
- Use exponential backoff for retries — Avoid overwhelming the API with rapid retry attempts.
- Set a reasonable timeout — DEUNA's production timeout is 60 seconds.
❌ Don'ts
- Don't reuse keys for different operations — Each new business operation (new payment, new order) must use a fresh key.
- Don't use sequential or predictable keys — Sequential keys can lead to collisions. Always use random UUIDs.
- Don't change the request body with the same key — Sending a different payload with the same key will result in an error.
- Don't rely on idempotency as a substitute for proper error handling — Always implement proper error handling alongside idempotency.
Common Use Cases
Payment Processing
# Creating a payment — use idempotency to prevent double charges
curl -X POST https://api.deuna.io/v1/merchants/payments \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: pay-a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{
"token": "order_token_here",
"payment_source": {
"card_token": "card_token_here"
}
}'Order Creation
# Creating an order — use idempotency to prevent duplicate orders
curl -X POST https://api.deuna.io/v1/orders \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: ord-a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{
"order": {
"order_id": "my-order-001",
"currency": "USD",
"items_total_amount": 10000,
"total_amount": 10000
}
}'Refunds
# Processing a refund — use idempotency to prevent double refunds
curl -X POST https://api.deuna.io/v1/merchants/transactions/{transaction_id}/refund \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: ref-a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
-d '{
"reason": "Customer requested refund",
"amount": 5000
}'Error Handling
| Scenario | Behavior | Recommended Action |
|---|---|---|
| First request succeeds | Response stored with key | Store response, proceed normally |
| Retry with same key + same body | Returns cached response | Use the response as if it were fresh |
| Retry with same key + different body | Returns 422 Unprocessable Entity | Generate a new key for the new operation |
| Key has expired | Treated as a new request | Generate a new key and retry |
| Network timeout | No response received | Retry with the same key |
| Server error (5xx) | Operation may or may not have completed | Retry with the same key |
Concurrency and Timing Cases
When multiple requests share the same idempotency key, DEUNA coordinates them so only one is processed and all the others receive the same stored result.
Case 1: Simultaneous Requests with the Same Key
Given two requests arrive with the same idempotency key at the same time,
When one of them acquires the processing lock,
Then the other waits (up to the wait timeout) and receives the stored result once the first request completes.
┌──────────┐ ┌───────────────┐ ┌──────────────┐
│ Client │ │ DEUNA API │ │ Storage │
└────┬─────┘ └───────┬───────┘ └──────┬───────┘
│ │ │
│ Request A + key K │ │
│───────────────────────▶│ Acquire lock │
│ │────────────────────────▶│
│ │ Lock acquired │
│ │◀────────────────────────│
│ │ │
│ Request B + key K │ │
│───────────────────────▶│ Acquire lock │
│ │────────────────────────▶│
│ │ Lock busy — wait │
│ │◀────────────────────────│
│ │ │
│ │ Store result + notify │
│ │────────────────────────▶│
│◀───────────────────────│ Result A (stored) │
│◀───────────────────────│ Result A (stored) │Case 2: Retry After a Network Timeout
Given the first request was processed successfully server-side, but the client never received the response (network timeout),
When the client retries with the same key and body,
Then DEUNA returns the stored result from the original request without reprocessing the operation.
Case 3: Same Key, Different Body
Given a request with key K and body A was already processed,
When a new request arrives with the same key K but a different body B,
Then DEUNA rejects the request with HTTP 400 Bad Request and error code IS-4401 (idempotency key body mismatch). The new request is not processed and no response is cached.
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"code":"IS-4401","description":"idempotency key body mismatch"}Use a new idempotency key for the new operation. The same key will keep rejecting different bodies.
Case 4: Many Concurrent Retries with the Same Key
Given multiple retries with the same key are in flight at the same time,
When one of them completes the operation,
Then all the requests waiting on that key receive the same stored result.
Case 5: Lock Expiry During a Long Operation (known operable risk)
Given the original operation takes longer than the processing lock TTL (30 s by default),
When a new request with the same key arrives after the lock expired but while the original operation is still running,
Then the new request may acquire the lock and execute the operation again, potentially duplicating the side effect.
This is a known operable risk of the platform. Keep idempotent operations short and avoid operations that routinely exceed the lock TTL.
Case 6: In-Flight Request Beyond the Wait Window (known operable risk)
Given a retry arrives while the original request is still being processed,
When the retry exhausts the wait timeout (3 s by default) without the original request completing,
Then the retry receives HTTP 409 Conflict with error code IS-4409 (concurrent request in progress) and cannot tell whether the operation eventually completed.
For example, sending two simultaneous POST /v2/merchants/orders/purchase requests with the same X-Idempotency-Key, where the first request takes longer than the wait window:
HTTP/1.1 409 Conflict
Content-Type: application/json
{"code":"IS-4409","description":"concurrent request in progress"}If the first request completes within the wait window, the second request receives the stored result from the first request (replay). It only receives IS-4409 when the first request does not finish in time.
This is a known operable risk. If you receive
IS-4409, retry with the same key — the stored result is returned as soon as the operation finishes, or the key is released if it never does.
Error and Degradation Cases
How the idempotency layer behaves when errors occur and when the underlying storage degrades.
Case 7: Error Responses Are Cached for a Shorter Time
Given a request fails with a client (4xx) or server (5xx) error,
When the same key is retried,
Then the stored error response is returned for a short window (2 s by default). After that window, the key is treated as a new request and the operation can be retried.
Successful results are stored for 24 hours by default; error results for only 2 seconds.
Case 8: Storage Degradation
8a. Rate Limiter Fails Open
Given the rate limiter cannot reach the underlying storage,
When requests arrive,
Then the rate limiter fails open: all requests are temporarily allowed through, bypassing the request quota until the storage recovers.
8b. Circuit Breaker Opens
Given storage errors exceed the circuit breaker threshold,
When a new request tries to acquire the processing lock,
Then the request is allowed through (fail-open): the operation executes normally but without idempotency protection. No IS-5503 error is returned to the client; the gateway falls back to a plain request.
8c. Completion and Release Are Not Circuit-Protected (known operable risk)
Given the storage is degraded while the circuit breaker is open,
When an operation completes or a lock is released,
Then the completion and release steps are not protected by the circuit breaker and may fail silently. The client still receives the operation's response, but the result may not be cached, so a later retry with the same key could re-execute the operation.
This is a known operable risk. The operation may have executed even if the result was not cached. Verify the operation status before retrying to avoid duplicates.
Case 9: Storage Unavailable → Fail-Open
Given the underlying storage is unavailable,
When any idempotency step (acquire, complete, release) is attempted,
Then the request is allowed through (fail-open): the operation executes normally but without idempotency protection. The client does not receive an error code; the gateway falls back to a plain request.
Case 10: Client Cancellation While Waiting
Given a request is waiting on another in-flight request with the same key,
When the client cancels the request,
Then the wait is aborted and the client receives HTTP 409 Conflict with error code IS-4409 (concurrent request in progress). The original operation continues unaffected.
Case 11: Retention TTL Mismatch Between Acquire and Complete
Given an operation uses a different retention TTL in the acquire step than in the complete step,
When the operation finishes,
Then the stored result may expire at a different time than the request hash, breaking deduplication once the shorter TTL expires.
Keep the retention TTL consistent across all steps of the same operation.
Error Codes
The idempotency layer returns the following error codes. When the idempotency service itself is degraded (storage unavailable, circuit breaker open), requests are allowed through fail-open — no error code is returned and the request executes without idempotency protection.
| Code | Condition | Currently emitted | Retryable | Recommended action |
|---|---|---|---|---|
IS-4401 | body hash mismatch | ✅ As error (HTTP 400) | ❌ No | Do not retry. Generate a new idempotency key for the new operation |
IS-4409 | concurrent request in progress | ✅ As error (HTTP 409) | ✅ Yes | Retry with the same key once the lock is released or the operation completes |
IS-4429 | rate limit exceeded | ✅ As error (HTTP 429) | ✅ Yes | Wait for the rate-limit window to reset, then retry with the same key |
If you do not receive one of these codes but the request still went through, the idempotency service may have been degraded at that moment and the request was executed without idempotency protection (fail-open). Verify the operation status before retrying to avoid duplicates.
Idempotency Key Lifecycle
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Created │────▶│ Active │────▶│ Expired │
│ │ │ │ │ │
│ Key sent in │ │ Response is │ │ Key is no │
│ first req │ │ cached and │ │ longer valid│
│ │ │ returned on │ │ │
│ │ │ retries │ │ │
└─────────────┘ └─────────────┘ └─────────────┘- Created — The key is sent for the first time with a request.
- Active — DEUNA has processed the request and cached the response. Any subsequent request with the same key returns the cached response.
- Expired — After a period of time, the key expires and a new request with the same key would be treated as a new operation.
Tip: Always generate a new idempotency key for each distinct business operation, even if a previous key has expired.
Testing in Sandbox
You can test idempotent behavior in the sandbox environment:
-
Send a request with an idempotency key to the sandbox URL:
https://api.sandbox.deuna.io -
Send the same request again with the same key and verify you receive the exact same response.
-
Send a request with a different key and verify a new resource is created.
-
Change the request body with the same key and verify you receive an error.
Use the DEUNA Postman Collection for quick testing.
Next Steps
- DEUNA API Overview — Learn about the full API architecture.
- Response Codes — Understand API error codes and how to handle them.
- Environments — Learn about Sandbox and Production environments.
- Rate Limits — Understand API rate limiting policies.
- Webhooks — Set up webhooks for asynchronous event notifications.