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:

  1. Per-key bucket (optional) — only when the API key has rate_limit_per_minute set.
  2. 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/emails and /v1/sms (scheduled or not), each item of /v1/emails/batch and /v1/sms/batch (the bulk shape counts each recipient), each recipient of an audience blast, a test-mode POST /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 — with 429 TEST_VOLUME_LIMIT_EXCEEDED and 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 refused 413 or 422 UNSUBSCRIBE_TOKEN_LIMIT spends 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, or X-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/emails or /v1/sms, an audience blast, or a dashboard resend of a test message that would pass it answers 429 TEST_SCHEDULE_LIMIT_EXCEEDED and 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.