Rate limits
Inbox applies a coarse edge limit of 6000 requests per 60-second fixed window, per credential. It is deliberately generous: it exists to stop a runaway loop or a credential-stuffing sweep, not to shape normal traffic.
private static readonly LIMIT = 6000;
private static readonly WINDOW_SECONDS = 60;
It runs before authentication
RateLimitGuard is the first guard in the pipeline, ahead of AuthGuard. Two consequences:
- Public routes are limited too.
/v1/ping,/v1/health, the webhook receivers, the LiveChat widget routes, the campaign click redirect — all of them consume from a bucket. - A 429 can pre-empt a 401. Hammering with a bad key gets you rate limited before you get told the key is bad. That is the point: a rejected credential should not cost an Accounts introspection round-trip per attempt.
Bucket key derivation
Resolved in order, first match wins:
| Order | Source | Bucket key |
|---|---|---|
| 1 | x-api-key header |
tok:<sha256(key)[0:32]> |
| 2 | Authorization: Bearer <token> |
tok:<sha256(token)[0:32]> |
| 3 | Request IP | ip:<address> |
| 4 | Nothing above | anon |
A service key may arrive on either header, so both are bucketed identically — you cannot double your allowance by alternating headers.
The hash is not decoration. The bucket key becomes a Redis key and shows up in metrics and logs, and credentials are opaque to inbox (Accounts owns them). Hashing means the secret never reaches either place. Truncating to 32 hex characters keeps the key short while leaving ample collision headroom.
Note that ip: and anon are shared buckets. Unauthenticated traffic from behind one NAT shares one
allowance, and anything the guard cannot key at all shares a single global bucket.
When you exceed it
{
"error": {
"code": "rate_limit_exceeded",
"message": "Too many requests.",
"requestId": "01J8Z…",
"rateLimit": { "limit": 6000, "remaining": 0, "resetAt": 1756654380 }
}
}
Status is 429. AllExceptionsFilter also sets a retry-after response header, in seconds,
computed as max(0, resetAt - now). resetAt itself is unix seconds.
This is a fixed window, not a sliding one: the counter resets wholesale at resetAt. Traffic
clustered around a window boundary can therefore land up to 12000 requests in a rolling minute
without ever being refused.
Redis-backed or per-process
When INBOX_REDIS_URL is set, counting goes through RedisRateLimiter, which does INCR plus
EXPIRE-on-first plus a TTL read in one Lua script — atomic, so there is no stuck-key race and no
partial state. Every instance shares one counter.
When it is not set, the service falls back to InMemoryRateLimiter: a plain Map in the process.
The effective limit then multiplies by your instance count. Four instances behind a load balancer
give a single credential up to 24000 requests per minute, and which instance a given request lands on
decides which counter it touches. In-memory buckets also vanish on restart. That is fine for local
development and wrong for production — if you run more than one instance, set INBOX_REDIS_URL.
Handling it correctly
Respect retry-after and cap it. An upstream or a misconfigured proxy answering Retry-After: 86400
should not park your request for a day holding a socket.
async function call(req: Request): Promise<Response> {
for (let attempt = 0; ; attempt++) {
const res = await fetch(req);
if (res.status !== 429 && res.status < 500) return res;
if (attempt >= 3) return res;
const retryAfter = Number(res.headers.get("retry-after"));
const waitSeconds =
res.status === 429 && retryAfter > 0
? Math.min(retryAfter, 60) // cap
: Math.min(2 ** attempt, 8); // backoff for 5xx
await res.body?.cancel().catch(() => undefined); // don't pin keep-alive sockets
await new Promise((r) => setTimeout(r, waitSeconds * 1000));
}
}
That is what @~inbox/sdk does. It retries 429 and 5xx up to maxRetries (default 3), caps
Retry-After at 60 seconds, drains the response body before sleeping, and — the part that makes the
retry safe — reuses the same Idempotency-Key across attempts, so a retried write replays instead
of duplicating. See Idempotency.
A different limiter: per-connection provider budgets
Do not confuse the edge limit with ConnectionRateLimiterService, which protects the provider,
not inbox. It buckets on conn-cap:<connectionId>:<capability> and is consumed at the point inbox is
about to call out to a network:
| Where | Capability | Budget |
|---|---|---|
| Thread reply | the thread's messaging capability | 200 / hour |
private_reply on a comment |
comments |
200 / hour |
| Post dispatch | publish |
1000 / hour |
| Contact profile enrichment | profile-enrich |
its own small budget |
It throws the same rate_limit_exceeded code with the same rateLimit payload, so a 429 from inbox
may mean either "you are calling us too fast" or "this connection has spent its hourly budget with
its provider". They are told apart by the numbers: limit: 6000 is the edge limit; anything else is
a per-connection budget.
Profile enrichment treats its own 429 as "skip this time", never as a failure — enrichment is opportunistic and must not fail an ingest.