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 responsepackage 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
passpackage 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
429response 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.
Related
- Refer to the Rate Limits reference for limit values, window semantics, and the 429 response format.
- How to retry Sent API requests safely
- How to handle Sent API errors
How to retry Sent API requests safely
Retry Sent API v3 requests without duplicating messages or contacts: derive stable idempotency keys, reuse them across retries, and resolve conflicts.
Testing & Debugging
Test and debug your Sent integration by validating sends with sandbox mode, unit-testing webhook handlers, verifying signatures, and tracing failures.