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. 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.