Skip to main content

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

StatusMeaningWhat to do
200Success
201Created — API key creation
202Accepted — a generation job is queuedPoll the job
204Success, empty body — read-all, some deletesDo not parse the body
307Redirect from a missing trailing slashAdd the slash; see below
401Credential missing, malformed, or expiredRefresh the token or check the API key. Do not retry the same credential
402Not enough creditsTop up. Retrying will not help
403Credential is valid but not permitted hereWrong role — either the JWT role is not authenticated, or you have viewer access where member is required
404Not found, or not yoursUnknown id, or a resource belonging to another user. These are deliberately indistinguishable
413Request body too largeOver ~100 MB on a file upload (returned by Cloudflare as HTML, not JSON), or 10 MB of text on ingest-text
422Request body failed validationFix the request
429Rate limit exceededOnly on the two ingestion routes. Honour Retry-After
5xxServer-side failureRetry 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 role claim is not authenticated (most often a refresh token used as an access token), and a viewer-role user attempting to generate.
A 401 saying Missing Bearer token when you sent an API key

This 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:

  1. A 402 means no job was created and nothing was spent.
  2. 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.

POST bodies and 307

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

EndpointLimit
POST /api/notebooks/{id}/files/ingest-text120 requests/hour (default)
POST /api/notebooks/{id}/files/enrich-search120 requests/hour (default)
Everything elseNo 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:

HeaderMeaning
Retry-AfterSeconds until the bucket resets
X-RateLimit-LimitThe cap in force
X-RateLimit-Remaining0 on a breach
X-RateLimit-ResetUnix 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.

These headers appear only on a 429

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

BoundEffect
CreditsThe real quota on generation. Running out gives a 402, not a 429
Upstream providersA 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 ingestingest-text caps the text field at 10 MB of UTF-8
Artifact historyThe artifacts list is capped at 50 rows with no pagination
Generation is uncapped — be your own governor

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

OperationSafe to retry?
Any GETYes. All reads are idempotent
POST /generateNo. Each call charges credits and creates a new job. On a timeout, poll the artifacts list before resubmitting
File uploadNo. A retry creates a duplicate source
DELETEYes. Deleting an already-deleted resource returns 404, which is a safe no-op
POST /api-keysYes, 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.