API Documentation
Base URL: https://papyra.me · All requests and responses are JSON, except the PDF download itself.
Quickstart
Create an account, grab a key from your dashboard, and make one request. This returns a signed URL to a finished PDF:
curl -X POST 'https://papyra.me/v1/pdf?wait=true' \
-H 'Authorization: Bearer pk_live_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{"html": "<h1>Hello</h1>", "filename": "hello"}'Response:
{
"id": "0d75580d-50c5-4fdd-8742-998247885343",
"status": "completed",
"url": "https://papyra.me/d/0d75580d-...?exp=1788362886&sig=GCZlP1IP...",
"expires_at": "2026-09-08T15:28:06.948Z",
"size_bytes": 12609,
"page_count": 1
}Authentication
Every request needs your API key in the Authorization header:
Authorization: Bearer pk_live_xxxxxxxxxxxxxxxxxxxxxxxxKeys are created and revoked in the dashboard. The full key is shown once, at creation — we only store a hash, so we genuinely cannot show it to you again. Lost it? Revoke and create a new one.
Keep keys server-side. A key carries your full quota. Never ship one in frontend JavaScript, a mobile app, or a public repository — anyone who reads it can spend your plan.
Sync vs. async
The same endpoint works both ways, controlled by one query parameter:
?wait=true— the request stays open until the PDF is finished, then returns200with a ready-to-use URL. Simplest to code against. Best for documents that render in a few seconds.- Without it — returns
202immediately with a document id. PollGET /v1/documents/:iduntil the status iscompleted. Better for large documents and for keeping your own request handlers fast.
If a ?wait=true request exceeds the render timeout, it falls back to the 202 response instead of failing — the job keeps running and you can still poll for it.
Create a document
Send exactly one of html, url or template. Sending zero or more than one is a validation error — this is deliberate, so there is never ambiguity about which source won.
Body fields
| Field | Type | Description |
|---|---|---|
html | string | Raw HTML to render. Full CSS supported. |
url | string | Public http(s) URL to render. Must resolve to a public address on port 80 or 443. |
template | string | Slug of a saved template. Combine with data. |
data | object | Variables injected into the template. HTML-escaped. |
filename | string | Suggested download name. Letters, numbers, space, dot, hyphen and underscore only. |
options | object | Page setup — see PDF options. |
Rendering a URL
curl -X POST 'https://papyra.me/v1/pdf?wait=true' \
-H 'Authorization: Bearer pk_live_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"url": "https://example.com/reports/42",
"filename": "report-42",
"options": { "format": "A4", "printBackground": true }
}'Response headers
Every successful call returns your remaining quota, so you can alert before hitting the wall:
X-Quota-Limit: 5000
X-Quota-Remaining: 4873PDF options
| Option | Default | Description |
|---|---|---|
format | A4 | A4, A3, A5, Letter, Legal or Tabloid. |
landscape | false | Rotate the page. |
printBackground | true | Include CSS backgrounds and colors. Turn off to save ink on documents meant to be printed. |
scale | 1 | Between 0.1 and 2. |
margin | 10mm all round | Object with top, right, bottom, left. Units: mm, cm, in, px, pt. |
pageRanges | all pages | e.g. "1-5" or "1,3,7". |
headerHtml / footerHtml | none | Small HTML fragments repeated on every page. Chrome provides .pageNumber, .totalPages, .date, .title and .url classes. |
waitForNetworkIdle | false | Wait until network activity settles. Needed for pages that load content with JavaScript. Costs render time. |
delayMs | 0 | Extra wait before printing, up to 5000 ms. |
Page numbers in the footer
{
"html": "<h1>Report</h1>",
"options": {
"footerHtml": "<div style='font-size:9px;width:100%;text-align:center'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>",
"margin": { "bottom": "20mm" }
}
}Increase the bottom margin when you add a footer, otherwise the content prints over it.
Check a document
Use this to poll an async job. Status is one of queued, processing, completed or failed.
{
"id": "0d75580d-50c5-4fdd-8742-998247885343",
"status": "completed",
"url": "https://papyra.me/d/0d75580d-...?exp=...&sig=...",
"error": null,
"size_bytes": 12609,
"page_count": 1,
"render_ms": 175,
"created_at": "2026-09-01T15:28:06.469Z",
"completed_at": "2026-09-01T15:28:06.948Z",
"expires_at": "2026-09-08T15:28:06.948Z"
}When status is failed, the error field carries a human-readable reason and url is null.
Download
The url we return is already signed — just use it as is. It needs no API key, which means you can email it, redirect a browser to it, or hand it to a third-party system.
The signature covers both the document id and the expiry, so editing either invalidates the link. Default validity is 24 hours; documents themselves are deleted after 7 days. Need permanent storage? Download the file and keep it on your side.
Managing templates
Templates let you store the layout once and send only data. This keeps invoice markup out of your application code and makes design changes a one-line API call instead of a deploy.
curl -X POST 'https://papyra.me/v1/templates' \
-H 'Authorization: Bearer pk_live_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"slug": "invoice",
"name": "Customer invoice",
"html": "<h1>Invoice {{number}}</h1><p>Total: {{total}}</p>"
}'Then render it with data:
curl -X POST 'https://papyra.me/v1/pdf?wait=true' \
-H 'Authorization: Bearer pk_live_YOUR_KEY' \
-H 'Content-Type: application/json' \
-d '{
"template": "invoice",
"data": { "number": "1042", "total": "$250.00" },
"filename": "invoice-1042"
}'The rest of the endpoints:
GET/v1/templates/:slug
PUT/v1/templates/:slug
DELETE/v1/templates/:slug
PUT accepts name, html, or both. Slugs are lowercase letters, numbers and hyphens, and are unique within your account only — another customer using the slug invoice never collides with yours.
Template syntax
Deliberately small. Every value is HTML-escaped on the way in, so a customer name containing <script> renders as text instead of executing.
| Syntax | Does |
|---|---|
{{name}} | Insert a value, escaped. |
{{customer.address.city}} | Read a nested field. |
{{#each items}} ... {{/each}} | Repeat a block over a list. Inside, use {{@index}} (from 0) or {{@number}} (from 1). |
{{#if paid}} ... {{else}} ... {{/if}} | Conditional block. Empty string, 0, false and [] are falsy. |
A line-items table
<table>
{{#each items}}
<tr>
<td>{{@number}}</td>
<td>{{description}}</td>
<td>{{amount}}</td>
</tr>
{{/each}}
</table>
{{#if paid}}
<p>Paid in full — thank you.</p>
{{else}}
<p>Due on {{due_date}}.</p>
{{/if}}There is no raw/unescaped output and no expression evaluation, on purpose. Template engines that evaluate arbitrary expressions have a long history of remote code execution when the template comes from a user — and here, the template is written by a user.
Error codes
All errors share one shape:
{
"error": {
"code": "quota_exceeded",
"message": "You have used all 100 documents included in the Free plan this month.",
"used": 100,
"limit": 100,
"upgrade_url": "https://papyra.me/pricing"
}
}Match on code, never on message — codes are stable, wording may improve.
| HTTP | Code | Meaning |
|---|---|---|
| 400 | invalid_request | Validation failed. details lists each field. |
| 400 | invalid_json | Body was not valid JSON. |
| 400 | invalid_html | HTML too large, empty, or referencing a blocked protocol. |
| 400 | url_not_allowed | The URL is not a public http(s) address on port 80/443. |
| 401 | missing_api_key | No Authorization header. |
| 401 | invalid_api_key | Key does not exist or was revoked. |
| 402 | subscription_inactive | Subscription canceled or unpaid. Update billing. |
| 403 | template_limit_reached | Your plan's saved-template limit is full. |
| 404 | document_not_found | Unknown id, or it belongs to another account. |
| 404 | template_not_found | No template with that slug in your account. |
| 409 | template_already_exists | You already have a template with that slug. |
| 413 | payload_too_large | Request body over 8 MB. |
| 422 | render_failed | The document could not be rendered. See the message. |
| 429 | rate_limit_exceeded | Too many requests per minute. Honor retry_after_seconds. |
| 429 | quota_exceeded | Monthly document allowance used up. |
Retrying safely
Retry 429 and 5xx with exponential backoff. Do not retry 4xx other than 429 — the request will fail identically every time and each attempt still counts against your rate limit.
Limits by plan
| Plan | Documents/month | Requests/minute | Saved templates | Footer |
|---|---|---|---|---|
| Free | 100 | 10 | 1 | Papyra footer |
| Starter | 5,000 | 60 | 10 | None |
| Pro | 50,000 | 300 | 100 | None |
| Scale | 500,000 | 1,000 | 1,000 | None |
Other limits that apply to every plan: 8 MB request body, 2 MB of HTML, 50 MB output PDF, 30 second render timeout, 7 day document retention.
Security notes
Worth knowing if you are evaluating us for anything sensitive:
- Your HTML runs isolated. Every document renders in a throwaway browser context — no cookies, cache or storage shared with any other render, yours or anyone else's.
- The renderer cannot reach our database. It runs in a container with no network route to it and no database credentials in its environment, precisely because it executes third-party markup.
- Private network access is blocked. Every request your document makes — images, fonts, stylesheets, redirects — is checked against the resolved IP. Internal ranges and cloud metadata endpoints are refused.
- Files are private by default. Nothing is stored in a public bucket. Access requires a signed URL scoped to a single document with an expiry.
Something unclear or missing? Tell us — documentation gaps are bugs.