PaperPony

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"
  }
}
  • code is stable and machine-readable. Branch on it, never on message.
  • message is written for a human developer: the cause and, where possible, the fix. It never contains the offending payload or any customer data.
  • request_id is also returned as the X-Request-Id header, on every response. Quote it when reporting a problem.
  • job_id appears 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.