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, live | The organization's live keys | ${orgId} |
free: 60 / min • payg: 600 / min • pro: 600 / min • enterprise: 6000 / min |
| Per-org, test | The organization's test keys | ${orgId}:test |
The same numbers as live |
All three use a sliding 60-second window. A request is charged to exactly one per-org bucket, the one for its key's mode.
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, evaluateRateLimit() runs no limiter and returns no response — requests pass, with no X-RateLimit-* headers. 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.
Test keys
Test traffic has limits of its own and never spends a budget live traffic runs
on. Every limit a sok_test_* key can reach is counted apart from the live one,
at the same size, so a CI suite running flat out (or a leaked test key) is
throttled alone:
| Limit | Live bucket | Test bucket |
|---|---|---|
| API requests per minute (above) | ${orgId} |
${orgId}:test |
| Verification codes — per destination, per hour | 5 | 5, separate |
| Verification codes — per org, per hour | 1,000 | 1,000, separate |
| Webhook test-fire — per endpoint, per minute | 20 | 20, separate |
| Webhook replay — per endpoint, per minute | 10 | 10, separate |
| Simulated 10DLC provisioning — per org, per hour | 10 (billed registrations) | 10, separate |
A sok_live_* key's buckets, and the numbers on its X-RateLimit-* headers,
are exactly what they were before the split. On a test key the headers describe
the test bucket.
Three more limits apply to test keys only:
Test messages per day. A test send reaches no provider and costs nothing, but it still writes a message, fans it out to your webhooks and, if tracked, signs its links, so test mode has a daily allowance of messages per organization, reset at 00:00 UTC:
Plan Test-mode messages per UTC day Free 1,000 Pay as you go, Pro 10,000 Enterprise 100,000 Every test-mode message counts once:
POST /v1/emailsand/v1/sms(scheduled or not), each item of/v1/emails/batchand/v1/sms/batch(the bulk shape counts each recipient), each recipient of an audience blast, a test-modePOST /v1/verifications, and a dashboard resend or template test send of a test message. A request is charged whole or refused whole — a batch of 100 with 40 left today is refused, not cut to 40 — with429 TEST_VOLUME_LIMIT_EXCEEDEDand nothing written; its message says how many are left today. A request refused for any other reason spends none of it — the charge comes last before the write, after an audience blast's render (a blast refused413or422 UNSUBSCRIBE_TOKEN_LIMITspends nothing; a blast that cannot fit in what is left is refused before it renders, by a look that spends nothing) and after a verification's hourly ceilings. Two exceptions: a batch is charged for every item up front, including items that then fail on their own, and a dashboard template test send whose provider call fails has spent its row. Under a tenant (a tenant-bound key, orX-Sendoka-Tenant-Ref), the request is also charged to that tenant's share, a quarter of the organization's day, so one tenant cannot spend every other's; its refusals name only its own share.Scheduled test messages. An organization may have at most 20,000 test-mode messages waiting to send (
scheduled, or claimed and mid-send), and one tenant at most 5,000 of them. Test sends share the scheduled queue with live ones, and the send cron always takes due live messages first, so this bounds what one organization can park there. A scheduled/v1/emailsor/v1/sms, an audience blast, or a dashboard resend of a test message that would pass it answers429 TEST_SCHEDULE_LIMIT_EXCEEDEDand schedules nothing. It is not a rate: it frees up as the messages send or are canceled.API keys. A test key cannot create keys at all (
403 ENVIRONMENT_ESCALATION): every key created spends the organization's key-creation limit (10 an hour, shared with the dashboard) and a slot of the plan's key cap. Create keys in the dashboard or with a live key.
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 (and, for test keys only, TEST_SCHEDULE_LIMIT_EXCEEDED and TEST_VOLUME_LIMIT_EXCEEDED — see Test keys). Neither of the test-key codes carries Retry-After, and the SDKs do not retry them.
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://www.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.