Rate Limits & Errors
MemorySync enforces rate limits per API key, not per plan. When you create a key in Settings → API Keys, you pick one of four preset tiers or define your own. Each tier is a three-layer budget — per second, per minute, per hour — enforced atomically by a sliding-window limiter. Dashboard sessions and unauthenticated callers are limited separately, by IP and user.
Per-key tiers
Pick a tier when creating or editing an API key. The same numbers appear in the dashboard preset menu — they are the source of truth and applied to every request that key makes, regardless of route.
| Tier | Per second | Per minute | Per hour |
|---|---|---|---|
free | 2 | 30 | 100 |
pro | 10 | 200 | 5,000 |
enterprise | 50 | 1,000 | 50,000 |
custom | (set by caller) | (set by caller) | (set by caller) |
unlimited | — | — | — |
How enforcement works
- Every request with
X-API-Keyis identified as{environment}:apikey:{key_id}. The limiter looks up the key row, resolves its tier (or custom limits), and runs an atomic sliding-window check against the cache. - When any of the three layers is exceeded the gateway returns
429witherror.code = "RATE_LIMIT_EXCEEDED",blocked_byset to the layer that tripped, and aRetry-Afterheader. - Custom limits are stored on the key as
rate_limit_per_secondandrate_limit_per_minute; the per-hour value is taken from the legacyrate_limitcolumn. - Rotating a key keeps the tier; revoking a key voids its budget immediately.
Limits for non-key traffic
| Caller | Limit | Identifier |
|---|---|---|
| Dashboard session (JWT) | 20 / sec, 500 / min, 5,000 / hour | user:{user_id} |
| Authenticated user (non-dashboard) | 10 / sec, 200 / min, 2,000 / hour | user:{user_id} |
| Anonymous (per IP) | 10 / sec, 200 / min, 2,000 / hour | ip:{address} |
| Docs & health probes | 5 / sec, 60 / min, 600 / hour | ip:{address} |
| SCIM provisioning | 30 / sec, 1,000 / min, 50,000 / hour | scim:{api_key_or_ip} |
Per-route overrides
A small number of unauthenticated routes have their own per-IP caps that take precedence over the defaults above:
| Route | Limit |
|---|---|
POST /auth/login | 10 / minute / IP |
POST /auth/signup | 5 / minute / IP |
POST /auth/refresh | 60 / hour / principal |
Response headers
X-RateLimit-Limit— the most-restrictive bucket size for the key.X-RateLimit-Remaining— tokens left in the current window.X-RateLimit-Reset— unix seconds when the bucket refills.X-RateLimit-Per-Second-{Limit,Remaining,Reset},-Per-Minute-*,-Per-Hour-*— per-layer breakdown so clients can see exactly which window will free up next.Retry-After— seconds to wait, present on every429.
Quota vs rate limit
Rate limits cap request throughput per key. Plan quotas cap monthly memory writes and queries on the organisation as a whole — they are enforced inside POST /memory/add and POST /memory/query after the rate-limit check. When a quota is exhausted, the API uses silent degradation: POST /memory/add returns 200 with {"status":"ok"} and the request is accepted but the memory is not stored; POST /memory/query returns 200 with {"memories":[]}. No error is surfaced to the caller. Watch the usage dashboard for metrics approaching 100% of your plan limit; webhook and email alerts can also be configured for at-quota and approaching-quota thresholds.
Error envelope
| Field | Notes |
|---|---|
error.code | Stable machine-readable string. Branch on this in code. |
error.message | Human-readable explanation. Do not branch on this. |
error.details | Object with structured context (validation field, missing scope, blocked layer, …). |
error.request_id | Trace id. Quote this in support tickets. |
error.timestamp | RFC 3339 UTC. |
{
"error": {
"code": "validation_error",
"message": "field 'text' is required",
"details": { "field": "text" },
"request_id": "req_01HX…",
"timestamp": "2026-05-04T12:30:11Z"
}
}Common error codes
| Status | Code | Meaning |
|---|---|---|
400 | validation_error | Request body or query parameters failed schema validation. |
401 | unauthenticated | Missing or invalid bearer token / API key. |
403 | forbidden | Authenticated principal lacks required scope, role, or project access. |
404 | not_found | Target resource does not exist or is not visible to the caller. |
429 | rate_limited | Per-IP or per-route limit exceeded — see Retry-After. |
500 | internal_error | Unhandled server error. The request_id is logged for support. |
Client strategy
- Always retry
429after theRetry-Afterseconds, never sooner. - Apply full jitter on retries; the official SDKs do this automatically.
- Branch on
error.code, never onerror.message. - Quote
error.request_idin support tickets — it correlates the request across audit, datastore, and worker logs. - Inspect
error.details.blocked_byto see which layer (per-second / per-minute / per-hour) tripped, and back off proportionally.