BETA
Skip to content

Rate Limiting

Krafter Mail enforces rate limits to protect the service from abuse and to keep one team's traffic from affecting another. Limits are evaluated per team using a fixed window.

Limits

ResourceLimitWindow
All API endpoints100 requests60 seconds (fixed)

The window starts at floor(now / 60) * 60, so all callers in the same calendar minute share one bucket. The bucket is keyed on the authenticated team — two API keys belonging to the same team share the same counter.

INFO

This is a fixed window, not a sliding window. When the window rolls over, the counter resets to zero.

Rate Limit Headers

Every API response — both 200 OK and 429 Too Many Requests — includes the current bucket's headers:

http
HTTP/1.1 200 OK
x-ratelimit-limit: 100
x-ratelimit-remaining: 87
x-ratelimit-reset: 1705312260
HeaderDescription
x-ratelimit-limitMaximum requests per window
x-ratelimit-remainingRequests remaining in the current window
x-ratelimit-resetUnix timestamp (seconds) when the window resets

HTTP header names are case-insensitive; Krafter emits them in lowercase.

Exceeding the Limit

When the limit is exceeded, the API returns a 429 Too Many Requests response with a Retry-After header (RFC 7231 §7.1.3) and a JSON body that carries the same value as retry_after:

http
HTTP/1.1 429 Too Many Requests
Content-Type: application/json
Retry-After: 23
x-ratelimit-limit: 100
x-ratelimit-remaining: 0
x-ratelimit-reset: 1705312260
json
{
  "error": "Rate limit exceeded",
  "retry_after": 23
}

The number is the seconds remaining until the current window resets, not a back-off recommendation. Use either the header or the JSON field — they are always equal.

Handling Rate Limits

Exponential Backoff

The recommended approach is to honour Retry-After, then add jitter and exponential backoff for any further attempts:

typescript
async function sendWithRetry(payload: object, maxRetries = 3) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    const response = await fetch("https://app.krafter.dev/api/v1/emails", {
      method: "POST",
      headers: {
        Authorization: "Bearer kr_live_abc123def456",
        "Content-Type": "application/json",
      },
      body: JSON.stringify(payload),
    });

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

    const retryAfter = parseInt(response.headers.get("Retry-After") || "1", 10);
    const jitter = Math.random() * 1000;
    const delay = retryAfter * 1000 + jitter;

    console.log(`Rate limited. Retrying in ${Math.round(delay / 1000)}s...`);
    await new Promise((resolve) => setTimeout(resolve, delay));
  }

  throw new Error("Max retries exceeded");
}

Batch Sending

If you need to send to many recipients at once, use the batch endpoint instead of issuing individual requests. A single batch request counts as one request against your rate limit:

bash
curl -X POST https://app.krafter.dev/api/v1/emails/batch \
  -H "Authorization: Bearer kr_live_abc123def456" \
  -H "Content-Type: application/json" \
  -d '{
    "emails": [
      {"from": "hello@yourdomain.com", "to": "alice@example.com",
       "subject": "Welcome", "html": "<p>Hi Alice</p>", "stream": "transactional"},
      {"from": "hello@yourdomain.com", "to": "bob@example.com",
       "subject": "Welcome", "html": "<p>Hi Bob</p>", "stream": "transactional"}
    ]
  }'

Best Practices

  • Watch x-ratelimit-remaining and slow down before hitting zero.
  • Use the batch endpoint to send to multiple recipients in a single request.
  • Honour Retry-After before retrying on 429.
  • Spread requests over time rather than sending bursts at the start of the window.
  • Sandbox keys also have hourly and daily caps layered on top of the per-team rate limit. Hitting a sandbox cap returns 429 with a policy code — see Error handling.

Built by Krafter Studio