Errors
One envelope, stable codes, and a link from every failure to the row that explains it. Branch on code, never on the message.
The envelope
Every error response has this shape, without exception:
{
"error": {
"code": "insufficient_credits",
"message": "Account has 0 credits remaining. Current period resets 2026-08-01T00:00:00.000Z.",
"doc_url": "https://paperpony.dev/docs/errors#insufficient_credits",
"request_id": "req_01JZ3M8Q0000000000000000AB"
}
}
codeis stable and machine-readable. Branch on it, never onmessage.messageis written for a human developer: the cause and, where possible, the fix. It never contains the offending payload or any customer data.request_idis also returned as theX-Request-Idheader, on every response. Quote it when reporting a problem.job_idappears when a job row exists for the failed operation, which means on a synchronous render that failed. See "Why a failed render is not a 200" below.
The one exception is GET /v1/health. It is a probe, not an API operation,
and monitoring needs to see which dependency is down, so it answers 200 or
503 with its own shape and never uses the envelope:
{ "status": "ok", "checks": { "postgres": "ok", "redis": "ok", "r2": "ok", "browser_pool": "ok" } }
A rate-limited health request is a real API error and does use the envelope.
Codes
| Code | Status | Cause | Fix |
|---|---|---|---|
unauthorized |
401 | No Authorization header, or it is not a Bearer credential. |
Send Authorization: Bearer pp_live_.... |
invalid_api_key |
401 | The key is malformed, unknown, or revoked. | Check the key. Keys are shown once at creation and are not recoverable, so a lost key is replaced rather than found: ask for another and the old one is revoked. |
account_suspended |
403 | The account may not use the API. | Nothing sets this status today: billing is not built, so the only way to reach it is by hand. The code exists so a client written now already handles it. Write to hello@paperpony.dev if you see it. |
insufficient_credits |
402 | The account has fewer credits than the request needs. | Wait for the period to reset, or write to hello@paperpony.dev for a higher limit. There is nothing to buy during early access. The message states the balance and the reset date. |
rate_limited |
429 | Too many requests for the account's plan. | Back off for the number of seconds in Retry-After. A higher limit is set by hand during early access: write to hello@paperpony.dev. |
invalid_request |
422 | The body is not valid JSON, or fails validation. | Fix the request. The message names the field. |
not_found |
404 | No route matches the path. | Check the method and path against the reference. |
template_not_found |
404 | No template with that ID is readable by this account. | Check the ID. See the note below. |
job_not_found |
404 | No job with that ID is readable by this account. | Check the ID. See the note below. |
payload_too_large |
413 | The body is over the size limit: 5 MB of inline html or template source, 6 MB of request body. |
Send less. Store the document as a template, and reference large assets by URL instead of inlining them. |
page_weight_exceeded |
413 | The document pulled in more than 20 MB of subresources while rendering. | Reduce the images, fonts and stylesheets it references. Distinct from payload_too_large on purpose: that one is about what you sent, this one about what the document fetched, and the fixes differ. |
render_failed |
502 | The renderer could not produce output. | Check the template and the data. The message says what failed. |
render_timeout |
504 | The render exceeded options.timeout_ms. |
Simplify the document, or raise timeout_ms (up to 60000). |
internal_error |
500 | A failure on our side. | Retry. If it persists, write to hello@paperpony.dev with the request_id. |
Rate limits by plan
| Plan | Requests per minute |
|---|---|
| Free | 10 |
| Starter | 60 |
| Growth | 300 |
| Scale | 1,000 |
Limits count per account, not per key. Issuing more keys does not raise
them. GET /v1/health is unauthenticated and is limited to 60 requests per
minute per IP address.
Why a failed render is not a 200
POST /v1/pdf/render answers 200 with the finished job when a synchronous
render succeeds, and 202 with the same object when it is still running. When
it fails, it answers with this envelope rather than a 200 carrying
"status": "failed". Every error in this API has one shape, and a client that
branches on the HTTP status should not have to special-case one endpoint.
Nothing is lost by that. The envelope carries job_id:
{
"error": {
"code": "render_timeout",
"message": "The render exceeded options.timeout_ms (15000 ms). ...",
"doc_url": "https://paperpony.dev/docs/errors#render_timeout",
"request_id": "req_01JZ3M8Q0000000000000000AB",
"job_id": "job_01JZ3M8Q0000000000000000CD"
}
}
GET /v1/jobs/job_01JZ...CD then gives the timings, the page count and the
error detail. Failed renders and timeouts charge no credits.
Why a wrong owner is still a 404
A resource that exists on another account answers exactly the same
template_not_found or job_not_found as one that does not exist at all. It is
never 403, and the message never differs. Anything else would confirm that a
particular ID exists, which is an enumeration oracle over other customers' data.
Using keys from a browser
Do not put a live key in browser code. Anything shipped to a browser is
readable by anyone who opens
devtools. A leaked pp_live_ key spends your credits and cannot be un-leaked;
you can only revoke it.
The API sends Access-Control-Allow-Origin: * because it is a server-side API
authenticated by a bearer token, with no cookie to protect. That header is a
convenience for tooling and server-side proxies. It is not permission to call
the API from a page.
Call the API from your backend and hand the result to the page.
Keys are issued by hand while self-service is being built, so there is nowhere to rotate one yourself yet. Ask for a replacement and the old key is revoked in the same step. That is also the fix for a key you have already leaked: revocation is immediate, with no grace window.