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:
| Endpoint | API Key | OAuth | JWT | Window |
|---|---|---|---|---|
POST /api/emails/send | 100 | 50 | 100 | 60 seconds |
POST /api/emails/send/bulk | 10 | 5 | 10 | 60 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
| Endpoint | API Key | OAuth | JWT | Window |
|---|---|---|---|---|
GET /api/emails | 300 | 150 | 300 | 60 seconds |
GET /api/emails/:id | 300 | 150 | 300 | 60 seconds |
GET /api/templates/* | 300 | 150 | 300 | 60 seconds |
Why 300/minute? Read operations are lightweight and used frequently in dashboards/reporting.
Default Limits (Other Endpoints)
| Authentication | Rate Limit | Window |
|---|---|---|
| API Key | 1000 | 60 seconds |
| OAuth | 500 | 60 seconds |
| JWT (Dashboard) | 500 | 60 seconds |
| Unauthenticated | 60 | 60 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 availableBenefits 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/jsonHeader definitions:
| Header | Description | Example |
|---|---|---|
X-RateLimit-Limit | Maximum requests allowed in window | 100 |
X-RateLimit-Remaining | Requests remaining in current window | 85 |
X-RateLimit-Reset | UTC timestamp when window resets | 2025-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/jsonAdditional header when exceeded:
| Header | Description | Example |
|---|---|---|
Retry-After | Seconds until you can retry | 45 |
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: Always429for rate limit errorscode:ERR_QUOTA_003(rate limit error code)message: Human-readable error messagerelatedInfo.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 limiting5. 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 callRate Limit Summary
Rate limits are based on authentication type, not subscription plans:
| Operation | API Key | OAuth | JWT |
|---|---|---|---|
| Email Send | 100/min | 50/min | 100/min |
| Bulk Send | 10/min | 5/min | 10/min |
| Query Emails | 300/min | 150/min | 300/min |
| Other Endpoints | 1000/min | 500/min | 500/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 attemptPHP
<?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?
- Request is rejected with
429 Too Many Requests - Rate limit headers show
X-RateLimit-Remaining: 0 Retry-Afterheader tells you how long to wait- Window continues to slide - oldest requests expire
- 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-Resetshows 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:
- Sending too fast (< 2 seconds between requests)
- Multiple team members/API keys sending simultaneously
- Retry logic not implemented correctly
- 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-Resetheader 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
- Sending Emails: Learn how to send emails in Sending Emails
- Error Handling: Handle all API errors in Error Handling
- Best Practices: Optimize your integration in Best Practices
Related: Sending Emails | Error Handling | Best Practices