The error envelope
Every non-2xx response has this shape:
{
"error": {
"code": "VALIDATION_FAILED",
"message": "firstName is required",
"details": { "issues": ["..."] }
},
"meta": {
"requestId": "req_...",
"version": "v1",
"timestamp": "2026-09-21T12:00:00.000Z",
"rateLimit": { "limit": 5000, "remaining": 4999, "reset": 1758456000 }
}
}
error.details is optional and, when present, usually carries validation
issue details. meta.rateLimit is included on every response, success or
error, so you can track your remaining quota without a separate call.
Every error code
This table is generated from OrgApiErrorCode in
apps/web/src/lib/org-api/responses.ts; it can't drift out of sync with
the API. It's also published on its own at
Reference → Error codes.
| Code | HTTP status | Meaning |
|---|---|---|
| MISSING_API_KEY | 401 | No Authorization/x-api-key header supplied. |
| INVALID_API_KEY | 401 | Key malformed or no bcrypt match. |
| REVOKED_API_KEY | 401 | Key revoked, outside rotation grace window. |
| EXPIRED_API_KEY | 401 | Key is past its expiresAt. |
| IP_NOT_ALLOWED | 403 | Caller IP isn't in the key's IP allowlist. |
| BILLING_ACCESS_DENIED | 402 | Org's billing/access level blocks this route. |
| INSUFFICIENT_SCOPE | 403 | Key is missing a required scope for this operation. |
| FORBIDDEN | 403 | Generic authorization failure (e.g. author-only edit). |
| RATE_LIMITED | 429 | Rate-limit bucket exceeded; see Retry-After header. |
| QUOTA_EXCEEDED | 402 | A route-specific quota check failed. |
| VALIDATION_FAILED | 400 | Request body failed Zod/shape validation. |
| NOT_FOUND | 404 | Resource not found, or belongs to another organization. |
| CONFLICT | 409 | Generic conflict with existing state. |
| IDEMPOTENCY_REQUIRED | 400 | Idempotency-Key header missing on a mutating request that requires it. |
| IDEMPOTENCY_CONFLICT | 409 | Same Idempotency-Key replayed with a different request body. |
| OWNERSHIP_REQUIRED | 400 | Missing or invalid owner assignment on a created resource. |
| EXTERNAL_REFERENCE_CONFLICT | 409 | An External Reference tuple already points at a different resource. |
| SEAT_LIMIT_REACHED | 409 | Creating a schedulable team member would exceed the included technician seats; requires an interactive org-admin action. |
| AURORA_IDENTIFIERS_REQUIRED | 400 | Aurora sync request is missing design/project identifiers. |
| CUSTOM_FIELD_VALIDATION_FAILED | 400 | customFields payload fails the tenant Field contract. |
| UNSUPPORTED_PROJECT_TEMPLATE | 400 | Template can't safely apply (e.g. FROM_QUOTE-only fields). |
| UNSUPPORTED_SORT | 400 | Invalid sortBy/sortDir combination. |
| UNSUPPORTED_FILTER | 400 | Invalid or unsupported query filter. |
| INTERNAL_ERROR | 500 | Uncaught exception — treat as a solarOS bug, retry with backoff. |
Rate limit headers
Every response that has rate-limit info carries three headers, set
alongside meta.rateLimit:
X-RateLimit-Limit: the request cap for the current window.X-RateLimit-Remaining: requests left in the current window.X-RateLimit-Reset: when the window resets (Unix timestamp, seconds).
A 429 RATE_LIMITED response additionally carries Retry-After (seconds
to wait before retrying), and its error message states the same number:
Rate limit exceeded. Retry after <n> seconds.
IDEMPOTENCY_CONFLICT vs. a safe retry
Most POST operations require an Idempotency-Key header: a string you
generate per logical operation (e.g. derived from the source record's ID).
What happens when you send the same key again depends on whether the
request body matches:
- Same key, same body → solarOS recognizes the replay and returns the
original response again (with an
Idempotency-Replayed: trueheader). This is the safe case: retry a timed-out request with the exact same key and body, and you'll never create a duplicate. - Same key, different body →
409 IDEMPOTENCY_CONFLICT. This means you reused a key for what solarOS sees as a different logical request. Don't retry as-is; generate a new key for the new body, or investigate why the same key was reused with different data (a common cause: a script deriving the key from a timestamp instead of a stable source-record ID).
Idempotency-Key protects against accidental duplicate requests (safe
retries). It's a separate mechanism from External References
(externalReferences on create calls, and the /external-references/*
endpoints), which is how you reconcile solarOS records against IDs from
another system across separate calls. See
Migrate from another CRM for both
used together.