Rate Limits

The Sent API v3 implements rate limiting to ensure platform stability and fair access for all users. Rate limits are applied per customer account based on the API key used.


Rate Limit Tiers

Standard Endpoints

Most API endpoints have the following limits:

TierRequests per MinuteBurst Capacity
Standard20050

Standard endpoints include:

  • Contacts (GET, POST, PATCH, DELETE)
  • Messages (GET, POST)
  • Templates (GET, POST, PUT, DELETE)
  • Profiles (GET, POST, PATCH, DELETE)
  • Brands and Campaigns (GET, POST, PUT, DELETE)

Sensitive Endpoints

Certain endpoints that perform sensitive operations have stricter limits:

TierRequests per MinuteBurst Capacity
Sensitive105

Sensitive endpoints include:

  • Webhook secret rotation (POST /v3/webhooks/{id}/rotate-secret)
  • User invitation (POST /v3/users)
  • Profile completion (POST /v3/profiles/{profileId}/complete)

Message Sending

Message sending endpoints have specific limits based on your account tier:

TierMessages per MinuteNotes
Starter60For new accounts and testing
Growth300Standard production limits
EnterpriseCustomContact support for higher limits

Rate Limit Headers

All API responses include rate limit headers. When you exceed a rate limit, the 429 Too Many Requests response includes additional headers:

Standard Response Headers

X-RateLimit-Limit: 200
X-RateLimit-Remaining: 150
X-RateLimit-Reset: 1705312800

Rate Limit Exceeded Response (429)

Retry-After: 60
X-RateLimit-Limit: 200
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1705312800

Header Reference

HeaderDescriptionExample
X-RateLimit-LimitMaximum requests allowed in the window200
X-RateLimit-RemainingRequests remaining in current window150
X-RateLimit-ResetUnix timestamp when the window resets1705312800
Retry-AfterSeconds until you can retry (only on 429)60

Handling Rate Limits

429 Response Body

When rate limited, the API returns:

{
  "success": false,
  "error": {
    "code": "BUSINESS_002",
    "message": "Rate limit exceeded",
    "doc_url": "https://docs.sent.dm/errors/BUSINESS_002"
  },
  "meta": {
    "request_id": "req_abc123",
    "timestamp": "2024-01-15T10:30:00Z",
    "version": "v3"
  }
}

Best Practices

Implement Exponential Backoff

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")
}

Monitor Rate Limit Headers

interface RateLimitStatus {
  limit: number;
  remaining: number;
  reset: Date;
}

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

  // Log rate limit status for monitoring
  const limit = response.headers.get('X-RateLimit-Limit');
  const remaining = response.headers.get('X-RateLimit-Remaining');
  const reset = response.headers.get('X-RateLimit-Reset');

  if (limit && remaining && reset) {
    console.log(`Rate limit: ${remaining}/${limit}, resets at ${new Date(parseInt(reset) * 1000)}`);

    // Alert when running low
    if (parseInt(remaining) < 20) {
      console.warn('Rate limit running low!');
    }

    // Store for metrics
    trackRateLimit({
      limit: parseInt(limit),
      remaining: parseInt(remaining),
      reset: new Date(parseInt(reset) * 1000)
    });
  }

  return response;
}

function trackRateLimit(status: RateLimitStatus): void {
  // Send to your monitoring system
  // metrics.gauge('api.rate_limit.remaining', status.remaining);
}
import time
from dataclasses import dataclass
from typing import Optional
import requests

@dataclass
class RateLimitStatus:
    limit: int
    remaining: int
    reset: int  # Unix timestamp

def make_request(url: str, headers: dict) -> requests.Response:
    """Make request and track rate limit headers."""
    response = requests.get(url, headers=headers)

    # Log rate limit status for monitoring
    limit = response.headers.get('X-RateLimit-Limit')
    remaining = response.headers.get('X-RateLimit-Remaining')
    reset = response.headers.get('X-RateLimit-Reset')

    if limit and remaining and reset:
        reset_time = time.strftime('%Y-%m-%d %H:%M:%S',
                                   time.localtime(int(reset)))
        print(f'Rate limit: {remaining}/{limit}, resets at {reset_time}')

        # Alert when running low
        if int(remaining) < 20:
            print('WARNING: Rate limit running low!')

        # Store for metrics
        track_rate_limit(RateLimitStatus(
            limit=int(limit),
            remaining=int(remaining),
            reset=int(reset)
        ))

    return response

