Errors & Limits
Error shape
Errors come back as JSON with a single detail field. FastAPI produces this for every handled error.
{ "detail": "Insufficient credits" }
Validation errors (422) are the exception — detail is an array describing each field that failed, not a string.
{
"detail": [
{
"type": "missing",
"loc": ["body", "topic"],
"msg": "Field required",
"input": { "template_id": "research_summary" }
}
]
}
Handle both shapes. A client that assumes detail is always a string will render [object Object] at exactly the moment a user most needs to know which field they got wrong.
Status codes
| Status | Meaning | What to do |
|---|---|---|
200 | Success | — |
201 | Created — API key creation | — |
202 | Accepted — a generation job is queued | Poll the job |
204 | Success, empty body — read-all, some deletes | Do not parse the body |
307 | Redirect from a missing trailing slash | Add the slash; see below |
401 | Credential missing, malformed, or expired | Refresh the token or check the API key. Do not retry the same credential |
402 | Not enough credits | Top up. Retrying will not help |
403 | Credential is valid but not permitted here | Wrong role — either the JWT role is not authenticated, or you have viewer access where member is required |
404 | Not found, or not yours | Unknown id, or a resource belonging to another user. These are deliberately indistinguishable |
413 | Request body too large | Over ~100 MB on a file upload (returned by Cloudflare as HTML, not JSON), or 10 MB of text on ingest-text |
422 | Request body failed validation | Fix the request |
429 | Rate limit exceeded | Only on the two ingestion routes. Honour Retry-After |
5xx | Server-side failure | Retry with backoff |
401 versus 403
They are not interchangeable and the distinction is load-bearing:
- 401 — the credential is bad. Get a new one and retry.
- 403 — the credential is good but does not authorize this. Retrying with the same credential will always fail. The two common causes are a JWT whose
roleclaim is notauthenticated(most often a refresh token used as an access token), and a viewer-role user attempting to generate.
Missing Bearer token when you sent an API keyThis does not mean your key is invalid. It means the route is JWT-only and never looked at the X-API-Key header. Only ingest-text, enrich-search, the Sonix routes and the workflow routes accept keys — see Authentication.
File upload (POST /files/, upload-zip, ingest-url, the YouTube route) is the most common place to hit this, and there is no key-based alternative for it.
A genuinely bad key on a key-accepting route reads Invalid or missing API key instead. The wording is the fastest way to tell the two apart.
404 hides authorization failures
Requesting a notebook, artifact, or API key that belongs to someone else returns 404, not 403. This is intentional — it prevents probing for the existence of other users' resources. Do not read a 404 as proof the id does not exist.
402 and the credit charge order
Credits are checked and charged when a generation job is submitted, before any work runs. Two consequences:
- A
402means no job was created and nothing was spent. - A job that reaches
status: "error"has already been charged. There is no automatic refund. A failed generation costs the same as a successful one.
See Models and credit cost for the multipliers that determine what you are charged.
307 redirects on trailing slashes
Several collection routes are registered with a trailing slash: /api/notebooks/, /api/artifacts/, /api/templates/, /api/notifications/. Requesting them without it returns a 307 to the canonical path.
Most clients follow the redirect, so it works. But it doubles your request count, which is worth avoiding on anything you poll. Write the slash.
A 307 preserves the method and body, unlike a 301 or 302. So a POST to a slash-less path is not silently downgraded to a GET — but you still pay the extra round trip.
On a file upload that round trip is the entire body. POST /api/notebooks/{id}/files without the slash re-sends the whole multipart payload to /files/, so a 60 MB upload puts 120 MB on the wire and takes twice as long. It is the usual explanation for an upload that appears to hang. See Adding sources.
Rate limits
Rate limiting is selective, not global. Two ingestion endpoints enforce a request-rate cap; everything else is ungated and bounded only by credits.
Where the limit applies
| Endpoint | Limit |
|---|---|
POST /api/notebooks/{id}/files/ingest-text | 120 requests/hour (default) |
POST /api/notebooks/{id}/files/enrich-search | 120 requests/hour (default) |
| Everything else | No request-rate limit |
The cap is a per-hour token bucket that resets on the hour boundary — not a rolling window. It is configurable server-side, so read the response headers rather than assuming 120.
Buckets are per credential, not per user. If you authenticated with an API key, that key gets its own bucket, so two keys belonging to the same user do not contend. JWT callers share a single per-user bucket.
The 429 response
{
"detail": "Rate limit exceeded: 120 requests per hour. Try again after 2026-07-30T15:00:00+00:00."
}
with these headers:
| Header | Meaning |
|---|---|
Retry-After | Seconds until the bucket resets |
X-RateLimit-Limit | The cap in force |
X-RateLimit-Remaining | 0 on a breach |
X-RateLimit-Reset | Unix timestamp of the reset |
Honour Retry-After rather than backing off blindly — it tells you exactly when the bucket rolls over, and retrying earlier just burns another rejected request.
Successful responses do not carry X-RateLimit-*, so you cannot watch your remaining budget as you go. Track your own request count if you need to stay under the cap.
Everything else that bounds throughput
| Bound | Effect |
|---|---|
| Credits | The real quota on generation. Running out gives a 402, not a 429 |
| Upstream providers | A model or text-to-speech provider can throttle the platform. This surfaces as a job in status: "error" with the provider's message, never as a 429 on your request |
| File upload | ~100 MB in practice — Cloudflare rejects larger bodies at the edge with an HTML 413 before the request reaches the API. The API's own cap is 300 MB and nginx allows 1024 MB, but neither is reachable through the public hostname |
| Raw text ingest | ingest-text caps the text field at 10 MB of UTF-8 |
| Artifact history | The artifacts list is capped at 50 rows with no pagination |
Nothing stops you from submitting a hundred concurrent generation jobs. Each charges credits immediately and competes for the same rendering capacity. Queue your own work and keep concurrency modest.
Timeouts
The nginx layer in front of the API allows 300 seconds for both reading and sending. This is generous for every synchronous endpoint.
Generation is not affected: the submit call returns in well under a second with 202 Accepted, and the rendering happens in the background. The only long-lived connection is the SSE job stream, which terminates itself after 3 minutes — see Tracking a job.
Retrying safely
| Operation | Safe to retry? |
|---|---|
Any GET | Yes. All reads are idempotent |
POST /generate | No. Each call charges credits and creates a new job. On a timeout, poll the artifacts list before resubmitting |
| File upload | No. A retry creates a duplicate source |
DELETE | Yes. Deleting an already-deleted resource returns 404, which is a safe no-op |
POST /api-keys | Yes, but each call mints a new key. Revoke the orphan |
The rule of thumb: retry reads freely, and treat anything that charges credits or creates a source as non-idempotent. When a mutating call times out, check state before retrying rather than assuming it failed.