Idempotency

What is Idempotency?

Idempotency is the ability to ensure that the same operation can be made multiple times with the same effect as a single execution. This is critical for building reliable integrations that can safely retry failed requests without creating duplicate resources or triggering unintended side effects.

  • Safe Retries: Retry failed requests without creating duplicates
  • Network Resilience: Handle network failures and timeouts smoothly
  • Prevent Duplicates: Avoid duplicate contracts, invoices, or payments
  • 24-Hour Cache: Responses are cached and reused for 24 hours

When to Use Idempotency Keys

Use idempotency keys for any operation that creates or modifies resources:

OperationUse Idempotency Key?
Creating a contract✅ Yes
Updating contract details✅ Yes
Creating invoice adjustments✅ Yes
Submitting timesheets✅ Yes
Fetching contracts (GET)❌ No (already idempotent)
Deleting resources❌ No (DELETE is naturally idempotent)

Idempotency keys are supported for POST and PATCH requests only.

How It Works

When you include an Idempotency-Key header in your request:

  1. First request: Deel processes the request normally and caches the successful response (2xx status codes only)
  2. Duplicate request (same key within 24 hours): Deel immediately returns the cached response without processing again
  3. Cached responses include an x-original-request-id header indicating the response is from cache

Implementation

Generating Idempotency Keys

Always use a randomly generated UUID v4 for idempotency keys:

1const { v4: uuidv4 } = require('uuid');
2
3// Generate a unique idempotency key
4const idempotencyKey = uuidv4();
5
6console.log(idempotencyKey); // e.g., "550e8400-e29b-41d4-a716-446655440000"

Key Requirements:

  • Must be unique for at least 24 hours
  • Can be up to 64 characters long
  • Use UUID v4 for best results
  • Never reuse keys across different operations

Making Idempotent Requests

Include the Idempotency-Key header in your POST or PATCH requests:

$curl -X POST 'https://api.letsdeel.com/rest/v2/contracts' \
> -H 'Content-Type: application/json' \
> -H 'Authorization: Bearer YOUR_TOKEN' \
> -H 'Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000' \
> -d '{
> "client_id": "org_123",
> "worker_email": "contractor@example.com",
> "job_title": "Backend Developer",
> "scope": "Software Development",
> "rate": 100,
> "rate_type": "hourly"
> }'

Handling Cached Responses

When Deel returns a cached response, you can identify it by checking for the x-original-request-id header:

1const response = await axios.post(url, data, {
2 headers: {
3 'Idempotency-Key': idempotencyKey
4 }
5});
6
7// Check if response is from cache
8const originalRequestId = response.headers['x-original-request-id'];
9
10if (originalRequestId) {
11 console.log(`Cached response from request: ${originalRequestId}`);
12 console.log('This request was not processed again');
13} else {
14 console.log('New request processed successfully');
15}

Handling Concurrent Requests

If you send multiple requests with the same idempotency key while a request is still in progress, you’ll receive a 429 error:

1{
2 "error": "Request already in progress",
3 "status": 429,
4 "headers": {
5 "Retry-After": "5"
6 }
7}

How to handle:

1async function createContractWithRetry(contractData, idempotencyKey) {
2 try {
3 return await createContract(contractData, idempotencyKey);
4 } catch (error) {
5 if (error.response?.status === 429) {
6 const retryAfter = error.response.headers['retry-after'] || 2;
7 console.log(`Request in progress. Waiting ${retryAfter}s...`);
8
9 // Wait and retry
10 await new Promise(resolve => setTimeout(resolve, retryAfter * 1000));
11 return createContract(contractData, idempotencyKey);
12 }
13 throw error;
14 }
15}

If you intentionally cancel a request and want to retry, wait 2 minutes before using the same idempotency key.

Best Practices

When making critical requests, store the idempotency key in your database alongside the request data:

1// Before making request
2const idempotencyKey = uuidv4();
3await db.contracts.create({
4 data: contractData,
5 idempotencyKey: idempotencyKey,
6 status: 'pending'
7});
8
9// Make request
10const response = await createContract(contractData, idempotencyKey);
11
12// Update status
13await db.contracts.update({
14 where: { idempotencyKey },
15 data: { status: 'created', deelId: response.id }
16});

Benefits:

  • Track which requests succeeded
  • Retry failed requests with same key
  • Audit trail of all attempts

Always include idempotency keys for operations that create or modify data:

1// ✅ Good - Using idempotency key
2await createContract(data, { idempotencyKey: uuidv4() });
3await updateContract(id, data, { idempotencyKey: uuidv4() });
4await createInvoiceAdjustment(data, { idempotencyKey: uuidv4() });
5
6// ❌ Bad - No idempotency key
7await createContract(data); // Risk of duplicates on retry

