PaperPony

Render a PDF

POST /v1/pdf/render, the one product-specific verb. Send a stored template and data, or inline HTML, and get back a finished job.

Render a PDF, synchronously or asynchronously

posthttps://api.paperpony.dev/v1/pdf/render

Send either template_id or html, never both.

Synchronous and asynchronous are the same path. With async unset the request waits: if the render finishes within 20 seconds you get 200 with the finished job, and if it does not you get 202 with the same job object still running, which you then poll with GET /v1/jobs/{id}. With async: true you get 202 immediately. The body is the same shape in every case.

A synchronous render that fails answers with the error envelope rather than a 200 carrying status: failed, because every error in this API uses one shape. That envelope carries job_id, so the timings, the page count and the error detail are still readable from GET /v1/jobs/{id}.

Credits. One credit per page, minimum one per job. Failed renders and timeouts charge nothing. Test-mode keys run the whole pipeline, produce watermarked output and charge zero.

Free tier. Free-plan renders run with JavaScript disabled, so a document that builds itself with a script renders differently there than on a paid plan. Free-plan and test-key output is watermarked.

Not yet supported. webhook_url from the specification is deliberately rejected rather than accepted and ignored; webhook delivery arrives in a later phase.

Parameters

NameInDescription
Idempotency-KeyheaderRetry safety. The same key on the same account within 24 hours returns the original job and charges nothing. Reusing a key with a different body is an error, not a silent replay.

Body

Send exactly one of template_id or html.

renderPdf request body
FieldTypeDescription
template_idstring
htmlstringHandlebars over HTML. Up to 5 MB; reference large assets by URL rather than inlining them.
dataobjectValues for the template.
optionsobjectEvery field is optional; the values shown are the defaults.
format"A0" | "A1" | "A2" | "A3" | "A4" | "A5" | "A6" | "Letter" | "Legal" | "Tabloid" | "Ledger"default "A4"
orientation"portrait" | "landscape"default "portrait"
marginobjectCSS lengths. A bare number is read as pixels.
print_backgroundbooleandefault true
scalenumberdefault 1 · min 0.1 · max 2
header_htmlstring | nullRendered into the top margin. Templated with the same data as the body. Chromium recognises the classes pageNumber, totalPages, date, title and url inside it. Needs an explicit font-size, and a top margin large enough to hold it.default null
footer_htmlstring | nulldefault null
page_rangesstring | nulldefault null
wait_for"load" | "domcontentloaded" | "networkidle"default "networkidle"
timeout_msintegerdefault 15000 · min 1000 · max 60000
filenamestringSanitized, not rejected: path separators, null bytes and control characters are stripped and the result is capped at 100 characters.default "document.pdf"
asyncbooleanAnswer 202 immediately instead of waiting for the render.default false

Example

curl https://api.paperpony.dev/v1/pdf/render \
  -X POST \
  -H "Authorization: Bearer $PAPERPONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "template_id": "tpl_01JZ3M8Q0000000000000000AB",
  "data": {
    "number": "INV-0001",
    "customer": "Fabrikam Ltd",
    "total": 4308
  },
  "options": {
    "format": "A4",
    "margin": {
      "top": "20mm",
      "bottom": "20mm"
    }
  }
}'
import { PaperPony } from 'paperpony';

const pony = new PaperPony({ apiKey: process.env.PAPERPONY_API_KEY });

const job = await pony.pdf.render({
  template_id: 'tpl_01JZ3M8Q0000000000000000AB',
  data: { number: 'INV-0001', customer: 'Fabrikam Ltd', total: 4308 },
  options: { format: 'A4', margin: { top: '20mm', bottom: '20mm' } },
});

console.log(job.output_url, job.page_count, job.credits_charged);
import os
import requests

response = requests.post(
    "https://api.paperpony.dev/v1/pdf/render",
    headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
    json={
    "template_id": "tpl_01JZ3M8Q0000000000000000AB",
    "data": {
        "number": "INV-0001",
        "customer": "Fabrikam Ltd",
        "total": 4308
    },
    "options": {
        "format": "A4",
        "margin": {
            "top": "20mm",
            "bottom": "20mm"
        }
    }
},
)
response.raise_for_status()
print(response.json())

Returns

renderPdf response
FieldTypeDescription
idrequiredstring
statusrequired"queued" | "processing" | "succeeded" | "failed"
productrequiredstring
moderequired"sync" | "async"
template_idrequiredstring | null
output_urlrequiredstring | nullSigned and expiring, never permanent. Signed fresh on every read of the job.
output_bytesrequiredinteger | null
output_expires_atrequiredstring | nullWhen the object itself is deleted, from the plan's retention.date-time
page_countrequiredinteger | null
credits_chargedrequiredinteger
credits_remainingrequiredinteger
watermarkedrequiredboolean
errorrequiredobject | null
coderequiredstring
messagerequiredstring
created_atrequiredstringdate-time
started_atrequiredstring | nulldate-time
finished_atrequiredstring | nulldate-time

Responses

StatusCodeMeaning
200The render finished inside the synchronous budget.
202The job is queued or still running. Poll `GET /v1/jobs/{id}`.
401unauthorized · invalid_api_keyNo credential, or the credential is malformed, unknown or revoked.
402insufficient_creditsThe account has fewer credits than the request needs. The message states the balance and the reset date.
403account_suspendedThe key is valid but the account may not use the API. Nothing sets this automatically: billing is not built, so it can only be set by hand. Write to hello@paperpony.dev if you see it.
404template_not_foundNo route matches the path (not_found), or the resource does not exist on this account (template_not_found, job_not_found).
413payload_too_large · page_weight_exceededpayload_too_large: what you sent is over a limit. page_weight_exceeded: what the document pulled in while rendering is.
422invalid_requestThe body or the query failed validation. The message names the field.
429rate_limitedToo many requests. Authenticated routes count per account, never per key.
500internal_errorUnexpected failure. Quote request_id when reporting it.
502render_failedThe renderer could not produce output. The message names what failed.
504render_timeoutThe render exceeded options.timeout_ms.
Every error uses one envelope, and every response carries an X-Request-Id. Quote it if you report a problem.