Server API conventions
Conventions shared by every endpoint in the server-api tree. Per-endpoint pages link back here rather than restating these.
Base URL and versioning
All endpoints are rooted at /api/v1. The version number is part of the URL path. No version negotiation via headers is supported — clients that need a different version must update their base URL.
https://<your-exd-host>/api/v1/...
All request and response bodies are JSON unless otherwise noted (manifest upload and download use multipart/form-data and application/octet-stream respectively). The Content-Type: application/json header must be set on all JSON request bodies.
Authentication
Every API request carries a bearer credential in the Authorization header. The credential may be a human session token issued after tenant login or a service token issued through the token-management API. See tokens for credential format and lifecycle; access-control for the per-endpoint permission model.
Authorization: Bearer exd_write_7gHkLmN3pQrStUvWxYz2AbCdEfGhJkLmNpQr3s
Human tenant roles
Human users authenticate through a tenant. A tenant is mapped either to an SSO provider or to a verified email domain.
| Role | Scope | Permissions |
|---|---|---|
tenant_admin | One tenant | Full access to all flag namespaces in the tenant. |
namespace_admin | One flag namespace | Full access inside that flag namespace. |
For email-domain tenants, every verified user in the tenant is a tenant admin. For SSO tenants, tenant-admin status is configured explicitly or derived from trusted SSO claims.
Service tokens
| Type | Prefix | Binding |
|---|---|---|
namespace-write | exd_write_ | One flag namespace |
namespace-read | exd_read_ | One flag namespace |
namespace-client | exd_client_ | One flag namespace + one environment (public-by-design) |
tenant-admin | exd_tenant_ | One tenant |
superadmin | exd_admin_ | Installation |
Attempting to use a token outside its bound scope returns 403 Forbidden (forbidden) or 404 Not Found where the resource-visibility rules require resource hiding.
Error response format
All error responses use a consistent JSON envelope:
{
"error": {
"code": "manifest_lint_failed",
"message": "Manifest validation failed with 2 errors",
"request_id": "01HV5XK2GFQT8N3JRDCP7MW04B",
"details": {
"error_count": 2,
"warning_count": 1,
"errors": [
{
"code": "E006",
"severity": "error",
"file": "segments/checkout-redesign-rollout-10.toml",
"line": 34,
"message": "Bucket range must satisfy 0 <= start <= end <= 9999"
}
]
}
}
}
request_id is a ULID present on every response — including successes, in the X-Request-Id header. Include it when reporting issues.
Error codes
| Code | HTTP status | Description |
|---|---|---|
manifest_lint_failed | 422 | The uploaded manifest archive failed lint validation. details carries the full lint report. Upload rejected. |
version_conflict | 409 | If-Version was supplied and the current server version does not match. Fetch the latest version and retry. |
namespace_not_found | 404 | No flag namespace exists with the given slug under the path's tenant. |
tenant_not_found | 404 | No tenant exists with the given slug. |
flag_not_found | 404 | The requested flag key does not exist in the flag namespace's current manifest. |
segment_not_found | 404 | The requested segment key does not exist in the flag namespace's current manifest. |
unauthorized | 401 | The bearer credential is missing, malformed, expired, revoked, or otherwise cannot authenticate a principal. |
forbidden | 403 | The principal is authenticated but lacks the required permission. |
invalid_request | 400 | The request body or query parameters failed validation. details contains field-level error messages. |
schema_version_mismatch | 422 | One or more files in the uploaded manifest declare a schema_version whose major component differs from the server's current supported major. |
archive_too_large | 413 | The uploaded tar.gz archive exceeds the flag-namespace size limit (default: 50 MB uncompressed). |
invalid_subscription | 400 | The subscription on GET /api/v1/events is malformed or exceeds documented caps. details carries reason (too_many_namespaces, too_many_flags, unknown_namespace, unknown_flag, malformed) and, where applicable, namespace and flag. |
closure_token_invalid | 401 | The signed token on GET .../closure is missing, expired, malformed, or does not match the (principal, tenant, namespace, version, subscription) tuple it was issued for. Reconnect to the event stream to obtain a fresh token. |
closure_unavailable | 410 | The closure for (version, subscription) is no longer reconstructable — the requested version has been pruned past the retention horizon. Reconnect to the event stream and treat the next event as a fresh subscription. |
rate_limit_exceeded | 429 | The request rate limit has been exceeded. See X-RateLimit-Reset for when the limit resets. |
internal_error | 500 | Unexpected server-side error. Include the request_id in any bug report. |
Pagination
All list endpoints support cursor-based pagination. Cursor-based pagination is preferred over offset-based because it is stable under concurrent writes — inserting a new flag namespace between two pages does not cause items to be skipped or duplicated.
Query parameters
| Parameter | Default | Description |
|---|---|---|
limit | 50 | Items per page. Maximum 200. |
after | — | Opaque cursor string from the previous response's next_cursor. Omit for the first page. |
Response fields
Every list response includes:
| Field | Description |
|---|---|
next_cursor | Opaque string to pass as after on the next request. null if there are no more pages. |
Example
# First page
curl "https://exd.example.com/api/v1/namespaces?limit=2" \
-H "Authorization: Bearer exd_admin_2mNpQrStUvWxYzAbCdEfGhJkLmNoPqRsTuVwXy"
# Response includes: "next_cursor": "01HV5XKCZAQT8N3JRDCP7MW04L"
# Second page
curl "https://exd.example.com/api/v1/namespaces?limit=2&after=01HV5XKCZAQT8N3JRDCP7MW04L" \
-H "Authorization: Bearer exd_admin_2mNpQrStUvWxYzAbCdEfGhJkLmNoPqRsTuVwXy"
Cursors are opaque and must NOT be parsed or constructed by clients. Cursors are valid for 24 hours; stale cursors return invalid_request with the message cursor_expired.
Rate limits
All responses include rate-limit headers:
| Header | Description |
|---|---|
X-RateLimit-Limit | Maximum requests allowed in the current window. |
X-RateLimit-Remaining | Requests remaining in the current window. |
X-RateLimit-Reset | Unix timestamp (seconds) at which the current window resets. |
Exceeding the limit returns 429 Too Many Requests (rate_limit_exceeded). Retry-After is set to the number of seconds until the limit resets.
Limits by endpoint category
| Category | Limit | Window | Scope |
|---|---|---|---|
Evaluate, authenticated callers (namespace-read, namespace-write, admin tokens, human sessions) | 10,000 | 1 minute | Per flag namespace |
Evaluate, namespace-client callers | 600 | 1 minute | Per (namespace, environment, Origin) |
Evaluate, namespace-client callers without Origin (native callers) | 60 | 1 minute | Per (namespace, environment, source IP) |
Upload (PUT /manifest) | 60 | 1 minute | Per flag namespace |
Lint (POST /manifest/lint) | 120 | 1 minute | Per flag namespace |
| Namespace management | 300 | 1 minute | Per token |
| Tenant management | 300 | 1 minute | Per token |
| Manifest download | 600 | 1 minute | Per flag namespace |
Snapshot (GET /manifest/snapshot) | 30 | 1 minute | Global for superadmin; per tenant for tenant-limited |
Event stream (GET /events) | 16 concurrent connections | — | Per token |
Closure (GET .../closure) | 600 | 1 minute | Per (token, tenant, namespace) |
Rate limits are enforced using a sliding-window algorithm. Limits apply to authenticated requests; unauthenticated requests are rejected before reaching rate-limit accounting. The namespace-client per-Origin scope is intentionally narrower than the per-namespace authenticated scope: a single flag namespace running both server-side and browser evaluation has effectively independent quotas for the two modes, and a single misbehaving Origin cannot exhaust the namespace's headroom for legitimate authenticated callers.
Higher throughput? If your use case requires more than these per-namespace limits, prefer the client-side SDK (manifest distribution mode), which downloads the manifest locally and evaluates in-process with zero network latency and no server-side rate limit. Browser deployments that exceed the
namespace-clientper-Origin quota should batch via/evaluate/allon page load and cache results client-side rather than re-evaluating per interaction.
See also
- README — server-api index by route family.
- access-control — permission model behind every endpoint.
- tokens — credential format and lifecycle.