How to handle Sent API rate limits

This guide shows you how to keep an integration running smoothly under the Sent API v3 rate limits: recover from 429 responses, watch for limit pressure, and shape your own traffic so batch jobs and bursts stay under the account limit. It assumes you can already send authenticated requests; for the limit values and window semantics, see the Rate Limits reference.

Rate limits apply per customer account (200 requests per minute for standard endpoints), so every strategy below operates at the account level, not per API key.

Back off and retry on 429

When a request returns 429 Too Many Requests, wait the number of seconds in the Retry-After header before retrying. Fall back to exponential backoff if the header is absent:

async function makeRequestWithRetry(
  url: string,
  options: RequestInit,
  maxRetries = 3
): Promise<Response> {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch(url, options);

    if (response.status !== 429) {
      return response;
    }

    if (attempt === maxRetries) {
      throw new Error('Max retries exceeded');
    }

    // Get retry delay from header or use exponential backoff
    const retryAfter = response.headers.get('Retry-After');
    const delay = retryAfter
      ? parseInt(retryAfter) * 1000
      : Math.pow(2, attempt) * 1000;

    console.log(`Rate limited. Retrying after ${delay}ms...`);
    await sleep(delay);
  }

  throw new Error('Unreachable');
}

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

def make_request_with_retry(
    method: str,
    url: str,
    headers: dict,
    json_data: Optional[dict] = None,
    max_retries: int = 3
) -> requests.Response:
    """Make request with exponential backoff on 429."""

    for attempt in range(max_retries + 1):
        response = requests.request(
            method, url, headers=headers, json=json_data
        )

        if response.status_code != 429:
            return response

        if attempt == max_retries:
            raise Exception('Max retries exceeded')

        # Get retry delay from header or use exponential backoff
        retry_after = response.headers.get('Retry-After')
        delay = int(retry_after) if retry_after else (2 ** attempt)

        print(f'Rate limited. Retrying after {delay}s...')
        time.sleep(delay)

    raise Exception('Unreachable')
package main

import (
    "fmt"
    "math"
    "net/http"
    "strconv"
    "time"
)

func makeRequestWithRetry(
    req *http.Request,
    maxRetries int,
) (*http.Response, error) {
    client := &http.Client{}

    for attempt := 0; attempt <= maxRetries; attempt++ {
        resp, err := client.Do(req)
        if err != nil {
            return nil, err
        }

        if resp.StatusCode != 429 {
            return resp, nil
        }

        if attempt == maxRetries {
            return nil, fmt.Errorf("max retries exceeded")
        }

        // Get retry delay from header or use exponential backoff
        retryAfter := resp.Header.Get("Retry-After")
        delay := math.Pow(2, float64(attempt))

        if retryAfter != "" {
            if seconds, err := strconv.Atoi(retryAfter); err == nil {
                delay = float64(seconds)
            }
        }

        fmt.Printf("Rate limited. Retrying after %.0fs...\n", delay)
        time.Sleep(time.Duration(delay) * time.Second)
    }

    return nil, fmt.Errorf("unreachable")
}

If the retried request is a mutation (POST, PUT, PATCH), send an Idempotency-Key so the retry can't duplicate the operation. See How to retry Sent API requests safely.

Monitor 429 responses as a pressure signal

The API sends the X-RateLimit-* headers only on 429 responses, so there is no per-request quota readout to poll. Treat each 429 as the signal instead: count them in your metrics and alert when they occur, using the headers on the rejection to log when capacity returns:

async function makeRequest(url: string, options: RequestInit): Promise<Response> {
  const response = await fetch(url, options);

  if (response.status === 429) {
    const limit = response.headers.get('X-RateLimit-Limit');
    const reset = response.headers.get('X-RateLimit-Reset');

    console.warn(
      `Rate limited: limit ${limit}/min, window resets at ${new Date(parseInt(reset!) * 1000)}`
    );

    // Send to your monitoring system
    // metrics.increment('api.rate_limited', { limit });
  }

  return response;
}
import time
import requests

def make_request(url: str, headers: dict) -> requests.Response:
    """Make request and track 429 responses for monitoring."""
    response = requests.get(url, headers=headers)

    if response.status_code == 429:
        limit = response.headers.get('X-RateLimit-Limit')
        reset = response.headers.get('X-RateLimit-Reset')
        reset_time = time.strftime('%Y-%m-%d %H:%M:%S',
                                   time.localtime(int(reset)))

        print(f'WARNING: rate limited: limit {limit}/min, window resets at {reset_time}')

        # Send to your monitoring system (Datadog, Prometheus, etc)
        # statsd.increment('api.rate_limited')

    return response
