Idempotency
Safely retry API requests without creating duplicate charges, customers, or other resources. Learn how to use idempotency keys to build reliable payment integrations.
Overviewโ
Network issues, timeouts, and server errors can cause API requests to fail or return unclear results. Idempotency allows you to safely retry requests without worrying about duplicate operations. By providing an Idempotency-Key header, Omise guarantees the same request will produce the same result, even if sent multiple times.
- Add
Idempotency-Keyheader to POST/PATCH requests - Use unique key per operation (UUID recommended)
- Same key returns same result (cached for 24 hours)
- Essential for charge creation and money operations
- Prevents duplicate payments during network issues
What is Idempotency?โ
Idempotency means an operation can be performed multiple times with the same result. In payment processing, this is critical:
Without Idempotencyโ
1. Send charge request โ Network timeout
2. Did it succeed? Unknown. Retry?
3. Retry โ Duplicate charge! Customer charged twice ๐ฅ
With Idempotencyโ
1. Send charge with idempotency key โ Network timeout
2. Retry with same key โ Same result returned
3. No duplicate charge โ
How Idempotency Worksโ
- You send a request with an
Idempotency-Keyheader - Omise processes it and stores the result
- If you retry with the same key within 24 hours:
- Omise returns the cached result
- No new operation is performed
- Same response status code and body
Example Flowโ
# First request (network timeout)
curl https://api.omise.co/charges \
-X POST \
-u skey_test_...: \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d "amount=100000" \
-d "currency=thb" \
-d "card=tokn_test_..."
# Response: (timeout - unclear if succeeded)
# Retry with same key
curl https://api.omise.co/charges \
-X POST \
-u skey_test_...: \
-H "Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000" \
-d "amount=100000" \
-d "currency=thb" \
-d "card=tokn_test_..."
# Response: Returns the original charge (not a new one)
When to Use Idempotencyโ
Always Use For:โ
โ Creating Charges
POST /charges
Most important - prevents duplicate payments
โ Creating Customers
POST /customers
Prevents duplicate customer records
โ Creating Refunds
POST /charges/:id/refunds
Prevents duplicate refunds
โ Creating Transfers
POST /transfers
Prevents duplicate payouts
โ Creating Recipients
POST /recipients
Prevents duplicate recipient records
โ Any POST Request All POST requests that create resources should use idempotency keys
โ PATCH Requests Updates can be retried safely with idempotency
Not Needed For:โ
โ GET Requests Reading data is already idempotent (no side effects)
โ DELETE Requests Deleting is naturally idempotent (deleting twice = same result)
Idempotency-Key Headerโ
Header Formatโ
Idempotency-Key: <unique-string>
Key Requirementsโ
| Requirement | Description |
|---|---|
| Format | Any string up to 255 characters |
| Uniqueness | Must be unique per operation |
| Characters | Alphanumeric and hyphens recommended |
| Case Sensitive | key-1 โ KEY-1 |
| Lifetime | Stored for 24 hours |
Recommended: Use UUIDsโ
# UUIDv4 format (recommended)
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Why UUIDs?
- โ Guaranteed uniqueness
- โ No collision risk
- โ Standard format
- โ Available in all languages
Implementation Examplesโ
Rubyโ
require 'omise'
require 'securerandom'
Omise.api_key = ENV['OMISE_SECRET_KEY']
# Generate unique idempotency key
idempotency_key = SecureRandom.uuid
begin
charge = Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token
}, {
'Idempotency-Key' => idempotency_key
})
puts "Charge created: #{charge.id}"
rescue Omise::Error => e
if e.http_status >= 500 || e.message.include?('timeout')
# Safe to retry with same key
charge = Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token
}, {
'Idempotency-Key' => idempotency_key # Same key!
})
else
raise
end
end
Pythonโ
import omise
import uuid
omise.api_secret = os.environ['OMISE_SECRET_KEY']
# Generate unique idempotency key
idempotency_key = str(uuid.uuid4())
try:
charge = omise.Charge.create(
amount=100000,
currency='thb',
card=token,
headers={'Idempotency-Key': idempotency_key}
)
print(f"Charge created: {charge.id}")
except omise.errors.BaseError as e:
if e.http_status >= 500:
# Safe to retry with same key
charge = omise.Charge.create(
amount=100000,
currency='thb',
card=token,
headers={'Idempotency-Key': idempotency_key} # Same key!
)
else:
raise
PHPโ
<?php
require_once 'vendor/autoload.php';
define('OMISE_SECRET_KEY', getenv('OMISE_SECRET_KEY'));
// Generate unique idempotency key
$idempotencyKey = 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)
);
try {
$charge = OmiseCharge::create([
'amount' => 100000,
'currency' => 'thb',
'card' => $token
], [
'Idempotency-Key' => $idempotencyKey
]);
echo "Charge created: " . $charge['id'];
} catch (Exception $e) {
if ($e->getCode() >= 500) {
// Retry with same key
$charge = OmiseCharge::create([
'amount' => 100000,
'currency' => 'thb',
'card' => $token
], [
'Idempotency-Key' => $idempotencyKey // Same key!
]);
} else {
throw $e;
}
}
Node.jsโ
const omise = require('omise')({
secretKey: process.env.OMISE_SECRET_KEY
});
const { v4: uuidv4 } = require('uuid');
async function createChargeWithRetry(chargeData, maxRetries = 3) {
// Generate unique idempotency key
const idempotencyKey = uuidv4();
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const charge = await omise.charges.create({
...chargeData,
headers: {
'Idempotency-Key': idempotencyKey
}
});
console.log(`Charge created: ${charge.id}`);
return charge;
} catch (error) {
const isServerError = error.statusCode >= 500;
const isTimeout = error.code === 'ETIMEDOUT';
const isLastAttempt = attempt === maxRetries - 1;
if ((isServerError || isTimeout) && !isLastAttempt) {
// Safe to retry with same key
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
}
// Usage
createChargeWithRetry({
amount: 100000,
currency: 'thb',
card: 'tokn_test_...'
});
Goโ
package main
import (
"fmt"
"github.com/google/uuid"
"github.com/omise/omise-go"
"github.com/omise/omise-go/operations"
"time"
)
func createChargeWithRetry(client *omise.Client, amount int64, currency, token string) (*omise.Charge, error) {
// Generate unique idempotency key
idempotencyKey := uuid.New().String()
maxRetries := 3
for attempt := 0; attempt < maxRetries; attempt++ {
charge, err := client.CreateCharge(&operations.CreateCharge{
Amount: amount,
Currency: currency,
Card: token,
Headers: map[string]string{
"Idempotency-Key": idempotencyKey,
},
})
if err == nil {
return charge, nil
}
// Check if retryable
if omiseErr, ok := err.(*omise.Error); ok {
if omiseErr.StatusCode >= 500 && attempt < maxRetries-1 {
// Retry with exponential backoff
delay := time.Duration(1<<uint(attempt)) * time.Second
time.Sleep(delay)
continue
}
}
return nil, err
}
return nil, fmt.Errorf("max retries exceeded")
}
func main() {
client, _ := omise.NewClient(
os.Getenv("OMISE_PUBLIC_KEY"),
os.Getenv("OMISE_SECRET_KEY"),
)
charge, err := createChargeWithRetry(client, 100000, "thb", "tokn_test_...")
if err != nil {
log.Fatal(err)
}
fmt.Printf("Charge created: %s\n", charge.ID)
}
Idempotency Key Strategiesโ
Strategy 1: UUID per Request (Recommended)โ
Generate a new UUID for each unique operation:
# Each charge gets unique key
charge1_key = SecureRandom.uuid
charge1 = create_charge(amount: 100_000, key: charge1_key)
charge2_key = SecureRandom.uuid
charge2 = create_charge(amount: 50_000, key: charge2_key)
Pros:
- โ Simple and safe
- โ No collision risk
- โ Works for all scenarios
Cons:
- โ ๏ธ Need to store key if you want to check status later
Strategy 2: Derived from Order IDโ
Base key on your internal order/transaction ID:
def get_idempotency_key(order_id):
return f"order-{order_id}"
# Create charge for order
order_id = "ORD-12345"
idempotency_key = get_idempotency_key(order_id)
charge = omise.Charge.create(
amount=100000,
currency='thb',
card=token,
metadata={'order_id': order_id},
headers={'Idempotency-Key': idempotency_key}
)
Pros:
- โ Easy to reconstruct key later
- โ Links charge to your system
- โ Can check if order was already charged
Cons:
- โ ๏ธ Need unique order IDs
- โ ๏ธ Can't retry with different parameters
Strategy 3: Store Key with Requestโ
Save the key in your database:
// Store in database
const payment = await db.payments.create({
order_id: 'ORD-12345',
amount: 100000,
idempotency_key: uuidv4(),
status: 'pending'
});
// Use stored key for charge
const charge = await omise.charges.create({
amount: payment.amount,
currency: 'thb',
card: token,
headers: {
'Idempotency-Key': payment.idempotency_key
}
});
// Update status
await db.payments.update(payment.id, {
status: 'completed',
charge_id: charge.id
});
Pros:
- โ Can retry anytime (key stored)
- โ Audit trail
- โ Can correlate requests
Cons:
- โ ๏ธ More complex
- โ ๏ธ Requires database
Strategy 4: Hash of Request Parametersโ
Generate key from request content:
<?php
function getIdempotencyKey($chargeParams) {
$content = json_encode($chargeParams);
return hash('sha256', $content);
}
$chargeParams = [
'amount' => 100000,
'currency' => 'thb',
'card' => $token,
'metadata' => ['order_id' => 'ORD-12345']
];
$idempotencyKey = getIdempotencyKey($chargeParams);
$charge = OmiseCharge::create($chargeParams, [
'Idempotency-Key' => $idempotencyKey
]);
Pros:
- โ Same parameters = same key automatically
- โ No storage needed
- โ Deterministic
Cons:
- โ ๏ธ Different parameters = different key (might want same charge)
- โ ๏ธ Sensitive to parameter order
- โ ๏ธ Token changes would change key
Key Expirationโ
24-Hour Lifetimeโ
Idempotency keys are stored for 24 hours from first use:
# Day 1, 10:00 AM - First request
curl -H "Idempotency-Key: abc123" ...
# Creates new charge, key stored until Day 2, 10:00 AM
# Day 1, 11:00 AM - Retry
curl -H "Idempotency-Key: abc123" ...
# Returns cached result from 10:00 AM
# Day 2, 11:00 AM - After expiration
curl -H "Idempotency-Key: abc123" ...
# Key expired, creates NEW charge (different from first)
After 24 hours, the same key will create a new resource. Don't reuse keys across different operations or after 24 hours.
Handling Expired Keysโ
# โ Bad - Reusing old key
def create_charge_for_order(order_id)
# This key might be expired if order is old
key = "order-#{order_id}"
Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token
}, {
'Idempotency-Key' => key
})
end
# โ
Good - Always use fresh key
def create_charge_for_order(order_id)
# Combine order ID with timestamp
key = "order-#{order_id}-#{Time.now.to_i}"
Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token,
metadata: { order_id: order_id }
}, {
'Idempotency-Key' => key
})
end
Retry Logic Best Practicesโ
1. Retry Only Transient Errorsโ
function isRetryable(error) {
// Retry server errors
if (error.statusCode >= 500) return true;
// Retry timeouts
if (error.code === 'ETIMEDOUT') return true;
if (error.code === 'ECONNRESET') return true;
// Don't retry client errors
if (error.statusCode >= 400 && error.statusCode < 500) return false;
return false;
}
async function chargeWithRetry(chargeData) {
const idempotencyKey = uuidv4();
const maxRetries = 3;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
return await omise.charges.create({
...chargeData,
headers: { 'Idempotency-Key': idempotencyKey }
});
} catch (error) {
if (!isRetryable(error) || attempt === maxRetries - 1) {
throw error;
}
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
2. Use Exponential Backoffโ
import time
import random
def exponential_backoff(attempt, base_delay=1, max_delay=60):
"""Calculate delay with jitter"""
delay = min(base_delay * (2 ** attempt), max_delay)
jitter = random.uniform(0, delay * 0.1) # Add 10% jitter
return delay + jitter
def create_charge_with_retry(charge_data, max_attempts=5):
idempotency_key = str(uuid.uuid4())
for attempt in range(max_attempts):
try:
return omise.Charge.create(
**charge_data,
headers={'Idempotency-Key': idempotency_key}
)
except omise.errors.BaseError as e:
is_last_attempt = attempt == max_attempts - 1
if e.http_status < 500 or is_last_attempt:
raise
# Wait before retry
delay = exponential_backoff(attempt)
time.sleep(delay)
raise Exception("Max retries exceeded")
3. Set Timeoutsโ
require 'timeout'
def create_charge_with_timeout(charge_params, timeout_seconds: 30)
idempotency_key = SecureRandom.uuid
begin
Timeout.timeout(timeout_seconds) do
Omise::Charge.create(
charge_params,
{ 'Idempotency-Key' => idempotency_key }
)
end
rescue Timeout::Error
# Timeout - safe to retry with same key
retry_charge(charge_params, idempotency_key)
end
end
def retry_charge(params, key, max_retries: 3)
max_retries.times do |attempt|
begin
return Omise::Charge.create(
params,
{ 'Idempotency-Key' => key }
)
rescue Omise::Error => e
raise unless e.http_status >= 500
sleep(2 ** attempt)
end
end
raise "Max retries exceeded"
end
4. Log Retry Attemptsโ
const logger = require('./logger');
async function createChargeWithLogging(chargeData) {
const idempotencyKey = uuidv4();
const maxRetries = 3;
logger.info('Creating charge', {
idempotency_key: idempotencyKey,
amount: chargeData.amount,
currency: chargeData.currency
});
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const charge = await omise.charges.create({
...chargeData,
headers: { 'Idempotency-Key': idempotencyKey }
});
logger.info('Charge created successfully', {
idempotency_key: idempotencyKey,
charge_id: charge.id,
attempt: attempt + 1
});
return charge;
} catch (error) {
logger.error('Charge attempt failed', {
idempotency_key: idempotencyKey,
attempt: attempt + 1,
error_code: error.code,
error_message: error.message,
status_code: error.statusCode
});
const isRetryable = error.statusCode >= 500;
const isLastAttempt = attempt === maxRetries - 1;
if (!isRetryable || isLastAttempt) {
logger.error('Charge failed permanently', {
idempotency_key: idempotencyKey,
total_attempts: attempt + 1
});
throw error;
}
const delay = Math.pow(2, attempt) * 1000;
logger.info('Retrying charge', {
idempotency_key: idempotencyKey,
delay_ms: delay,
next_attempt: attempt + 2
});
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
Advanced Patternsโ
Pattern 1: Distributed Systemsโ
When multiple servers might retry the same operation:
# Use Redis to coordinate retries across servers
require 'redis'
class IdempotentChargeCreator
def initialize(redis_client)
@redis = redis_client
end
def create_charge(order_id, charge_params)
# Try to acquire lock for this order
lock_key = "charge_lock:#{order_id}"
idempotency_key = "charge:#{order_id}"
# Try to set lock (expires in 60 seconds)
locked = @redis.set(lock_key, "locked", nx: true, ex: 60)
unless locked
# Another server is processing this
return wait_for_charge(order_id)
end
begin
# We got the lock - create charge
charge = Omise::Charge.create(
charge_params,
{ 'Idempotency-Key' => idempotency_key }
)
# Store result
@redis.set("charge_result:#{order_id}", charge.to_json, ex: 86400)
charge
ensure
# Release lock
@redis.del(lock_key)
end
end
private
def wait_for_charge(order_id)
# Wait for other server to finish
10.times do
result = @redis.get("charge_result:#{order_id}")
return JSON.parse(result) if result
sleep(0.5)
end
raise "Timeout waiting for charge"
end
end
Pattern 2: Webhook Idempotencyโ
Handle duplicate webhook deliveries:
# Webhooks can be delivered multiple times
# Use event ID as idempotency key
processed_events = set() # Or use database/Redis
def handle_webhook(event_data):
event_id = event_data['id']
# Check if already processed
if event_id in processed_events:
print(f"Event {event_id} already processed")
return
# Process event
if event_data['key'] == 'charge.complete':
process_successful_charge(event_data['data'])
# Mark as processed
processed_events.add(event_id)
# Better: Store in database
def handle_webhook_persistent(event_data):
event_id = event_data['id']
# Try to insert (unique constraint on event_id)
try:
db.events.create({
'event_id': event_id,
'processed_at': datetime.now()
})
except UniqueConstraintError:
# Already processed
return
# Process event
process_event(event_data)
Pattern 3: Background Job Retriesโ
Idempotent job processing:
// Using job queue (e.g., Bull, Sidekiq)
const queue = require('./queue');
queue.process('create-charge', async (job) => {
const { order_id, amount, currency, token } = job.data;
// Use job ID as part of idempotency key
const idempotencyKey = `job-${job.id}-order-${order_id}`;
try {
const charge = await omise.charges.create({
amount,
currency,
card: token,
metadata: { order_id },
headers: { 'Idempotency-Key': idempotencyKey }
});
// Store result
await db.orders.update(order_id, {
charge_id: charge.id,
status: 'charged'
});
return { success: true, charge_id: charge.id };
} catch (error) {
if (error.statusCode >= 500) {
// Retryable - job queue will retry
throw error;
} else {
// Not retryable - mark order as failed
await db.orders.update(order_id, {
status: 'failed',
error: error.message
});
return { success: false, error: error.message };
}
}
});
// Add job
queue.add('create-charge', {
order_id: 'ORD-12345',
amount: 100000,
currency: 'thb',
token: 'tokn_test_...'
}, {
attempts: 5,
backoff: {
type: 'exponential',
delay: 2000
}
});
Testing Idempotencyโ
Test Duplicate Requestsโ
# Test that duplicate requests return same result
describe 'Idempotency' do
it 'returns same charge for duplicate requests' do
idempotency_key = SecureRandom.uuid
# First request
charge1 = Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token
}, {
'Idempotency-Key' => idempotency_key
})
# Duplicate request
charge2 = Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token
}, {
'Idempotency-Key' => idempotency_key
})
# Should be the same charge
expect(charge2.id).to eq(charge1.id)
expect(charge2.amount).to eq(charge1.amount)
end
it 'creates new charge with different key' do
# First request
charge1 = Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token
}, {
'Idempotency-Key' => SecureRandom.uuid
})
# Different key
charge2 = Omise::Charge.create({
amount: 100000,
currency: 'thb',
card: token
}, {
'Idempotency-Key' => SecureRandom.uuid
})
# Should be different charges
expect(charge2.id).not_to eq(charge1.id)
end
end
Simulate Network Errorsโ
// Test retry logic
const nock = require('nock');
describe('Idempotent Retry', () => {
it('retries on server error', async () => {
const idempotencyKey = uuidv4();
// First request fails
nock('https://api.omise.co')
.post('/charges')
.matchHeader('Idempotency-Key', idempotencyKey)
.reply(500, { error: 'Internal Server Error' });
// Retry succeeds
nock('https://api.omise.co')
.post('/charges')
.matchHeader('Idempotency-Key', idempotencyKey)
.reply(200, {
object: 'charge',
id: 'chrg_test_123',
amount: 100000
});
const charge = await createChargeWithRetry({
amount: 100000,
currency: 'thb',
card: 'tokn_test_...'
});
expect(charge.id).toBe('chrg_test_123');
});
});
Common Mistakesโ
โ Don't: Reuse Keys Across Operationsโ
# โ Bad - Same key for different operations
idempotency_key = "my-key"
charge1 = omise.Charge.create(
amount=100000,
headers={'Idempotency-Key': idempotency_key}
)
charge2 = omise.Charge.create(
amount=50000, # Different amount!
headers={'Idempotency-Key': idempotency_key}
)
# charge2 will return charge1's result!
# โ
Good - Unique key per operation
charge1 = omise.Charge.create(
amount=100000,
headers={'Idempotency-Key': str(uuid.uuid4())}
)
charge2 = omise.Charge.create(
amount=50000,
headers={'Idempotency-Key': str(uuid.uuid4())}
)
โ Don't: Forget to Handle Cached Errorsโ
# โ Bad - Original request failed, retry returns same error
idempotency_key = SecureRandom.uuid
begin
Omise::Charge.create({
amount: 100, # Too small!
currency: 'thb'
}, {
'Idempotency-Key' => idempotency_key
})
rescue Omise::Error => e
# Retry with fixed amount but same key
Omise::Charge.create({
amount: 100000, # Fixed amount
currency: 'thb'
}, {
'Idempotency-Key' => idempotency_key # Same key = cached error!
})
# Returns same error, not new charge
end
# โ
Good - Use new key for corrected request
begin
Omise::Charge.create({
amount: 100,
currency: 'thb'
}, {
'Idempotency-Key' => SecureRandom.uuid
})
rescue Omise::Error => e
# Use NEW key for fixed request
Omise::Charge.create({
amount: 100000,
currency: 'thb'
}, {
'Idempotency-Key' => SecureRandom.uuid # New key!
})
end
โ Don't: Retry Client Errorsโ
// โ Bad - Retrying invalid request
async function badRetry() {
const idempotencyKey = uuidv4();
for (let i = 0; i < 3; i++) {
try {
return await omise.charges.create({
amount: 100, // Too small - will always fail!
currency: 'thb',
headers: { 'Idempotency-Key': idempotencyKey }
});
} catch (error) {
// Retrying won't help - request is invalid
continue;
}
}
}
// โ
Good - Only retry server errors
async function goodRetry(chargeData) {
const idempotencyKey = uuidv4();
for (let i = 0; i < 3; i++) {
try {
return await omise.charges.create({
...chargeData,
headers: { 'Idempotency-Key': idempotencyKey }
});
} catch (error) {
// Only retry server errors
if (error.statusCode < 500) {
throw error; // Don't retry client errors
}
// Continue retry loop for server errors
}
}
}
Quick Referenceโ
Idempotency Headerโ
Idempotency-Key: <unique-string>
When to Useโ
| Request Type | Use Idempotency? |
|---|---|
| POST (create) | โ Always |
| PATCH (update) | โ Recommended |
| GET (read) | โ Not needed |
| DELETE | โ Not needed |
Key Lifetimeโ
- Stored: 24 hours from first use
- Expired: Creates new resource after 24 hours
Retry Decision Treeโ
Request failed?
โโ Yes โ Check error type
โ โโ 5xx Server Error โ Retry with SAME key
โ โโ Network timeout โ Retry with SAME key
โ โโ 4xx Client Error โ Fix request, use NEW key
โ โโ Other error โ Don't retry
โโ No โ Success!
Example Implementationโ
def create_charge_idempotently(params)
key = SecureRandom.uuid
Omise::Charge.create(
params,
{ 'Idempotency-Key' => key }
)
rescue Omise::Error => e
raise unless e.http_status >= 500
# Retry with same key
Omise::Charge.create(
params,
{ 'Idempotency-Key' => key }
)
end
Related Resourcesโ
Next: Learn about API Versioning to manage version changes and maintain backward compatibility.