Errors
Status codes, error shapes, and what to do about each one.
Successful responses are wrapped in { "success": true, "data": … }. Errors are not — they come back in one of three shapes.
Shapes
Gate errors
Raised by the subscription and rate-limit gates before the endpoint runs. These carry a stable code worth branching on.
{
"error": "Rate limit exceeded",
"message": "You have exceeded the minute rate limit for this endpoint.",
"code": "RATE_LIMIT_EXCEEDED",
"retry_after": 42
}
| Code | Status | Meaning |
|---|---|---|
UNAUTHENTICATED | 401 | No user could be resolved for the request |
NO_ACTIVE_SUBSCRIPTION | 402 | The account has no plan |
RATE_LIMIT_EXCEEDED | 429 | A minute, hour, or day window is full |
Validation errors
Raised when the request body fails validation. Status is always 422, and errors maps each rejected field to its messages.
{
"message": "The given data was invalid.",
"errors": {
"title": ["Article title must be at least 3 characters."],
"target_word_count": ["Target word count must not exceed 10,000 words."]
}
}
The messages are written for humans and can be surfaced directly in a UI.
Plain errors
Authentication and lookup failures return only a message.
{
"message": "Unauthenticated."
}
Status reference
| Status | When | Retry? |
|---|---|---|
401 | Missing, malformed, expired, or revoked key; creator no longer an account member | No — fix the key |
402 | Account has no active subscription | No — subscribe |
403 | Not authorised for that project, or the user account is disabled | No |
404 | Article, project, or workflow does not exist, or is not visible to this account | No |
422 | Validation failed, or the monthly word quota cannot cover the article | No — change the request |
429 | Rate limit exceeded | Yes, after retry_after |
500 | Unexpected server error | Yes, with backoff |
404 is also the answer when a record exists but belongs to another account. Existence is never confirmed across account boundaries.
Cases worth handling explicitly
Out of words. Article creation reserves 120% of target_word_count against the monthly allowance. When it does not fit, the failure arrives as a 422 on target_word_count with a message stating remaining and required words. It is not a validation mistake — the request would succeed next month, or with a smaller target. See Rate limits & quotas.
Unknown article type. article_type accepts a built-in type or a custom workflow type defined in that project. A custom type that exists in a different project fails validation here.
Research not supported. Setting research_enabled: true on an article type whose workflow has no research step is rejected rather than silently ignored.
A 404 on a workflow. Workflow routes are nested under a project; a step that exists but belongs to another project returns 404, not 403.
Failures after 201
POST /v1/articles returns 201 when the job is queued, not when the article is written. Anything that goes wrong afterwards shows up as status: "failed" on the article rather than as an HTTP error — it will never reach a webhook. Poll or watch for that status; see Article lifecycle.
Retry policy
async function withRetry(fn, attempts = 4) {
for (let i = 0; i < attempts; i++) {
const res = await fn()
if (res.status !== 429 && res.status < 500) return res
const body = res.status === 429 ? await res.clone().json() : null
const wait = body?.retry_after ?? 2 ** i
await new Promise((r) => setTimeout(r, wait * 1000))
}
throw new Error('exhausted retries')
}
Retry 429 and 5xx. Everything else is a request that needs changing, and repeating it only spends quota.