How to retry Sent API requests safely
This guide shows you how to retry failed Sent API v3 mutations (after timeouts, network drops, or 5xx errors) without sending the same message or creating the same resource twice. It assumes you know the idempotency contract: key format, 24-hour expiry, and replay semantics.
The pattern has three parts: derive a stable key for the operation, reuse that exact key on every retry attempt, and handle the conflict case when two attempts overlap.
Derive the key from a business identifier
Generate the key once per logical operation, from identifiers your system already tracks. A retry must present the same key, so the key cannot depend on when or how the attempt happens:
// Good: derived from the business operation — any retry reproduces it
const key = `payment-${userId}-${invoiceId}`;
// Good: derived from a client-generated request ID stored with the operation
const key = `contact-create-${clientRequestId}`;
// Bad: static key (blocks every later operation of this type for 24 hours)
const key = 'create-message';
// Bad: fresh random value per attempt (each retry becomes a new operation)
const key = crypto.randomUUID();# Good: derived from the business operation — any retry reproduces it
key = f"payment-{user_id}-{invoice_id}"
# Good: derived from a client-generated request ID stored with the operation
key = f"contact-create-{client_request_id}"
# Bad: static key (blocks every later operation of this type for 24 hours)
key = "create-message"
# Bad: fresh random value per attempt (each retry becomes a new operation)
key = str(uuid.uuid4())// Good: derived from the business operation — any retry reproduces it
key := fmt.Sprintf("payment-%s-%s", userID, invoiceID)
// Good: derived from a client-generated request ID stored with the operation
key := fmt.Sprintf("contact-create-%s", clientRequestID)
// Bad: static key (blocks every later operation of this type for 24 hours)
key := "create-message"
// Bad: fresh random value per attempt (each retry becomes a new operation)
key := uuid.New().String()If an operation can legitimately repeat (the same invoice reminder sent weekly), include the occurrence in the identifier: for example invoice-1234-reminder-2026-07, not a timestamp captured at send time.
A random UUID is only safe if you generate it when the operation is created and persist it alongside the operation. A UUID generated inside the send path produces a different key on each retry and defeats idempotency.
Reuse the same key across retry attempts
Send the key on the first attempt and on every retry. Retry 5xx and network errors with exponential backoff. Don't retry 4xx errors; they fail the same way every time until you fix the request:
async function sendMessageWithRetry(
payload: MessagePayload,
operationId: string, // stable, caller-supplied (e.g. an order ID)
maxRetries = 3
): Promise<APIResponse> {
const idempotencyKey = `msg-${operationId}`;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const response = await fetch('https://api.sent.dm/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,
operation_id: str, # stable, caller-supplied (e.g. an order ID)
max_retries: int = 3,
) -> dict:
"""Send message with idempotency and retry logic."""
idempotency_key = f"msg-{operation_id}"
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"
)
// operationID is stable and caller-supplied (e.g. an order ID)
func sendMessageWithRetry(payload map[string]interface{}, operationID string, maxRetries int) (map[string]interface{}, error) {
idempotencyKey := fmt.Sprintf("msg-%s", operationID)
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")
}The API caches only 2xx responses for replay, so a retry after a 5xx error executes the request again. With the same key, at most one attempt ever takes effect.
Handle concurrent duplicates
If a duplicate request arrives while the original is still executing and the original does not finish within about 5 seconds, the API returns 409 Conflict with code CONFLICT_001. Wait briefly and retry with the same key to receive 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 (
"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
}Wrap the pattern in a reusable client
For codebases with many call sites, centralize the header handling and replay detection in one client class. The caller supplies the operation identifier, so keys stay stable across retries and are easy to trace in logs:
class IdempotentClient {
constructor(private apiKey: string, private baseUrl = 'https://api.sent.dm') {}
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 a message; the caller supplies a stable operation ID
async sendMessage(
to: string[],
templateId: string,
operationId: string,
parameters?: Record<string, string>
) {
return this.request('POST', '/v3/messages', {
to,
template: { id: templateId, parameters }
}, { idempotencyKey: `msg-${operationId}` });
}
}
// Usage
const client = new IdempotentClient(process.env.SENT_API_KEY!);
// The order ID makes the send idempotent per order
await client.sendMessage(
['+1234567890'],
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'order-confirmation-8412',
{ name: 'John' }
);
// Or provide your own key for other operations
await client.request('POST', '/v3/contacts', {
phone_number: '+1234567890'
}, { idempotencyKey: 'import-user-12345' });import requests
from typing import Any, Dict, List, Optional
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 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,
to: List[str],
template_id: str,
operation_id: str,
parameters: Optional[Dict[str, str]] = None
) -> Dict[str, Any]:
"""Send a message; the caller supplies a stable operation ID."""
return self.request('POST', '/v3/messages', {
'to': to,
'template': {'id': template_id, 'parameters': parameters or {}}
}, idempotency_key=f"msg-{operation_id}")
def create_contact(self, phone_number: str, operation_id: str) -> Dict[str, Any]:
"""Create a contact; the caller supplies a stable operation ID."""
return self.request('POST', '/v3/contacts', {
'phone_number': phone_number
}, idempotency_key=f"contact-{operation_id}")
# Usage
client = IdempotentClient(api_key='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx')
# The order ID makes the send idempotent per order
message = client.send_message(
['+1234567890'],
'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
'order-confirmation-8412',
{'customer_name': 'John'}
)
# The CSV row makes each import idempotent per row
contact = client.create_contact('+1234567890', operation_id='import-csv-row-42')package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
type IdempotentClient struct {
APIKey string
BaseURL string
}
func NewIdempotentClient(apiKey string) *IdempotentClient {
return &IdempotentClient{
APIKey: apiKey,
BaseURL: "https://api.sent.dm",
}
}
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
}
// SendMessage sends a message; the caller supplies a stable operation ID
func (c *IdempotentClient) SendMessage(
to []string,
templateID string,
operationID string,
parameters map[string]string,
) (map[string]interface{}, error) {
payload := map[string]interface{}{
"to": to,
"template": map[string]interface{}{
"id": templateID,
"parameters": parameters,
},
}
return c.Request("POST", "/v3/messages", payload, fmt.Sprintf("msg-%s", operationID))
}
// Usage
func main() {
client := NewIdempotentClient(os.Getenv("SENT_API_KEY"))
// The order ID makes the send idempotent per order
message, err := client.SendMessage(
[]string{"+1234567890"},
"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"order-confirmation-8412",
map[string]string{"customer_name": "John"},
)
if err != nil {
panic(err)
}
fmt.Printf("Messages queued: %v\n", message["recipients"])
// Or provide your own key for other operations
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"])
}Verify the behavior
Confirm your retries are safe by forcing a replay: send the same request twice with the same key. The second response returns the original status and body with the Idempotent-Replayed: true header, and X-Original-Request-Id names the first request. If the second call creates a second resource instead, the key changed between attempts. Check that it derives only from stable identifiers.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
400 with VALIDATION_007 | Key contains characters outside A-Z a-z 0-9 - _ or exceeds 255 chars | Sanitize the identifiers you build keys from |
409 with CONFLICT_001 | Duplicate sent while the original is still executing | Wait briefly, retry with the same key |
| Second request returns stale data for a different payload | Key reused for a different operation; the API replays the cached response without comparing bodies | Derive keys so distinct operations never share one |
| Retry executed as a new operation | More than 24 hours passed and the key expired, or the key changed between attempts | Retry within 24 hours; derive keys from stable identifiers |
Related
- Refer to the Idempotency reference for the full contract: key format, caching rules, and response headers.
- How to handle Sent API errors
- How to handle Sent API rate limits
How to handle Sent API errors
Handle Sent API v3 error responses in client code: check the success flag, branch on error codes, capture request IDs, and test failures with sandbox mode.
How to handle Sent API rate limits
Keep your integration under Sent API v3 rate limits: back off on 429 responses, honor Retry-After, throttle requests client-side, and pace batch workloads.