Rate limits
Per-principal plan-tier quotas, the 429 response shape, and how to back off gracefully.
Rate limits protect shared infrastructure. They're generous for normal product integrations but exist to prevent runaway loops.
Quotas
Rate limiting is per principal — one bucket per API key (hashed) — not
per endpoint. Every request, regardless of route, draws from the same two
sliding windows: a per-minute limit and a per-day limit. There are
no per-second or per-endpoint quotas. /events, /eggs and
/widget-sessions all consume the same bucket.
The size of that bucket comes from the customer's plan tier:
| Plan | Per minute | Per day |
|---|---|---|
| Starter | 100 | 5,000 |
| Growth | 1,000 | 50,000 |
| Pro | 5,000 | 250,000 |
| Enterprise | 50,000 | unlimited |
A few routes use different buckets:
| Bucket | Per minute | Per day |
|---|---|---|
Widget & embed-token routes (/widget*, /embed-tokens) | 3× the plan limit | 3× the plan limit |
| Scoped principals (publishable key, widget/dashboard JWT) | 200 | 10,000 |
| Public share & profile pages | 60 | 2,000 |
| Unauthenticated routes | 20 | 200 |
| Auth burst (login / register / password) | 10 | 100 |
The windows are 60 seconds (per minute) and 86,400 seconds (per day). Higher plan tiers raise both the per-minute and per-day limits — talk to sales if you need a larger bucket.
The 429 response
HTTP/1.1 429 Too Many Requests
Retry-After: 60
Content-Type: application/json
{
"error": {
"code": "rate_limited",
"message": "Rate limit exceeded. Please retry after 60 seconds.",
"requestId": "…"
}
}The body has no details object. The wait value comes from the
Retry-After response header — the full window in seconds (60 for the
per-minute limit, 86400 for the per-day limit), not a small value. The
SDK reads that header into RateLimitError.retryAfter (defaulting to 60s
if absent).
Built-in retry
The SDK retries 429s automatically (honouring Retry-After) up to
maxRetries (default 3) with exponential backoff + jitter. You only need
manual backoff for sustained overages or custom queue drains.
new HatchedClient({
apiKey: process.env.HATCHED_API_KEY!,
maxRetries: 5, // default 3
});Backoff pattern
import { RateLimitError } from '@hatched/sdk-js';
async function sendWithBackoff(event) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
return await hatched.events.send(event);
} catch (err) {
if (err instanceof RateLimitError) {
await sleep((err.retryAfter + Math.random()) * 1000);
continue;
}
throw err;
}
}
throw new Error('rate limit exhausted');
}Add jitter (the Math.random() term) so concurrent callers don't all
retry on the same millisecond.
Bulk ingestion
For high-volume backfills, don't serialise through events.send. Use
POST /events/batch (SDK: events.sendBatch) instead:
- Group up to 100 events per request (the batch cap).
- Each call processes synchronously and returns per-event results inline (see HTTP API).
- Accepted events are deduped by
(customer_id, event_id); each non-duplicate event counts against your monthly event quota. The batch endpoint shares the same per-minute / per-day rate-limit bucket as every other write — there is no cap bypass and no separate async throughput quota. A402(event_quota_exceeded) is returned if the monthly event quota is exhausted.
Headers
Every response includes:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 98
X-RateLimit-Reset: 1745327400X-RateLimit-Limit is your bucket's per-minute limit (here, 100 on the
Starter plan). X-RateLimit-Reset is a Unix epoch-seconds timestamp
marking the end of the current window. Use them to pace yourself before a
429 fires.