Idempotency

The Sent API v3 supports idempotency for safely retrying requests without accidentally performing the same operation twice. This is essential for preventing duplicate charges, messages, or resource creation when network errors or timeouts occur.


What is Idempotency?

An operation is idempotent if performing it multiple times has the same effect as performing it once. With idempotency keys, the API guarantees at-most-once execution for mutation operations.

Common Use Cases

  • Network timeouts: Retry a request when the connection drops
  • 5xx errors: Retry after server errors without side effects
  • User retries: Prevent duplicate form submissions
  • Webhook processing: Handle duplicate webhook deliveries safely

How It Works

  1. Generate a unique key for each distinct operation
  2. Include it in the Idempotency-Key header
  3. The API caches the response for 24 hours
  4. Duplicate requests with the same key return the cached response
POST /v3/messages
Idempotency-Key: msg_send_abc123
Content-Type: application/json

{
  "phone_number": "+1234567890",
  "template_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  "variables": {
    "customer_name": "John"
  }
}

Supported Endpoints

Idempotency is supported for all mutation endpoints:

EndpointMethod
/v3/messagesPOST
/v3/contactsPOST, PATCH
/v3/contacts/{id}PATCH, DELETE
/v3/templatesPOST
/v3/templates/{id}PUT, DELETE
/v3/profilesPOST
/v3/profiles/{id}PATCH, DELETE
/v3/profiles/{id}/completePOST
/v3/brandsPOST
/v3/brands/{id}PUT, DELETE
/v3/brands/{id}/campaignsPOST
/v3/brands/{id}/campaigns/{id}PUT, DELETE
/v3/webhooksPOST
/v3/webhooks/{id}PUT, DELETE, PATCH
/v3/webhooks/{id}/rotate-secretPOST
/v3/usersPOST
/v3/users/{id}PATCH, DELETE

Idempotency Key Format

Requirements

  • Length: 1-255 characters
  • Characters: Alphanumeric, hyphens (-), and underscores (_)
  • Pattern: ^[a-zA-Z0-9_-]+$
  • Scope: Per customer account
  • Expiration: 24 hours

Valid Examples

req-abc123
send_msg_001
webhook-retry-1
invoice-payment-2024-001
create-contact-john-doe

Invalid Examples

req abc 123       # Contains spaces
create@contact    # Contains special character @
send.msg.001      # Contains periods

Idempotency Key Best Practices

1. Generate Unique Keys

Include information that makes the key unique to the operation:

// Good: Includes user action + resource + timestamp
const key = `payment-${userId}-${invoiceId}-${Date.now()}`;

// Good: Includes resource type and client-generated ID
const key = `contact-create-${clientRequestId}`;

// Bad: Static key (would block legitimate retries)
const key = 'create-message';

// Bad: Random without context (hard to debug)
const key = crypto.randomUUID();
import time
import uuid

# Good: Includes user action + resource + timestamp
key = f"payment-{user_id}-{invoice_id}-{int(time.time() * 1000)}"

# Good: Includes resource type and client-generated ID
key = f"contact-create-{client_request_id}"

# Bad: Static key (would block legitimate retries)
key = "create-message"

# Bad: Random without context (hard to debug)
key = str(uuid.uuid4())
package main

import (
    "fmt"
    "time"
    "github.com/google/uuid"
)

// Good: Includes user action + resource + timestamp
key := fmt.Sprintf("payment-%s-%s-%d", userID, invoiceID, time.Now().UnixMilli())

// Good: Includes resource type and client-generated ID
key := fmt.Sprintf("contact-create-%s", clientRequestID)

// Bad: Static key (would block legitimate retries)
key := "create-message"

// Bad: Random without context (hard to debug)
key := uuid.New().String()

2. Use Consistent Keys for Retries

Keep the same key when retrying the same operation:

