Errors, limits and stability
Everything your integration needs in order to fail well: the shape of an error, the codes you can branch on, the headers that let you pace yourself, and what we promise about changes.
This page covers the three visual endpoints: POST /v1/visual and the status and download endpoints.
The OAuth endpoints under /v1/oauth are different: their own handlers follow RFC 6749 and answer error
with error_description, so read them as an OAuth client would. Four refusals are shared with every other
endpoint and reach an OAuth caller in the envelope below instead — an oversized body, a rejected origin, a
path that routes nowhere, and a method an endpoint does not serve. See
OAuth user authentication.
The error body
Every failed call to a visual endpoint answers application/json with the same two fields.
{
"error": "validation_failed",
"message": "validation failed: format is required; content is required"
}
erroris the machine-readable code. Branch on this.messageis for a human. It is not stable; do not parse it, match on it, or show it to an end user as-is without checking it reads well in your product.
A code is part of the contract once published: we may reword a message at any time, and we will not change the meaning of a code or silently drop one. We may add new codes, so treat a code you do not recognise as its HTTP status class and carry on.
Some failures add fields to the envelope. A 429 carries the limit that was hit; see
Rate limits below.
Error codes
| Code | Status | What happened | What to do |
|---|---|---|---|
invalid_request | 400 | The body is not readable JSON. | Fix the request. Retrying is pointless. |
validation_failed | 400 | The body parsed, but a field is missing or out of range. message lists every field at fault. | Fix the request. Retrying is pointless. |
invalid_request_id | 400 | A path parameter is not the identifier form the route expects. | Use the id returned by POST /v1/visual, and a file_id taken from generated_files. |
request_too_large | 413 | The request body exceeds the accepted size. | Split the content into smaller requests. |
method_not_allowed | 405 | The path exists and does not answer that method. | Check the method against the API reference. |
origin_not_allowed | 403 | The browser origin is not one this API answers. | Call the API from your server, not from a browser page. |
missing_authorization | 403 | No usable Authorization header was sent. | Send Authorization: Bearer YOUR_TOKEN. |
invalid_token | 403 | The bearer token is not one this API accepts. | Check the token, and that you are calling the right environment. |
token_expired | 401 | An OAuth access token is past its expiry. | Refresh with the refresh_token grant, then retry once. |
token_revoked | 401 | The token was revoked. | Mint a new token. Retrying the same one never succeeds. |
insufficient_scope | 403 | The OAuth grant does not carry the scope this route needs. | Ask for the scope at authorization time. |
forbidden | 403 | The resource exists and belongs to another caller. | Use a request id your own token created. |
not_found | 404 | The request, or that file of it, does not exist. | Check the id. A request that never completed has no files yet. A file that once existed may have expired. |
request_expired | 410 | The request completed more than 30 minutes ago. Its status and files are gone. | Create a new request. Download files as soon as a request completes. |
no_credits | 402 | The account has no credits left. | Top up or upgrade. Retrying without doing so never succeeds. |
rate_limit_exceeded | 429 | One of your windows is full. | Wait Retry-After seconds, then retry. See below. |
generation_limit_exceeded | 429 | The account's lifetime generation allowance is spent. | Contact us. Waiting does not help. |
too_many_failed_attempts | 429 | Repeated bad credentials from your address are being refused. | Stop retrying, fix the token, and come back after a pause. |
internal_error | 500 | The call failed on our side. | Retry with exponential backoff. If it persists, send us the request id. |
service_unavailable | 503 | A dependency is down or slow — the database, the queue, or the storage a download reads from. | Retry with exponential backoff. A download that answers this has not lost your file. |
Which failures are worth retrying
Retry internal_error, service_unavailable, rate_limit_exceeded and token_expired. Every other code
describes something that will be true again on the next call, so a retry loop around it only burns your
rate limit.
Rate limits
Limits are enforced per second, per minute, per hour and per day, and the numbers depend on your plan, so
the reliable way to know yours is to read them off a response rather than hard-code a figure.
POST /v1/visual carries them on any answer that reached its limiter:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Requests allowed in the window named by X-RateLimit-Window. |
X-RateLimit-Remaining | Requests left in that window. |
X-RateLimit-Reset | Unix timestamp when that window resets. |
X-RateLimit-Window | Which window the two counts above describe: second, minute, hour or day. |
Retry-After | Seconds to wait. Sent only with a rate_limit_exceeded, not with the other two 429 codes. |
A refusal that answers before the limiter carries none of them: 403 for a credential or an origin, 405
for the wrong method, 413 for a body that declared itself oversized, and the 429 for repeated bad
credentials all return earlier in the chain. Read the headers where they are present rather than requiring
them.
All of these are listed in Access-Control-Expose-Headers, so a call from an allowed browser origin can
read them too, alongside X-API-Version.
X-RateLimit-Window names the window with the fewest requests left in absolute terms, not the one
nearest its own limit proportionally. Because the per-second allowance is the smallest number on every
tier, a successful call usually reports second even when your day quota is nearly gone. So read these
headers as "you may send this many right now", which is what they reliably tell you, and do not read them
as naming the quota that will stop you next. To know how much of your daily budget is left, count your own
calls; the headers will not tell you until that window is the smallest one remaining.
Whichever window is reported, X-RateLimit-Remaining and X-RateLimit-Reset describe that same window, so
pacing on the pair is always correct for the next few requests.
If the limiter itself cannot be reached, the call is let through and the headers read
X-RateLimit-Window: fallback with zeroes. That means no reading is available, not that your limit is zero.
A 429 from the rate limiter looks like this:
{
"error": "rate_limit_exceeded",
"message": "Too many requests. Limit: 10 per minute. Try again in 45 seconds.",
"limit": 10,
"window": "minute",
"reset_time": 1704067260,
"retry_after": 45
}
The other two 429 codes are not the same thing and are not worth waiting out:
generation_limit_exceeded carries limit and used and means the account's lifetime allowance is spent,
and too_many_failed_attempts means bad credentials are being refused. Branch on error rather than on
the status.
The status and download endpoints are not rate limited, so polling a request costs you nothing against your
generation quota, and they send no rate limit headers. The only 429 they answer is
too_many_failed_attempts, which carries no Retry-After and none of the window fields. Poll with
exponential backoff anyway: most requests finish in 10 to 30 seconds.
Need more headroom? Contact us — higher limits and volume pricing are what custom plans are for.
Errors inside a completed request
The codes above describe a call that failed. A call can also succeed while the generation behind it does
not: POST /v1/visual returns 201, and the status endpoint later reports status: failed with an
error object, or status: completed with warnings.
| Code | Kind | Meaning |
|---|---|---|
no_credits | error | The account ran out of credits while the request was being processed. |
no_visuals | error | Nothing could be generated from this content. Simplify it, or drop visual_ids. |
upstream_unavailable | error | Generation was temporarily unavailable on our side. The content was fine; retry the same request. |
not_enough_visuals | warning | Fewer visuals than you asked for. The ones that worked are in generated_files. |
missing_visual_ids | warning | Some requested layouts did not fit the content. |
some_visuals_failed_orientation_control | warning | Some visuals missed the orientation constraint. They are returned last. |
invalid_style_id | warning | The style was not found; the default style was used. |
visual_id_with_random_sort | warning | sort_strategy: random was ignored because specific visual_ids were requested. |
A warning is not a failure: files are still there, and the request is still billed for what it produced.
A status: failed with no error object means the failure was ours and does not map to any of the codes
above. Retry once; if it happens again, send us the request id.
What we promise about changes
The API is generally available and supported for production use. We have not committed to a formal versioning scheme yet, so rather than point you at a rule, here is what we actually do:
- Additive changes ship without warning. New fields on a response, new optional request fields, new error codes, new styles. Write your client so an unknown field or code is ignored rather than fatal — this is the single thing that will keep your integration working through everything below.
- Breaking changes are announced in the changelog with the version they land in, and
we mail integrators we know about. Read
X-API-Versionon any response to see which version answered it. - Deprecated fields keep working until a version says otherwise.
context_before,context_afterandinverted_colorare deprecated today and still answered. - The error codes on this page are stable. A message may be reworded at any time; a published code will not change meaning or quietly disappear.
- Availability is not guaranteed. Production support means we run the API as a product and take its failures seriously, not that we promise uptime. The Terms of Service provide it as-is without an uptime guarantee, and interruptions can happen without notice. Design for that: retry the codes worth retrying, and cache what you have already generated.
If you need an availability or support commitment, talk to us.
The terms that govern your use of the API are in the Terms of Service.
Getting help
Send us the request id and the error code — those two together are usually enough to find what
happened. api@napkin.ai.