# Rate limits

> Per-principal plan-tier quotas, the 429 response shape, and how to back off gracefully.

Source: https://docs.hatched.live/docs/reference/rate-limits

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

```ts
new HatchedClient({
  apiKey: process.env.HATCHED_API_KEY!,
  maxRetries: 5, // default 3
});
```

## Backoff pattern

```ts
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:

1. Group up to **100 events per request** (the batch cap).
2. Each call processes **synchronously** and returns per-event results
   inline (see [HTTP API](/docs/reference/http-api)).
3. 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. A `402` (`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: 1745327400
```

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