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
- Generate a unique key for each distinct operation
- Include it in the
Idempotency-Keyheader - The API caches the response for 24 hours
- 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:
| Endpoint | Method |
|---|---|
/v3/messages | POST |
/v3/contacts | POST, PATCH |
/v3/contacts/{id} | PATCH, DELETE |
/v3/templates | POST |
/v3/templates/{id} | PUT, DELETE |
/v3/profiles | POST |
/v3/profiles/{id} | PATCH, DELETE |
/v3/profiles/{id}/complete | POST |
/v3/brands | POST |
/v3/brands/{id} | PUT, DELETE |
/v3/brands/{id}/campaigns | POST |
/v3/brands/{id}/campaigns/{id} | PUT, DELETE |
/v3/webhooks | POST |
/v3/webhooks/{id} | PUT, DELETE, PATCH |
/v3/webhooks/{id}/rotate-secret | POST |
/v3/users | POST |
/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-doeInvalid Examples
req abc 123 # Contains spaces
create@contact # Contains special character @
send.msg.001 # Contains periodsIdempotency 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)
raisepackage 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
| Header | Description |
|---|---|
Idempotent-Replayed | true if this is a cached response |
X-Original-Request-Id | Request ID of the original request |
X-Request-Id | Request 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:
| Feature | Purpose | Side Effects | Response |
|---|---|---|---|
| Test Mode | Validate requests | None (validation only) | Fake/sample data |
| Idempotency | Prevent duplicates | Only on first request | Real/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.