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)
        raise
package 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

SymptomLikely causeFix
400 with VALIDATION_007Key contains characters outside A-Z a-z 0-9 - _ or exceeds 255 charsSanitize the identifiers you build keys from
409 with CONFLICT_001Duplicate sent while the original is still executingWait briefly, retry with the same key
Second request returns stale data for a different payloadKey reused for a different operation; the API replays the cached response without comparing bodiesDerive keys so distinct operations never share one
Retry executed as a new operationMore than 24 hours passed and the key expired, or the key changed between attemptsRetry within 24 hours; derive keys from stable identifiers

On this page