Each unique operation should have its own idempotency key:

1// ❌ Bad - Reusing key for different operations
2const key = uuidv4();
3await createContract(data1, key);
4await createContract(data2, key); // Wrong! Will return first contract
5
6// ✅ Good - Unique key per operation
7await createContract(data1, uuidv4());
8await createContract(data2, uuidv4());

Cached responses last for 24 hours. Plan your retry logic accordingly:

  • Immediate retries: Safe (returns cached response)
  • Retries within 24 hours: Returns cached response
  • After 24 hours: Key can be reused (cache expired)

For intentional operation retries (not network failures), generate a new key.

Only successful responses (2xx) are cached. Failed requests should be retried:

1async function safeCreateContract(data) {
2 const idempotencyKey = uuidv4();
3 let attempts = 0;
4 const maxAttempts = 3;
5
6 while (attempts < maxAttempts) {
7 try {
8 return await createContract(data, idempotencyKey);
9 } catch (error) {
10 attempts++;
11
12 // Retry on network errors or 5xx
13 if (error.code === 'ECONNRESET' ||
14 error.response?.status >= 500) {
15 if (attempts < maxAttempts) {
16 await sleep(Math.pow(2, attempts) * 1000);
17 continue; // Retry with same key
18 }
19 }
20
21 // Don't retry on client errors (4xx)
22 throw error;
23 }
24 }
25}

Common Scenarios

Scenario 1: Network Timeout

1const idempotencyKey = uuidv4();
2
3try {
4 // First attempt times out
5 await createContract(data, idempotencyKey);
6} catch (error) {
7 if (error.code === 'ETIMEDOUT') {
8 // Safe to retry with same key
9 // If first request succeeded, you'll get the cached response
10 const result = await createContract(data, idempotencyKey);
11 console.log('Retry successful');
12 }
13}

Scenario 2: Uncertain Request Status

1const idempotencyKey = uuidv4();
2
3try {
4 await createContract(data, idempotencyKey);
5} catch (error) {
6 // Uncertain if request succeeded
7 console.log('Not sure if contract was created');
8}
9
10// Check if contract was created by retrying with same key
11try {
12 const result = await createContract(data, idempotencyKey);
13
14 // Check for cached response header
15 if (result.headers['x-original-request-id']) {
16 console.log('Contract was created in first attempt');
17 } else {
18 console.log('Contract just created now');
19 }
20} catch (error) {
21 console.log('Contract definitely not created');
22}

Scenario 3: Batch Operations

1async function createMultipleContracts(contractsData) {
2 const results = await Promise.allSettled(
3 contractsData.map(data => {
4 // Each contract gets unique idempotency key
5 const idempotencyKey = uuidv4();
6
7 return createContract(data, idempotencyKey);
8 })
9 );
10
11 // Process results
12 const succeeded = results.filter(r => r.status === 'fulfilled');
13 const failed = results.filter(r => r.status === 'rejected');
14
15 console.log(`Created: ${succeeded.length}, Failed: ${failed.length}`);
16
17 // Retry failed ones with same keys (if stored)
18 return { succeeded, failed };
19}

Troubleshooting

Possible causes:

  • Generating new keys for each retry (should reuse key)
  • Not including Idempotency-Key header
  • Using different keys for same operation

Solution:

1// Store key before first attempt
2const key = uuidv4();
3
4// Use same key for all retries
5await retryWithKey(operation, key);

Cause: Concurrent requests with same idempotency key

Solution: Wait for Retry-After duration before retrying:

1if (error.response?.status === 429) {
2 const retryAfter = error.response.headers['retry-after'];
3 await sleep(retryAfter * 1000);
4 // Retry...
5}

Scenario: First request created wrong resource, want to create a new one

Solution: Generate a new idempotency key:

1// First attempt (wrong data)
2await createContract(wrongData, uuidv4());
3
4// New attempt (correct data, new key)
5await createContract(correctData, uuidv4()); // New key

Testing Idempotency

Test that your integration handles idempotency correctly:

1describe('Idempotency', () => {
2 it('should not create duplicates on retry', async () => {
3 const data = { /* contract data */ };
4 const key = uuidv4();
5
6 // First request
7 const result1 = await createContract(data, key);
8
9 // Retry with same key
10 const result2 = await createContract(data, key);
11
12 // Should return same contract
13 expect(result1.id).toBe(result2.id);
14 expect(result2.headers['x-original-request-id']).toBeDefined();
15 });
16
17 it('should create different resources with different keys', async () => {
18 const data = { /* contract data */ };
19
20 const result1 = await createContract(data, uuidv4());
21 const result2 = await createContract(data, uuidv4());
22
23 // Should be different contracts
24 expect(result1.id).not.toBe(result2.id);
25 });
26});

Next Steps