async function sendMessageWithRetry(
  payload: MessagePayload,
  maxRetries = 3
): Promise<APIResponse> {
  // Generate key once for the operation
  const idempotencyKey = `msg-${payload.contactId}-${Date.now()}`;

  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      const response = await fetch('/v3/messages', {
        method: 'POST',
        headers: {
          'x-api-key': API_KEY,
          'Idempotency-Key': idempotencyKey,  // Same key on retry
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(payload)
      });

      if (response.ok) {
        return await response.json();
      }

      // Don't retry on 4xx errors (client errors)
      if (response.status >= 400 && response.status < 500) {
        throw new Error(`Client error: ${response.status}`);
      }

      // Retry on 5xx or network errors
      if (attempt < maxRetries) {
        await sleep(Math.pow(2, attempt) * 1000);
      }
    } catch (error) {
      if (attempt === maxRetries) throw error;
    }
  }

  throw new Error('Max retries exceeded');
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}
import time
import requests

def send_message_with_retry(payload: dict, max_retries: int = 3) -> dict:
    """Send message with idempotency and retry logic."""
    # Generate key once for the operation
    idempotency_key = f"msg-{payload['contact_id']}-{int(time.time() * 1000)}"

    for attempt in range(1, max_retries + 1):
        try:
            response = requests.post(
                'https://api.sent.dm/v3/messages',
                headers={
                    'x-api-key': API_KEY,
                    'Idempotency-Key': idempotency_key,  # Same key on retry
                    'Content-Type': 'application/json'
                },
                json=payload
            )

            if response.ok:
                return response.json()

            # Don't retry on 4xx errors (client errors)
            if 400 <= response.status_code < 500:
                raise Exception(f"Client error: {response.status_code}")

            # Retry on 5xx or network errors
            if attempt < max_retries:
                time.sleep(2 ** attempt)

        except Exception as e:
            if attempt == max_retries:
                raise e

    raise Exception('Max retries exceeded')
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "time"
)

func sendMessageWithRetry(payload map[string]interface{}, maxRetries int) (map[string]interface{}, error) {
    // Generate key once for the operation
    contactID := payload["contact_id"].(string)
    idempotencyKey := fmt.Sprintf("msg-%s-%d", contactID, time.Now().UnixMilli())

    for attempt := 1; attempt <= maxRetries; attempt++ {
        body, _ := json.Marshal(payload)
        req, _ := http.NewRequest(
            "POST",
            "https://api.sent.dm/v3/messages",
            bytes.NewBuffer(body),
        )

        req.Header.Set("x-api-key", API_KEY)
        req.Header.Set("Idempotency-Key", idempotencyKey)  // Same key on retry
        req.Header.Set("Content-Type", "application/json")

        client := &http.Client{}
        resp, err := client.Do(req)
        if err != nil {
            if attempt == maxRetries {
                return nil, err
            }
            time.Sleep(time.Duration(1<<attempt) * time.Second)
            continue
        }
        defer resp.Body.Close()

        if resp.StatusCode >= 200 && resp.StatusCode < 300 {
            var result map[string]interface{}
            json.NewDecoder(resp.Body).Decode(&result)
            return result, nil
        }

        // Don't retry on 4xx errors (client errors)
        if resp.StatusCode >= 400 && resp.StatusCode < 500 {
            return nil, fmt.Errorf("client error: %d", resp.StatusCode)
        }

        // Retry on 5xx or network errors
        if attempt < maxRetries {
            time.Sleep(time.Duration(1<<attempt) * time.Second)
        }
    }

    return nil, fmt.Errorf("max retries exceeded")
}

3. Handle Concurrent Requests

If two requests with the same key are sent simultaneously, the API returns a 409 Conflict error for the second request:

{
  "success": false,
  "error": {
    "code": "CONFLICT_001",
    "message": "Concurrent idempotent request in progress",
    "doc_url": "https://docs.sent.dm/errors/CONFLICT_001"
  },
  "meta": { ... }
}

Strategy: Wait briefly and retry to get the cached response:

