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 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.