Errors & rate limits
The error envelope, every status code, and how rate limiting behaves.
Error envelope
Every error response has the same shape:
{ "error": { "code": "FORBIDDEN", "message": "Access denied by policy" } }The code is stable and machine-readable; the message is for humans and may change.
Gateway status codes
| Status | code | When |
|---|---|---|
| 400 | VALIDATION_ERROR | Missing/invalid params — e.g. no connectionId/accountId, or no sender domain. |
| 401 | UNAUTHORIZED | Missing or invalid API key. |
| 403 | FORBIDDEN | The grant doesn't allow this call, or it's outside its schedule. |
| 403 | NO_ACTIVE_GRANT | The accountId has no active grant — the user revoked it, or it expired. Sever local state and re-run Connect (you also received a connection.revoked webhook). |
| 404 | NOT_FOUND | The connection doesn't exist. |
| 429 | RATE_LIMITED | The grant's daily limit is exhausted. |
| 501 | NOT_IMPLEMENTED | Not available yet (attachment download). |
| 503 | ENGINE_ERROR | The permission engine hit a malformed rule (fail-closed). |
| 503 | PROVIDER_ERROR | The upstream mail provider failed (fail-closed). |
Connect token codes
Returned by exchangeConnectCode (POST /connect/token):
| Status | code | When |
|---|---|---|
| 400 | INVALID_GRANT | The code is expired (5-min TTL) or already used. |
| 401 | INVALID_CLIENT | Bad client_id / client_secret. |
SDK error classes
The SDK throws typed errors so you can branch with instanceof instead of inspecting codes:
| Class | Maps to | code |
|---|---|---|
ThreadValidationError | 400 | VALIDATION_ERROR |
ThreadAuthError | 401 | UNAUTHORIZED |
ThreadDeniedError | 403 | FORBIDDEN or NO_ACTIVE_GRANT (check .code) |
ThreadNotFoundError | 404 | NOT_FOUND |
ThreadRateLimitedError | 429 | RATE_LIMITED |
ThreadProviderError | 503 | PROVIDER_ERROR |
ThreadWebhookSignatureError | 401 | INVALID_SIGNATURE |
ThreadError | base class | — (.code, .status) |
A 501 NOT_IMPLEMENTED (and any unrecognized status) throws the base ThreadError, with
.code and .status set from the response.
import { ThreadDeniedError, ThreadRateLimitedError, ThreadError } from '@jointhread/sdk';
try {
await thread.messages.list({ accountId, fromDomain: 'amazon.com' });
} catch (err) {
if (err instanceof ThreadDeniedError) {
if (err.code === 'NO_ACTIVE_GRANT') {
// Revoked or expired — sever local state for this accountId and offer reconnect.
}
// FORBIDDEN: the grant exists but doesn't cover this read (scope/schedule).
} else if (err instanceof ThreadRateLimitedError) {
// The grant's daily cap is exhausted — back off until the next UTC day.
} else if (err instanceof ThreadError) {
console.error(err.code, err.status);
}
}Rate limits
A user can cap a grant at N requests per day (rateLimitPerDay). When the cap is reached,
calls return 429 RATE_LIMITED until the next UTC day.
Rate limiting is per-grant and set by the user, not a fixed platform quota. Treat a 429 as a
"come back later" signal — back off and retry after the UTC day rolls over, and surface a
gentle "access paused" state rather than failing hard.
Fail-closed by design
When Thread can't be sure a request is allowed — a malformed rule, a provider outage — it
denies rather than guessing (503 ENGINE_ERROR / PROVIDER_ERROR). Retry 503s with backoff;
never treat them as permission to proceed.