package main

import (
    "fmt"
    "net/http"
    "strconv"
    "time"
)

func makeRequest(req *http.Request) (*http.Response, error) {
    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        return nil, err
    }

    if resp.StatusCode == 429 {
        limit := resp.Header.Get("X-RateLimit-Limit")
        resetUnix, _ := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64)
        resetTime := time.Unix(resetUnix, 0)

        fmt.Printf("WARNING: rate limited: limit %s/min, window resets at %s\n",
            limit, resetTime)

        // Send to your monitoring system
        // statsd.Increment("api.rate_limited")
    }

    return resp, nil
}

A sustained 429 rate means your steady-state traffic exceeds the account limit. Throttle client-side (next section) or contact support@sent.dm about a higher limit.

Throttle requests client-side

To avoid hitting the limit at all, space out requests at the source. Pacing at 3 requests per second keeps you at 180 requests per minute, safely under the 200/minute standard limit:

class RateLimiter {
  private minInterval: number;
  private lastRequestTime: number = 0;

  constructor(requestsPerSecond: number) {
    this.minInterval = 1000 / requestsPerSecond;
  }

  async throttle(): Promise<void> {
    const now = Date.now();
    const timeSinceLastRequest = now - this.lastRequestTime;

    if (timeSinceLastRequest < this.minInterval) {
      const delay = this.minInterval - timeSinceLastRequest;
      await sleep(delay);
    }

    this.lastRequestTime = Date.now();
  }
}

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

// Use: limit to 3 requests per second (180/min, well under the 200 limit)
const limiter = new RateLimiter(3);

async function makeThrottledRequest(url: string, options: RequestInit): Promise<Response> {
  await limiter.throttle();
  return fetch(url, options);
}
import time
import requests
from typing import Optional

class RateLimiter:
    """Simple rate limiter that enforces a minimum interval between requests."""

    def __init__(self, requests_per_second: float):
        self.min_interval = 1.0 / requests_per_second
        self.last_request_time: Optional[float] = None

    def throttle(self):
        """Wait if necessary to maintain rate limit."""
        if self.last_request_time is None:
            self.last_request_time = time.time()
            return

        now = time.time()
        time_since_last = now - self.last_request_time

        if time_since_last < self.min_interval:
            delay = self.min_interval - time_since_last
            time.sleep(delay)

        self.last_request_time = time.time()


# Use: limit to 3 requests per second (180/min, well under the 200 limit)
limiter = RateLimiter(3)

def make_throttled_request(url: str, headers: dict) -> requests.Response:
    limiter.throttle()
    return requests.get(url, headers=headers)
package main

import (
    "net/http"
    "sync"
    "time"
)

type RateLimiter struct {
    minInterval       time.Duration
    lastRequestTime   time.Time
    mu                sync.Mutex
}

func NewRateLimiter(requestsPerSecond float64) *RateLimiter {
    return &RateLimiter{
        minInterval: time.Duration(float64(time.Second) / requestsPerSecond),
    }
}

func (r *RateLimiter) Throttle() {
    r.mu.Lock()
    defer r.mu.Unlock()

    if r.lastRequestTime.IsZero() {
        r.lastRequestTime = time.Now()
        return
    }

    timeSinceLast := time.Since(r.lastRequestTime)
    if timeSinceLast < r.minInterval {
        time.Sleep(r.minInterval - timeSinceLast)
    }

    r.lastRequestTime = time.Now()
}

// Use: limit to 3 requests per second (180/min, well under the 200 limit)
var limiter = NewRateLimiter(3)

func makeThrottledRequest(req *http.Request) (*http.Response, error) {
    limiter.Throttle()

    client := &http.Client{}
    return client.Do(req)
}

If several workers share one account, budget the limit across them: for example, four workers at 40 requests per minute each. Every API key on the account draws from the same pool.

Cache responses and pace batch work

Two ways to reduce the requests you send in the first place: cache reads you would otherwise repeat, and process bulk workloads in paced batches instead of all at once.

// ❌ Inefficient: unpaced individual requests (100 API calls at once)
for (const contact of contacts) {
  await createContact(contact);
}

// ✅ Efficient: cache repeated reads, process writes in paced batches
class CachedContactClient {
  private cache = new Map<string, Contact>();

  async getContact(id: string): Promise<Contact> {
    // Return cached result if available
    if (this.cache.has(id)) {
      return this.cache.get(id)!;
    }

    const contact = await fetchContact(id);
    this.cache.set(id, contact);
    return contact;
  }

