Developer GuideRate Limiting

Rate Limiting

Understand API rate limits, headers, and how to handle rate limit errors.

Overview

FormaMail uses rate limiting to ensure fair usage and protect API performance. Rate limits are applied per team and use a sliding window algorithm for accurate tracking.

Important: Rate limits are enforced automatically on all API endpoints. Exceeding limits returns a 429 Too Many Requests error with retry information.


Rate Limits by Endpoint

FormaMail applies different rate limits based on the operation type:

Email Sending Endpoints

Rate limits vary by authentication type:

EndpointAPI KeyOAuthJWTWindow
POST /api/emails/send1005010060 seconds
POST /api/emails/send/bulk1051060 seconds

Why different limits? OAuth has lower limits because third-party apps share platform resources. API Keys have higher limits for direct integrations by paying customers.

Query Endpoints

EndpointAPI KeyOAuthJWTWindow
GET /api/emails30015030060 seconds
GET /api/emails/:id30015030060 seconds
GET /api/templates/*30015030060 seconds

Why 300/minute? Read operations are lightweight and used frequently in dashboards/reporting.

Default Limits (Other Endpoints)

AuthenticationRate LimitWindow
API Key100060 seconds
OAuth50060 seconds
JWT (Dashboard)50060 seconds
Unauthenticated6060 seconds

How Rate Limiting Works

FormaMail uses a sliding window algorithm with Redis for accurate, distributed rate limiting:

Sliding Window Algorithm

Window: 60 seconds
Limit: 100 requests (for email send with API Key)

[Request 1]  [Request 2]  ...  [Request 100]  ← All allowed

           [Request 101] ← Rate limit exceeded (429 error)

       Wait for window to slide forward

    [Request 1 expires after 60s] ← New slot available

Benefits over fixed windows:

  • No burst traffic at window boundaries
  • More accurate request counting
  • Automatic cleanup of old requests

Rate Limit Scope

Rate limits are applied per team:

Team A: 100 requests/min ✅
Team B: 100 requests/min ✅ (separate limit)

User 1 (Team A): Count towards Team A
User 2 (Team A): Count towards Team A (shared limit)

Key points:

  • All team members share the same rate limit
  • API keys count towards the team’s limit
  • Each team has independent limits

Rate Limit Headers

Every API response includes rate limit information in headers:

Response Headers

HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 85
X-RateLimit-Reset: 2025-11-07T10:31:00.000Z
Content-Type: application/json

Header definitions:

HeaderDescriptionExample
X-RateLimit-LimitMaximum requests allowed in window100
X-RateLimit-RemainingRequests remaining in current window85
X-RateLimit-ResetUTC timestamp when window resets2025-11-07T10:31:00.000Z

When Rate Limit Exceeded

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 2025-11-07T10:31:00.000Z
Retry-After: 45
Content-Type: application/json

Additional header when exceeded:

HeaderDescriptionExample
Retry-AfterSeconds until you can retry45

Error Response Format

When you exceed the rate limit, you’ll receive a 429 error:

Rate Limit Exceeded Response

{
  "statusCode": 429,
  "code": "ERR_QUOTA_003",
  "message": "Rate limit exceeded",
  "timestamp": "2025-11-07T10:30:00.000Z",
  "path": "/api/emails/send",
  "relatedInfo": {
    "limit": 100,
    "windowSeconds": 60,
    "resetAt": "2025-11-07T10:31:00.000Z",
    "retryAfterSeconds": 45
  }
}

Response fields:

  • statusCode: Always 429 for rate limit errors
  • code: ERR_QUOTA_003 (rate limit error code)
  • message: Human-readable error message
  • relatedInfo.limit: Your rate limit (requests per window)
  • relatedInfo.windowSeconds: Window duration (60 seconds)
  • relatedInfo.resetAt: When the window resets (UTC)
  • relatedInfo.retryAfterSeconds: How long to wait before retrying

Handling Rate Limits

Best Practices

1. Check Headers Proactively

Monitor rate limit headers in every response:

const response = await fetch('https://api.formamail.com/api/emails/send', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${apiKey}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({ ... })
});
 
// Check headers
const limit = parseInt(response.headers.get('X-RateLimit-Limit'));
const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
const reset = new Date(response.headers.get('X-RateLimit-Reset'));
 
console.log(`Rate limit: ${remaining}/${limit} remaining`);
console.log(`Resets at: ${reset.toLocaleString()}`);
 
// Slow down if approaching limit
if (remaining < 5) {
  console.warn('Approaching rate limit! Slowing down...');
  await sleep(2000); // Wait 2 seconds before next request
}

2. Implement Exponential Backoff

Retry with increasing delays when you hit the limit:

async function sendEmailWithRetry(emailData, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch('https://api.formamail.com/api/emails/send', {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(emailData)
      });
 
      if (response.status === 429) {
        // Rate limit exceeded
        const retryAfter = parseInt(response.headers.get('Retry-After')) || 60;
        const delay = Math.min(retryAfter * 1000, 2 ** attempt * 1000);
 
        console.log(`Rate limited. Retrying in ${delay}ms...`);
        await sleep(delay);
        continue; // Retry
      }
 
      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${await response.text()}`);
      }
 
      return await response.json(); // Success
    } catch (error) {
      if (attempt === maxRetries - 1) throw error; // Last attempt failed
 
      // Exponential backoff for other errors
      const delay = 2 ** attempt * 1000;
      console.log(`Error: ${error.message}. Retrying in ${delay}ms...`);
      await sleep(delay);
    }
  }
}
 
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

3. Use Retry-After Header

Always respect the Retry-After header value:

if (response.status === 429) {
  const retryAfter = parseInt(response.headers.get('Retry-After'));
 
  // Wait exactly as long as the server specifies
  console.log(`Waiting ${retryAfter} seconds before retry...`);
  await sleep(retryAfter * 1000);
 
  // Retry request
  return sendEmail(emailData);
}

4. Implement Request Queuing

For high-volume applications, use a queue to control request rate:

class RateLimitedQueue {
  constructor(requestsPerMinute = 100) {
    this.requestsPerMinute = requestsPerMinute;
    this.queue = [];
    this.requestTimes = [];
  }
 
  async enqueue(requestFn) {
    return new Promise((resolve, reject) => {
      this.queue.push({ requestFn, resolve, reject });
      this.processQueue();
    });
  }
 
  async processQueue() {
    if (this.processing || this.queue.length === 0) return;
 
    this.processing = true;
 
    while (this.queue.length > 0) {
      const now = Date.now();
      const oneMinuteAgo = now - 60000;
 
      // Remove requests older than 1 minute
      this.requestTimes = this.requestTimes.filter(time => time > oneMinuteAgo);
 
      // Check if we can make another request
      if (this.requestTimes.length >= this.requestsPerMinute) {
        // Wait until oldest request is >1 minute old
        const oldestRequest = this.requestTimes[0];
        const waitTime = 60000 - (now - oldestRequest);
        await sleep(waitTime);
        continue;
      }
 
      // Process next request
      const { requestFn, resolve, reject } = this.queue.shift();
      this.requestTimes.push(now);
 
      try {
        const result = await requestFn();
        resolve(result);
      } catch (error) {
        reject(error);
      }
    }
 
    this.processing = false;
  }
}
 
// Usage
const queue = new RateLimitedQueue(100); // 100 requests per minute
 
// Add requests to queue
const result1 = await queue.enqueue(() => sendEmail(email1));
const result2 = await queue.enqueue(() => sendEmail(email2));
// ... Queue automatically manages rate limiting

5. Batch Operations

Use bulk endpoints instead of individual requests:

❌ Bad - 100 individual requests:

for (const email of emails) {
  await sendEmail(email); // 100 API calls
}

✅ Good - 1 bulk request:

await sendBulkEmails(emails); // 1 API call

Rate Limit Summary

Rate limits are based on authentication type, not subscription plans:

OperationAPI KeyOAuthJWT
Email Send100/min50/min100/min
Bulk Send10/min5/min10/min
Query Emails300/min150/min300/min
Other Endpoints1000/min500/min500/min

Note: All teams have the same rate limits. For higher limits, contact support for enterprise solutions.


Code Examples

JavaScript/Node.js

Basic Rate Limit Checking

const apiKey = process.env.FORMAMAIL_API_KEY;
 
async function sendEmail(emailData) {
  const response = await fetch('https://api.formamail.com/api/emails/send', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(emailData)
  });
 
  // Check rate limit headers
  const limit = response.headers.get('X-RateLimit-Limit');
  const remaining = response.headers.get('X-RateLimit-Remaining');
  const reset = response.headers.get('X-RateLimit-Reset');
 
  console.log(`Rate limit: ${remaining}/${limit} remaining (resets at ${reset})`);
 
  if (response.status === 429) {
    const retryAfter = response.headers.get('Retry-After');
    throw new Error(`Rate limit exceeded. Retry after ${retryAfter} seconds.`);
  }
 
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}: ${await response.text()}`);
  }
 
  return response.json();
}

With Automatic Retry

async function sendEmailWithRetry(emailData, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await sendEmail(emailData);
    } catch (error) {
      if (error.message.includes('Rate limit exceeded') && attempt < maxRetries - 1) {
        const match = error.message.match(/Retry after (\d+) seconds/);
        const retryAfter = match ? parseInt(match[1]) : 60;
 
        console.log(`Attempt ${attempt + 1} failed. Retrying in ${retryAfter}s...`);
        await sleep(retryAfter * 1000);
        continue;
      }
      throw error; // Rethrow if not rate limit or last attempt
    }
  }
}

Python

Basic Rate Limit Checking

import requests
import time
from datetime import datetime
 
API_KEY = os.environ['FORMAMAIL_API_KEY']
BASE_URL = 'https://api.formamail.com'
 
def send_email(email_data):
    response = requests.post(
        f'{BASE_URL}/api/emails/send',
        headers={
            'Authorization': f'Bearer {API_KEY}',
            'Content-Type': 'application/json'
        },
        json=email_data
    )
 
    # Check rate limit headers
    limit = int(response.headers.get('X-RateLimit-Limit', 0))
    remaining = int(response.headers.get('X-RateLimit-Remaining', 0))
    reset = response.headers.get('X-RateLimit-Reset')
 
    print(f'Rate limit: {remaining}/{limit} remaining (resets at {reset})')
 
    if response.status_code == 429:
        retry_after = int(response.headers.get('Retry-After', 60))
        raise Exception(f'Rate limit exceeded. Retry after {retry_after} seconds.')
 
    response.raise_for_status()
    return response.json()

With Automatic Retry

def send_email_with_retry(email_data, max_retries=3):
    for attempt in range(max_retries):
        try:
            return send_email(email_data)
        except Exception as error:
            if 'Rate limit exceeded' in str(error) and attempt < max_retries - 1:
                # Extract retry_after from error message
                import re
                match = re.search(r'Retry after (\d+) seconds', str(error))
                retry_after = int(match.group(1)) if match else 60
 
                print(f'Attempt {attempt + 1} failed. Retrying in {retry_after}s...')
                time.sleep(retry_after)
                continue
            raise  # Rethrow if not rate limit or last attempt

PHP

<?php
$apiKey = getenv('FORMAMAIL_API_KEY');
$baseUrl = 'https://api.formamail.com';
 
function sendEmail($emailData) {
    global $apiKey, $baseUrl;
 
    $ch = curl_init("$baseUrl/api/emails/send");
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($emailData));
    curl_setopt($ch, CURLOPT_HTTPHEADER, [
        "Authorization: Bearer $apiKey",
        "Content-Type: application/json"
    ]);
    curl_setopt($ch, CURLOPT_HEADER, true); // Include headers in output
 
    $response = curl_exec($ch);
    $headerSize = curl_getinfo($ch, CURLINFO_HEADER_SIZE);
    $headers = substr($response, 0, $headerSize);
    $body = substr($response, $headerSize);
    $statusCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
 
    curl_close($ch);
 
    // Parse rate limit headers
    preg_match('/X-RateLimit-Limit: (\d+)/', $headers, $limitMatch);
    preg_match('/X-RateLimit-Remaining: (\d+)/', $headers, $remainingMatch);
    preg_match('/X-RateLimit-Reset: (.+)/', $headers, $resetMatch);
 
    $limit = $limitMatch[1] ?? 0;
    $remaining = $remainingMatch[1] ?? 0;
    $reset = $resetMatch[1] ?? 'unknown';
 
    echo "Rate limit: $remaining/$limit remaining (resets at $reset)\n";
 
    if ($statusCode === 429) {
        preg_match('/Retry-After: (\d+)/', $headers, $retryAfterMatch);
        $retryAfter = $retryAfterMatch[1] ?? 60;
        throw new Exception("Rate limit exceeded. Retry after $retryAfter seconds.");
    }
 
    if ($statusCode >= 400) {
        throw new Exception("HTTP $statusCode: $body");
    }
 
    return json_decode($body, true);
}
 
function sendEmailWithRetry($emailData, $maxRetries = 3) {
    for ($attempt = 0; $attempt < $maxRetries; $attempt++) {
        try {
            return sendEmail($emailData);
        } catch (Exception $error) {
            if (strpos($error->getMessage(), 'Rate limit exceeded') !== false &&
                $attempt < $maxRetries - 1) {
                preg_match('/Retry after (\d+) seconds/', $error->getMessage(), $match);
                $retryAfter = $match[1] ?? 60;
 
                echo "Attempt " . ($attempt + 1) . " failed. Retrying in {$retryAfter}s...\n";
                sleep($retryAfter);
                continue;
            }
            throw $error; // Rethrow if not rate limit or last attempt
        }
    }
}
?>

Testing Rate Limits

Manual Testing

Test rate limits using curl:

#!/bin/bash
 
API_KEY="your-api-key"
TEMPLATE_ID="your-template-id"
 
echo "Sending 105 requests (rate limit is 100/min)..."
 
for i in {1..105}; do
  echo "Request $i:"
 
  curl -X POST https://api.formamail.com/api/emails/send \
    -H "Authorization: Bearer $API_KEY" \
    -H "Content-Type: application/json" \
    -d '{
      "templateId": "'$TEMPLATE_ID'",
      "to": [{"email": "test@example.com"}],
      "variables": {}
    }' \
    -i | grep -E "HTTP|X-RateLimit|Retry-After"
 
  echo "---"
done
 
echo "First 100 should succeed, last 5 should get 429 errors"

Expected output:

Request 1:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
---
...
Request 100:
HTTP/1.1 200 OK
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
---
Request 101:
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 0
Retry-After: 45
---

Common Questions

How are rate limits calculated?

Per team using a sliding 60-second window:

  • Window tracks the last 60 seconds of requests
  • Limit is checked on each request
  • Old requests automatically drop out of the window
  • More accurate than fixed windows

What happens if I exceed the rate limit?

  1. Request is rejected with 429 Too Many Requests
  2. Rate limit headers show X-RateLimit-Remaining: 0
  3. Retry-After header tells you how long to wait
  4. Window continues to slide - oldest requests expire
  5. When window has available slots, requests succeed again

Do rate limits reset at a fixed time?

No. Rate limits use a sliding window, not a fixed window:

  • No specific “reset time”
  • X-RateLimit-Reset shows when the current window ends
  • Requests continuously expire after 60 seconds
  • No burst traffic at window boundaries

Can I request higher rate limits?

Yes, custom rate limits are available:

  • Contact us for high-volume requirements
  • Rate limits can be increased per team
  • Custom limits available for large-scale senders

Do failed requests count towards the limit?

Yes. All requests count, regardless of outcome:

  • ✅ Successful requests (200) - count
  • ❌ Failed requests (400, 500) - count
  • ⏸️ Rate limited requests (429) - count

Why? To prevent abuse and ensure fair usage.

Are rate limits per API key or per team?

Per team. All users and API keys in a team share the same limit:

  • User A + User B + API Key 1 = shared 100 requests/min (for email send)
  • Exceeding limit affects all team members
  • Consider creating separate teams for independent limits

How do I monitor my rate limit usage?

Check headers on every response:

const remaining = parseInt(response.headers.get('X-RateLimit-Remaining'));
const limit = parseInt(response.headers.get('X-RateLimit-Limit'));
const percentUsed = ((limit - remaining) / limit) * 100;
 
if (percentUsed > 80) {
  console.warn(`Using ${percentUsed}% of rate limit!`);
}

Troubleshooting

Getting 429 errors frequently?

Possible causes:

  1. Sending too fast (< 2 seconds between requests)
  2. Multiple team members/API keys sending simultaneously
  3. Retry logic not implemented correctly
  4. Using individual requests instead of bulk endpoints

Solutions:

  • Implement exponential backoff (see examples above)
  • Use bulk endpoints for multiple emails
  • Add delays between requests (2-3 seconds)
  • Implement request queuing
  • Upgrade to higher plan (future)

Retry-After header missing?

If Retry-After header is missing:

  • Default to 60 seconds
  • Use exponential backoff (2^attempt seconds)
  • Check X-RateLimit-Reset header for exact reset time

Rate limit headers not appearing?

All endpoints include rate limit headers. If missing:

  • Check endpoint URL (must be /api/emails/...)
  • Verify authentication (rate limits require valid auth)
  • Contact support if issue persists

Next Steps


Related: Sending Emails | Error Handling | Best Practices