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.
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).
bashcurl -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 RequestsRetry-After: 8Content-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-Afterheader 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.
jsasync 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;}