  async batchProcessContacts(
    contacts: Contact[],
    batchSize: number = 10
  ): Promise<void> {
    // Process in batches with rate limiting
    for (let i = 0; i < contacts.length; i += batchSize) {
      const batch = contacts.slice(i, i + batchSize);

      // Process batch concurrently
      await Promise.all(
        batch.map(contact => this.processContact(contact))
      );

      // Wait between batches
      if (i + batchSize < contacts.length) {
        await sleep(1000);
      }
    }
  }

  private async processContact(contact: Contact): Promise<void> {
    // Implementation
  }
}

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

# ❌ Inefficient: unpaced individual requests (100 API calls at once)
# for contact in contacts:
#     create_contact(contact)

# ✅ Efficient: cache repeated reads, process writes in paced batches
class CachedContactClient:
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.cache: Dict[str, dict] = {}

    def get_contact(self, contact_id: str) -> dict:
        """Get contact with caching."""
        # Return cached result if available
        if contact_id in self.cache:
            return self.cache[contact_id]

        response = requests.get(
            f'https://api.sent.dm/v3/contacts/{contact_id}',
            headers={'x-api-key': self.api_key}
        )
        contact = response.json()['data']
        self.cache[contact_id] = contact
        return contact

    def batch_process_contacts(
        self,
        contacts: List[dict],
        batch_size: int = 10
    ):
        """Process contacts in batches with rate limiting."""
        for i in range(0, len(contacts), batch_size):
            batch = contacts[i:i + batch_size]

            # Process batch
            for contact in batch:
                self.process_contact(contact)

            # Wait between batches (except after last batch)
            if i + batch_size < len(contacts):
                time.sleep(1)

    def process_contact(self, contact: dict):
        """Process a single contact."""
        # Implementation
        pass
package main

import (
    "sync"
    "time"
)

// ❌ Inefficient: unpaced individual requests (100 API calls at once)
// for _, contact := range contacts {
//     createContact(contact)
// }

// ✅ Efficient: cache repeated reads, process writes in paced batches
type CachedContactClient struct {
    apiKey string
    cache  map[string]Contact
    mu     sync.RWMutex
}

func NewCachedContactClient(apiKey string) *CachedContactClient {
    return &CachedContactClient{
        apiKey: apiKey,
        cache:  make(map[string]Contact),
    }
}

func (c *CachedContactClient) GetContact(id string) (Contact, error) {
    // Return cached result if available
    c.mu.RLock()
    if contact, ok := c.cache[id]; ok {
        c.mu.RUnlock()
        return contact, nil
    }
    c.mu.RUnlock()

    // Fetch and cache
    contact, err := fetchContact(id, c.apiKey)
    if err != nil {
        return Contact{}, err
    }

    c.mu.Lock()
    c.cache[id] = contact
    c.mu.Unlock()

    return contact, nil
}

func (c *CachedContactClient) BatchProcessContacts(
    contacts []Contact,
    batchSize int,
) error {
    if batchSize == 0 {
        batchSize = 10
    }

    for i := 0; i < len(contacts); i += batchSize {
        end := i + batchSize
        if end > len(contacts) {
            end = len(contacts)
        }
        batch := contacts[i:end]

        // Process batch concurrently
        var wg sync.WaitGroup
        for _, contact := range batch {
            wg.Add(1)
            go func(c Contact) {
                defer wg.Done()
                processContact(c)
            }(contact)
        }
        wg.Wait()

        // Wait between batches
        if end < len(contacts) {
            time.Sleep(time.Second)
        }
    }

    return nil
}

func processContact(contact Contact) error {
    // Implementation
    return nil
}

type Contact struct {
    ID          string
    PhoneNumber string
}

func fetchContact(id, apiKey string) (Contact, error) {
    // Implementation
    return Contact{}, nil
}

A single POST /v3/messages request accepts multiple recipients, so sending to a list is one request, not one per recipient. For high-volume sending patterns, see the Batch Operations guide.

If you cache responses, remember that message and contact state changes server-side. Prefer webhooks over polling for status updates, and expire cached reads accordingly.

Verify your handling

You have working rate-limit handling when:

  • A 429 response results in a delayed retry that succeeds, not a failed operation.
  • Your metrics show 429 counts, and a sustained increase triggers an alert.
  • Batch jobs complete without producing 429 bursts, because throttling and pacing keep steady-state traffic under 200 requests per minute.

On this page