# Rate limits

/track and /engage are rate limited to keep the API healthy for everyone. Limits are enforced at two levels: per IP address and per project. Exceeding either returns 429 with a Retry-After header.

## Limits

| Limit                  | Default                     | Keyed by          |
| ---------------------- | --------------------------- | ----------------- |
| Per-IP rate limit      | 100 requests / 10 seconds   | Client IP address |
| Per-project rate limit | 1,000 requests / 10 seconds | Project token     |

Whichever limit is hit first returns the `429`. Both apply to all three
ingestion endpoints - `/track`, `/engage` and `/alias`. The `/api/query/*`
analytical endpoints have a separate limit - see [Queries](/api/queries).

## What a 429 looks like

When a request is rate limited, the whole batch is rejected - nothing in it
is inserted - so it is always safe to retry the exact same payload. Every
`429` carries a `Retry-After` header: seconds to wait, an integer of at
least 1 (fixed at `3600` for the monthly quota case below).

```bash
curl -i -X POST https://cohorly-service.velloalabs.com/track \
  -H "Content-Type: application/json" \
  -H "X-Cohorly-Token: YOUR_PROJECT_TOKEN" \
  -d '{ "event": "Signed Up", "properties": { "distinct_id": "user_123" } }'
```

```
HTTP/1.1 429 Too Many Requests
Retry-After: 8
Content-Type: application/json

{ "status": 0, "error": "rate limited" }
```

## Monthly event quota

Separately from the rate limit, each plan has a monthly event quota. If a
`/track` batch would push your project's account over its quota for the
current calendar month, the whole batch is rejected with `429` and a fixed
`Retry-After: 3600`. `/engage` and `/alias` are not counted against the
quota.

```json
{ "status": 0, "error": "monthly event quota exceeded" }
```

Check your plan's event allowance on your account's usage page (`GET
/api/usage`). As with rate limiting, the batch is rejected atomically, so it
is safe to retry later.

## Recommended client behavior

On any `429`, back off exponentially and retry rather than dropping the
batch:

- Respect the `Retry-After` header when present - wait at least that many
  seconds before retrying.
- Otherwise, use exponential backoff (for example starting at 2 seconds,
  doubling on each attempt, capped at a reasonable maximum) with a little
  random jitter to avoid retry storms.
- Keep queuing new events locally while backing off - a rate limit is
  transient, not a rejection of your data.

```js
async function sendWithBackoff(body, attempt = 0) {
  const res = await fetch("https://cohorly-service.velloalabs.com/track", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Cohorly-Token": "YOUR_PROJECT_TOKEN",
    },
    body: JSON.stringify(body),
  });

  if (res.status === 429) {
    const retryAfterSec = Number(res.headers.get("Retry-After")) || 2 ** attempt;
    await new Promise((r) => setTimeout(r, retryAfterSec * 1000));
    return sendWithBackoff(body, attempt + 1);
  }

  return res;
}
```

All official SDKs (Web, React, Next.js, React Native, iOS) handle this
automatically: events are queued locally and retried with exponential
backoff on `429`, respecting `Retry-After`. You do not need to implement
any of this yourself when using an SDK. See the [SDK docs](/sdks/web) for
details.
