Rate Limits
Implemented in src/lib/api/rate-limit.ts via @upstash/ratelimit.
Two buckets are checked in order on every authenticated /api/v1/* request:
- Per-key bucket (optional) — only when the API key has
rate_limit_per_minuteset. - Per-org / plan bucket — always checked unless Upstash is not configured.
If either bucket rejects, the request fails with 429 and no further work is done.
Buckets
| Bucket | Scope | Key | Default |
|---|---|---|---|
| Per-key | One API key | ${apiKeyId} |
Unset — falls through to the org bucket |
| Per-org | Whole organization | ${orgId} |
free: 60 / min • pro: 600 / min • enterprise: 6000 / min |
Both buckets use a sliding 60-second window.
Configure the per-key limit with rateLimitPerMinute when creating a key — see features/api-keys.md.
Graceful degradation
If UPSTASH_REDIS_REST_URL or UPSTASH_REDIS_REST_TOKEN is absent, checkRateLimit() returns null — requests pass. Useful for local dev.
Response when throttled
Status: 429.
{
"error": {
"type": "rate_limit_error",
"message": "Rate limit exceeded. Try again later.",
"code": "RATE_LIMITED"
}
}
Headers are set on both 429 and successful responses:
X-RateLimit-Limit: 600
X-RateLimit-Remaining: 0
X-RateLimit-Reset: <unix-ms>
X-RateLimit-Plan: pro
X-RateLimit-Scope: key | org
X-RateLimit-Scope tells you which bucket reported the numbers. When a per-key limit is set, the key bucket is authoritative; otherwise the org bucket is shown.
Usage quota vs. rate limit
Two separate limits:
- Rate limit (this doc) — short-burst throttle, per-minute.
- Usage limit (features/usage-limits.md) — monthly plan quota, per-channel.
Both can return 429 but with different code values — RATE_LIMITED vs USAGE_LIMIT_EXCEEDED.
Retrying after 429
Respect X-RateLimit-Reset — it's a UNIX ms timestamp. Sleep until then, or use exponential backoff with jitter (2^attempt × 250 ms + random up to 100 ms) capped at a few minutes.
curl
until curl -sf -X POST https://app.sendoka.com/api/v1/emails \
-H "Authorization: Bearer $SENDOKA_API_KEY" \
-H "Content-Type: application/json" \
-d "$payload"; do
sleep $((RANDOM % 3 + 1))
done
Node
Retry 5xx and 429 with exponential backoff. Honor X-RateLimit-Reset when present:
async function postWithRetry(url: string, body: unknown, attempts = 5) {
for (let i = 0; i < attempts; i++) {
const res = await fetch(url, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
if (res.status === 429 || res.status >= 500) {
const reset = Number(res.headers.get("X-RateLimit-Reset"));
const waitMs = reset
? Math.max(0, reset - Date.now())
: Math.min(60_000, 2 ** i * 250) + Math.random() * 100;
await new Promise((r) => setTimeout(r, waitMs));
continue;
}
throw await res.json();
}
throw new Error("max retries exceeded");
}
Python
import time, random, requests
def send(payload, attempts=5):
for i in range(attempts):
r = requests.post(url, json=payload, headers=headers)
if r.ok:
return r.json()
if r.status_code in (429, 500, 502, 503, 504):
reset = int(r.headers.get("X-RateLimit-Reset", 0))
wait = max(0, (reset / 1000 - time.time())) if reset else 2**i * 0.25 + random.random() * 0.1
time.sleep(wait)
continue
r.raise_for_status()
raise RuntimeError("max retries exceeded")
Idempotency
Pair retries with an Idempotency-Key header (any unique string, we suggest a UUIDv4) so a retry that the first attempt actually processed doesn't create a duplicate send. Keys are valid for 24 hours and stored with a body hash — reusing a key with a different body returns 409 IDEMPOTENCY_MISMATCH.