async function sendWithIdempotency(
  url: string,
  payload: object,
  key: string
): Promise<APIResponse> {
  try {
    return await apiRequest(url, payload, key);
  } catch (error: any) {
    if (error.code === 'CONFLICT_001') {
      // Wait for the original request to complete
      await sleep(500);
      return await apiRequest(url, payload, key);
    }
    throw error;
  }
}

function sleep(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}
import time

def send_with_idempotency(url: str, payload: dict, key: str) -> dict:
    """Send request with handling for concurrent idempotent requests."""
    try:
        return api_request(url, payload, key)
    except Exception as e:
        error_code = getattr(e, 'code', None)
        if error_code == 'CONFLICT_001':
            # Wait for the original request to complete
            time.sleep(0.5)
            return api_request(url, payload, key)
        raise
package main

import (
    "fmt"
    "time"
)

func sendWithIdempotency(url string, payload map[string]interface{}, key string) (map[string]interface{}, error) {
    result, err := apiRequest(url, payload, key)
    if err != nil {
        if err.Error() == "CONFLICT_001" {
            // Wait for the original request to complete
            time.Sleep(500 * time.Millisecond)
            return apiRequest(url, payload, key)
        }
        return nil, err
    }
    return result, nil
}

Response Headers

Idempotent-Replayed

When a cached response is returned, the Idempotent-Replayed: true header is included:

HTTP/1.1 201 Created
Idempotent-Replayed: true
X-Original-Request-Id: req_original_abc123
X-Request-Id: req_replay_def456
Content-Type: application/json

{
  "success": true,
  "data": { ... },
  "error": null,
  "meta": { ... }
}

Header Reference

HeaderDescription
Idempotent-Replayedtrue if this is a cached response
X-Original-Request-IdRequest ID of the original request
X-Request-IdRequest ID of the current request

Code Examples

Production-ready implementations with automatic key generation and replay detection.

class IdempotentClient {
  constructor(apiKey: string, baseUrl = 'https://api.sent.dm') {
    this.apiKey = apiKey;
    this.baseUrl = baseUrl;
  }

  async request(
    method: string,
    endpoint: string,
    payload?: object,
    options: { idempotencyKey?: string } = {}
  ) {
    const headers: Record<string, string> = {
      'x-api-key': this.apiKey,
      'Content-Type': 'application/json'
    };

    // Add idempotency key if provided
    if (options.idempotencyKey) {
      headers['Idempotency-Key'] = options.idempotencyKey;
    }

    const response = await fetch(`${this.baseUrl}${endpoint}`, {
      method,
      headers,
      body: payload ? JSON.stringify(payload) : undefined
    });

    const data = await response.json();

    // Check if this was a replay
    if (response.headers.get('Idempotent-Replayed')) {
      console.log('Idempotent replay detected');
      console.log('Original request ID:', response.headers.get('X-Original-Request-Id'));
    }

    if (!data.success) {
      throw new Error(`${data.error.code}: ${data.error.message}`);
    }

    return data.data;
  }

