Idempotency Guide
Prevent duplicate orders and enable safe retries with idempotency keys
Idempotency Guide
Idempotency ensures that multiple identical requests produce the same result as a single request. This is critical for financial operations like order creation and payouts, where duplicate requests could result in double charges.
Why Idempotency Matters
Without idempotency protection, these scenarios can cause duplicate charges:
| Scenario | Risk |
|---|---|
| Network timeout during order | Client retries, order placed twice |
| User double-clicks submit | Two orders created |
| Application crash during processing | Retry creates duplicate |
| Load balancer retry | Same request sent twice |
With idempotency keys, all retries safely return the original response.
Quick Start
Add the Idempotency-Key header to all order and payout requests:
curl -X POST "{{host}}/api/v1/orders" \
-H "X-API-Key: sk_live_abc123def456" \
-H "X-API-Secret: your-secret-here" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: ord_abc123_1705689660" \
-d '{
"product_id": 123,
"denomination": 100,
"quantity": 5
}'Requirements
| Rule | Value |
|---|---|
| Header | Idempotency-Key |
| Required For | POST /api/v1/orders, POST /api/v1/payouts, POST /client/api/cart/checkout |
| Length | 8-256 characters |
| TTL | 24 hours |
| Scope | Per client (your keys are isolated from other clients) |
Key Generation Strategies
Choose a strategy that ensures uniqueness within your system:
# Strategy 1: UUID
IDEMPOTENCY_KEY=$(uuidgen)
# Result: "550e8400-e29b-41d4-a716-446655440000"
# Strategy 2: Order ID + Timestamp (recommended for traceability)
IDEMPOTENCY_KEY="ord_${ORDER_ID}_$(date +%s)"
# Result: "ord_12345_1705689660"
# Use in request
curl -X POST "{{host}}/api/v1/orders" \
-H "X-API-Key: $API_KEY" \
-H "X-API-Secret: $API_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"product_id": 123, "denomination": 100, "quantity": 5}'<?php
// Strategy 1: UUID
function generateUuidKey(): string {
return sprintf('%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
mt_rand(0, 0xffff),
mt_rand(0, 0x0fff) | 0x4000,
mt_rand(0, 0x3fff) | 0x8000,
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
);
}
// Strategy 2: Order ID + Timestamp (recommended)
function generateTraceableKey(string $orderId): string {
return "ord_{$orderId}_" . time();
}
// Strategy 3: Hash of parameters (deterministic)
function generateDeterministicKey(int $productId, float $amount, string $userId): string {
return 'ord_' . substr(hash('sha256', "{$userId}_{$productId}_{$amount}_" . time()), 0, 32);
}
// Usage
$idempotencyKey = generateTraceableKey('INV-2024-001');
$ch = curl_init('{{host}}/api/v1/orders');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . $apiKey,
'X-API-Secret: ' . $apiSecret,
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencyKey
],
CURLOPT_POSTFIELDS => json_encode([
'product_id' => 123,
'denomination' => 100,
'quantity' => 5
])
]);
$response = curl_exec($ch);
curl_close($ch);
?>Response Headers
When idempotency is active, these headers are included in responses:
| Header | Description | Example |
|---|---|---|
Idempotency-Key | Echo of your key | ord_abc123_1705689660 |
Idempotency-Replayed | true if response is from cache | true |
Idempotency-Created-At | When original request was processed | 2026-01-21T10:30:00Z |
Detecting Cached Responses
<?php
// Check if response was replayed from cache
$headers = [];
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function($curl, $header) use (&$headers) {
$len = strlen($header);
$header = explode(':', $header, 2);
if (count($header) < 2) return $len;
$headers[trim($header[0])] = trim($header[1]);
return $len;
});
$response = curl_exec($ch);
if (isset($headers['Idempotency-Replayed']) && $headers['Idempotency-Replayed'] === 'true') {
echo "Response retrieved from cache (original request time: {$headers['Idempotency-Created-At']})\n";
}
?>Error Responses
Missing Idempotency Key (400)
{
"error": {
"code": "IDEMPOTENCY_KEY_REQUIRED",
"message": "Idempotency-Key header is required for this operation",
"details": "Include a unique Idempotency-Key header (8-256 characters)"
}
}Key Too Short (400)
{
"error": {
"code": "IDEMPOTENCY_KEY_TOO_SHORT",
"message": "Idempotency-Key must be at least 8 characters"
}
}Key Reused with Different Body (422)
This occurs when you use the same key but send different request parameters:
{
"error": "IdempotencyKeyReused",
"code": "E_IDEMPOTENCY_KEY_REUSED",
"message": "Idempotency-Key has already been used with a different request body"
}Common causes:
- Reusing the same key for different orders
- Changing order parameters on retry (e.g., quantity)
- Using the same key across different endpoints
Safe Retry Pattern
#!/bin/bash
# Safe retry pattern with idempotency
MAX_RETRIES=3
IDEMPOTENCY_KEY="ord_$(uuidgen)" # Generate ONCE before retries
for i in $(seq 1 $MAX_RETRIES); do
RESPONSE=$(curl -s -w "\n%{http_code}" -X POST "{{host}}/api/v1/orders" \
-H "X-API-Key: $API_KEY" \
-H "X-API-Secret: $API_SECRET" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $IDEMPOTENCY_KEY" \
-d '{"product_id": 123, "denomination": 100, "quantity": 5}')
HTTP_CODE=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [[ "$HTTP_CODE" -ge 200 && "$HTTP_CODE" -lt 300 ]]; then
echo "Success: $BODY"
exit 0
elif [[ "$HTTP_CODE" -ge 400 && "$HTTP_CODE" -lt 500 ]]; then
echo "Client error (no retry): $BODY"
exit 1
else
echo "Attempt $i failed with HTTP $HTTP_CODE, retrying..."
sleep $((2 ** i)) # Exponential backoff
fi
done
echo "All retries exhausted"
exit 1<?php
class OrderClient {
private string $baseUrl;
private string $apiKey;
private string $apiSecret;
public function __construct(string $baseUrl, string $apiKey, string $apiSecret) {
$this->baseUrl = $baseUrl;
$this->apiKey = $apiKey;
$this->apiSecret = $apiSecret;
}
public function createOrderSafely(array $orderData, int $maxRetries = 3): array {
// Generate idempotency key ONCE before any retries
$idempotencyKey = 'ord_' . uniqid() . '_' . time();
for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
try {
$response = $this->doRequest($orderData, $idempotencyKey);
return $response;
} catch (ClientException $e) {
// 4xx errors - don't retry, fix the request
throw $e;
} catch (ServerException $e) {
// 5xx errors - retry with backoff
if ($attempt < $maxRetries) {
sleep(pow(2, $attempt)); // 2, 4, 8 seconds
continue;
}
throw $e;
} catch (NetworkException $e) {
// Network errors - retry with backoff
if ($attempt < $maxRetries) {
sleep(pow(2, $attempt));
continue;
}
throw $e;
}
}
throw new Exception('All retries exhausted');
}
private function doRequest(array $data, string $idempotencyKey): array {
$ch = curl_init($this->baseUrl . '/api/v1/orders');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'X-API-Key: ' . $this->apiKey,
'X-API-Secret: ' . $this->apiSecret,
'Content-Type: application/json',
'Idempotency-Key: ' . $idempotencyKey
],
CURLOPT_POSTFIELDS => json_encode($data)
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($error) {
throw new NetworkException($error);
}
$result = json_decode($response, true);
if ($httpCode >= 400 && $httpCode < 500) {
throw new ClientException($result['message'] ?? 'Client error', $httpCode);
}
if ($httpCode >= 500) {
throw new ServerException($result['message'] ?? 'Server error', $httpCode);
}
return $result;
}
}
// Usage
$client = new OrderClient('{{host}}', 'sk_live_abc123def456', 'your-secret-here');
try {
$order = $client->createOrderSafely([
'product_id' => 123,
'denomination' => 100,
'quantity' => 5
]);
echo "Order created: {$order['id']}\n";
} catch (Exception $e) {
echo "Failed: {$e->getMessage()}\n";
}
?>Best Practices
DO
| Practice | Why |
|---|---|
| Generate key before making request | Don't generate on retry |
| Store the key with your order | Enables debugging and reconciliation |
Use meaningful prefixes (ord_, pay_) | Makes logs easier to read |
| Include timestamps | Helps debugging and prevents collisions |
| Log every request with its key | Makes troubleshooting easier |
DON'T
| Anti-Pattern | Risk |
|---|---|
| Reuse keys for different operations | Causes "key reused" errors |
| Change request body on retry | Breaks idempotency protection |
Use sequential IDs alone (1, 2, 3) | Too predictable, potential conflicts |
| Use user input as key | Users may submit same data intentionally |
| Generate new key on each retry | Defeats the purpose of idempotency |
Endpoints Requiring Idempotency
| Endpoint | Method | Reason |
|---|---|---|
/api/v1/orders | POST | Creates orders, deducts wallet |
/api/v1/payouts | POST | Creates withdrawals, deducts wallet |
/client/api/orders | POST | Client portal order creation |
/client/api/payouts | POST | Client portal payout creation |
/client/api/cart/checkout | POST | Cart checkout (creates orders) |
FAQ
What happens if I don't send an Idempotency-Key?
For /api/v1/orders and /api/v1/payouts, you'll receive a 400 Bad Request error. These endpoints require idempotency keys due to their financial nature.
How long are idempotency keys valid?
Keys are valid for 24 hours. After that, the same key can be reused (though we recommend always using fresh keys).
Can I use the same key for different endpoints?
Yes, keys are scoped per endpoint and client. However, we recommend using distinct prefixes (ord_, pay_) for clarity.
What if my request times out?
Retry with the same idempotency key. If the original request completed, you'll get the cached response. If it didn't, your retry will be processed normally.
Do GET requests need idempotency keys?
No. Idempotency is only for write operations (POST, PUT, PATCH) that modify state.
Related
- Create Order API - Order creation with idempotency
- Create Payout API - Payout creation with idempotency
- Webhooks - Real-time order status updates