def track_rate_limit(status: RateLimitStatus):
    """Send to your monitoring system (Datadog, Prometheus, etc)."""
    # Example: statsd.gauge('api.rate_limit.remaining', status.remaining)
    pass
package main

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

type RateLimitStatus struct {
    Limit     int
    Remaining int
    Reset     time.Time
}

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

    // Log rate limit status for monitoring
    limit := resp.Header.Get("X-RateLimit-Limit")
    remaining := resp.Header.Get("X-RateLimit-Remaining")
    reset := resp.Header.Get("X-RateLimit-Reset")

    if limit != "" && remaining != "" && reset != "" {
        limitInt, _ := strconv.Atoi(limit)
        remainingInt, _ := strconv.Atoi(remaining)
        resetUnix, _ := strconv.ParseInt(reset, 10, 64)
        resetTime := time.Unix(resetUnix, 0)

        fmt.Printf("Rate limit: %d/%d, resets at %s\n",
            remainingInt, limitInt, resetTime)

        // Alert when running low
        if remainingInt < 20 {
            fmt.Println("WARNING: Rate limit running low!")
        }

        // Store for metrics
        trackRateLimit(RateLimitStatus{
            Limit:     limitInt,
            Remaining: remainingInt,
            Reset:     resetTime,
        })
    }

    return resp, nil
}

func trackRateLimit(status RateLimitStatus) {
    // Send to your monitoring system
    // Example: statsd.Gauge("api.rate_limit.remaining", status.Remaining)
}

Implement Request Throttling

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 using token bucket algorithm."""

    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)
}

Use Request Batching

When possible, batch operations to reduce API calls:

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

// ✅ Efficient: Client-side caching to reduce duplicates
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, Optional
import time
import requests

# ❌ Inefficient: Individual requests (100 API calls)
# for contact in contacts:
#     create_contact(contact)

# ✅ Efficient: Client-side caching to reduce duplicates
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: Individual requests (100 API calls)
// for _, contact := range contacts {
//     createContact(contact)
// }

// ✅ Efficient: Client-side caching to reduce duplicates
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
}

Rate Limiting by Endpoint

Contacts

EndpointMethodRate Limit
/v3/contactsGET200/min
/v3/contactsPOST200/min
/v3/contacts/{id}GET200/min
/v3/contacts/{id}PATCH200/min
/v3/contacts/{id}DELETE200/min

Messages

EndpointMethodRate Limit
/v3/messagesPOST60-300/min*
/v3/messages/{id}GET200/min
/v3/messages/{id}/activitiesGET200/min

*Based on your account tier

Templates

EndpointMethodRate Limit
/v3/templatesGET200/min
/v3/templatesPOST200/min
/v3/templates/{id}GET200/min
/v3/templates/{id}PUT200/min
/v3/templates/{id}DELETE200/min

Webhooks

EndpointMethodRate Limit
/v3/webhooksGET200/min
/v3/webhooksPOST200/min
/v3/webhooks/{id}/rotate-secretPOST10/min
/v3/webhooks/{id}/testPOST60/min

Users

EndpointMethodRate Limit
/v3/usersGET200/min
/v3/usersPOST10/min
/v3/users/{id}PATCH200/min
/v3/users/{id}DELETE200/min

Increasing Rate Limits

If you're consistently hitting rate limits, consider:

Optimize Your Integration

  • Implement caching to reduce duplicate requests
  • Use webhooks instead of polling for status updates
  • Batch operations where possible

Contact Support

For legitimate high-volume use cases, contact support@sent.dm to discuss:

  • Increased rate limits
  • Enterprise pricing
  • Dedicated infrastructure

When contacting support, provide:

  • Your use case and expected volume
  • Current rate limit issues you're experiencing
  • Timeframe for increased needs

Rate Limiting FAQ

Do rate limits apply per API key or per account?

Rate limits apply per customer account. All API keys for the same account share the same rate limit pool.

Do failed requests count against rate limits?

Authentication failures (401, 403) do not count against rate limits. All other requests, including validation errors (400, 422), count toward your limit.

What happens when I exceed a rate limit?

You receive a 429 Too Many Requests response with a Retry-After header indicating when you can retry.

Can I check my current rate limit status?

Yes, check the X-RateLimit-* headers on any API response.

Do rate limits reset at specific times?

Rate limits use a sliding window. The X-RateLimit-Reset header indicates when your current window expires.


On this page