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

LimitDefaultKeyed by
Per-IP rate limit100 requests / 10 secondsClient IP address
Per-project rate limit1,000 requests / 10 secondsProject 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).

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

PreviousIngestion
NextAdmin API