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. | A payment failed and the seven days that follow it ran out. The fix is a working card at app.paperpony.dev/billing, and the account is active again the moment one succeeds. Nothing was deleted. An operator can also set this by hand, in which case write to hello@paperpony.dev. |
insufficient_credits |
402 | The account has fewer credits than the request needs. | On Free, wait for the period to reset or choose a plan at app.paperpony.dev/billing. On a paid plan this means the account reached ten times its allowance in one period, which is a safety limit rather than the allowance: reply to the mail you were sent, or write to hello@paperpony.dev, and it is raised. The message states which of the two happened, the figures, 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 larger plan raises the limit; it counts per account rather than per key, so a second key does not. |
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.
Rotating a key is two steps in the dashboard: create the new one, put it where the old one was, then revoke the old one. Revocation is immediate and has no grace window, so do it in that order. That is also the fix for a key you have already leaked, and there is no need to wait for anybody.