  // Send message with automatic idempotency key
  async sendMessage(
    phoneNumber: string,
    templateId: string,
    variables?: Record<string, string>
  ) {
    const key = `msg-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;

    return this.request('POST', '/v3/messages', {
      phone_number: phoneNumber,
      template_id: templateId,
      variables
    }, { idempotencyKey: key });
  }
}

// Usage
const client = new IdempotentClient(process.env.SENT_API_KEY!);

// Send with automatic key generation
await client.sendMessage('+1234567890', 'template-456', { name: 'John' });

// Or provide your own key for specific operations
await client.request('POST', '/v3/contacts', {
  phone_number: '+1234567890'
}, { idempotencyKey: 'import-user-12345' });
import time
import random
import requests
from typing import Optional, Dict, Any

class IdempotentClient:
    def __init__(self, api_key: str, base_url: str = 'https://api.sent.dm'):
        self.api_key = api_key
        self.base_url = base_url

    def _generate_key(self, prefix: str) -> str:
        """Generate a unique idempotency key."""
        timestamp = int(time.time() * 1000)
        random_str = ''.join(random.choices('abcdefghijklmnopqrstuvwxyz0123456789', k=8))
        return f"{prefix}-{timestamp}-{random_str}"

    def request(
        self,
        method: str,
        endpoint: str,
        payload: Optional[Dict] = None,
        idempotency_key: Optional[str] = None
    ) -> Dict[str, Any]:
        headers = {
            'x-api-key': self.api_key,
            'Content-Type': 'application/json'
        }

        if idempotency_key:
            headers['Idempotency-Key'] = idempotency_key

        response = requests.request(
            method,
            f'{self.base_url}{endpoint}',
            headers=headers,
            json=payload
        )

        # Check for replay
        if response.headers.get('Idempotent-Replayed'):
            print('Idempotent replay detected')
            print(f'Original request ID: {response.headers.get("X-Original-Request-Id")}')

        data = response.json()

        if not data.get('success'):
            raise Exception(f"{data['error']['code']}: {data['error']['message']}")

        return data['data']

    def send_message(
        self,
        phone_number: str,
        template_id: str,
        variables: Optional[Dict[str, Any]] = None
    ) -> Dict[str, Any]:
        """Send a message with automatic idempotency key."""
        key = self._generate_key(f"msg-{phone_number.replace('+', '')}")

        return self.request('POST', '/v3/messages', {
            'phone_number': phone_number,
            'template_id': template_id,
            'variables': variables or {}
        }, idempotency_key=key)

    def create_contact(self, phone_number: str, key_prefix: str = None) -> Dict[str, Any]:
        """Create a contact with idempotency key."""
        key = key_prefix or self._generate_key('contact')

        return self.request('POST', '/v3/contacts', {
            'phone_number': phone_number
        }, idempotency_key=key)


# Usage
client = IdempotentClient(api_key='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')

# Send message with automatic key
message = client.send_message(
    '+1234567890',
    'template-id',
    {'customer_name': 'John'}
)

# Create contact with custom key prefix
contact = client.create_contact('+1234567890', key_prefix='import-csv-row-42')
package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "os"
    "time"
    "math/rand"
)

type IdempotentClient struct {
    APIKey  string
    BaseURL string
}

func NewIdempotentClient(apiKey string) *IdempotentClient {
    return &IdempotentClient{
        APIKey:  apiKey,
        BaseURL: "https://api.sent.dm",
    }
}

func (c *IdempotentClient) generateKey(prefix string) string {
    timestamp := time.Now().UnixMilli()
    randomStr := fmt.Sprintf("%08d", rand.Intn(100000000))
    return fmt.Sprintf("%s-%d-%s", prefix, timestamp, randomStr)
}

func (c *IdempotentClient) Request(
    method string,
    endpoint string,
    payload interface{},
    idempotencyKey string,
) (map[string]interface{}, error) {
    var body []byte
    if payload != nil {
        body, _ = json.Marshal(payload)
    }

    req, err := http.NewRequest(
        method,
        c.BaseURL+endpoint,
        bytes.NewBuffer(body),
    )
    if err != nil {
        return nil, err
    }

    req.Header.Set("x-api-key", c.APIKey)
    req.Header.Set("Content-Type", "application/json")
    if idempotencyKey != "" {
        req.Header.Set("Idempotency-Key", idempotencyKey)
    }

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    // Check for replay
    if resp.Header.Get("Idempotent-Replayed") == "true" {
        fmt.Println("Idempotent replay detected")
        fmt.Printf("Original request ID: %s\n", resp.Header.Get("X-Original-Request-Id"))
    }

    var result map[string]interface{}
    if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
        return nil, err
    }

    if success, ok := result["success"].(bool); !ok || !success {
        errorData := result["error"].(map[string]interface{})
        return nil, fmt.Errorf("%s: %s", errorData["code"], errorData["message"])
    }

    return result["data"].(map[string]interface{}), nil
}

func (c *IdempotentClient) SendMessage(
    phoneNumber string,
    templateID string,
    variables map[string]string,
) (map[string]interface{}, error) {
    key := c.generateKey(fmt.Sprintf("msg-%s", phoneNumber))

    payload := map[string]interface{}{
        "phone_number": phoneNumber,
        "template_id":  templateID,
        "variables":    variables,
    }

    return c.Request("POST", "/v3/messages", payload, key)
}

// Usage
func main() {
    client := NewIdempotentClient(os.Getenv("SENT_API_KEY"))

    // Send with automatic key
    message, err := client.SendMessage(
        "+1234567890",
        "template-id",
        map[string]string{"customer_name": "John"},
    )
    if err != nil {
        panic(err)
    }
    fmt.Printf("Message sent: %v\n", message["id"])

    // Or provide custom key
    contact, err := client.Request(
        "POST",
        "/v3/contacts",
        map[string]string{"phone_number": "+1234567890"},
        "import-user-12345",
    )
    if err != nil {
        panic(err)
    }
    fmt.Printf("Contact created: %v\n", contact["id"])
}

Idempotency vs Test Mode

Both features help with safe API usage, but serve different purposes:

FeaturePurposeSide EffectsResponse
Test ModeValidate requestsNone (validation only)Fake/sample data
IdempotencyPrevent duplicatesOnly on first requestReal/cached data

Using Together

You can use both features together:

// Test mode without idempotency - for initial validation
await client.request('POST', '/v3/messages', {
  test_mode: true,
  phone_number: '+1234567890',
  template_id: 'template-id'
});

// Production request with idempotency - safe to retry
await client.request('POST', '/v3/messages', {
  phone_number: '+1234567890',
  template_id: 'template-id'
}, { idempotencyKey: 'msg-123' });
# Test mode without idempotency - for initial validation
client.request('POST', '/v3/messages', {
    'test_mode': True,
    'phone_number': '+1234567890',
    'template_id': 'template-id'
})

# Production request with idempotency - safe to retry
client.request(
    'POST',
    '/v3/messages',
    {
        'phone_number': '+1234567890',
        'template_id': 'template-id'
    },
    idempotency_key='msg-123'
)
// Test mode without idempotency - for initial validation
client.Request("POST", "/v3/messages", map[string]interface{}{
    "test_mode":    true,
    "phone_number": "+1234567890",
    "template_id":  "template-id",
}, "")

// Production request with idempotency - safe to retry
client.Request("POST", "/v3/messages", map[string]interface{}{
    "phone_number": "+1234567890",
    "template_id":  "template-id",
}, "msg-123")

Troubleshooting

Key Already Used for Different Request

If you reuse a key for a different request payload, the API returns the cached response from the original request:

// First request - creates contact A
await client.request('POST', '/v3/contacts', {
  phone_number: '+1111111111'
}, { idempotencyKey: 'create-contact' });

// Second request - different payload, same key
// Returns cached response for contact A, not new contact!
await client.request('POST', '/v3/contacts', {
  phone_number: '+2222222222'  // Different number!
}, { idempotencyKey: 'create-contact' });  // Same key!
# First request - creates contact A
client.request(
    'POST',
    '/v3/contacts',
    {'phone_number': '+1111111111'},
    idempotency_key='create-contact'
)

# Second request - different payload, same key
# Returns cached response for contact A, not new contact!
client.request(
    'POST',
    '/v3/contacts',
    {'phone_number': '+2222222222'},  # Different number!
    idempotency_key='create-contact'   # Same key!
)
// First request - creates contact A
client.Request("POST", "/v3/contacts", map[string]interface{}{
    "phone_number": "+1111111111",
}, "create-contact")

// Second request - different payload, same key
// Returns cached response for contact A, not new contact!
client.Request("POST", "/v3/contacts", map[string]interface{}{
    "phone_number": "+2222222222",  // Different number!
}, "create-contact")  // Same key!

Solution: Always use unique keys for distinct operations.

Key Expired

After 24 hours, idempotency keys expire. A retry with an expired key will execute as a new request.

Solution: Handle retries within 24 hours or implement application-level deduplication.


On this page