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_xxxxxxxxxxxxxxxxxxxxxxxx

Keys 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:

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

POST/v1/pdf

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

FieldTypeDescription
htmlstringRaw HTML to render. Full CSS supported.
urlstringPublic http(s) URL to render. Must resolve to a public address on port 80 or 443.
templatestringSlug of a saved template. Combine with data.
dataobjectVariables injected into the template. HTML-escaped.
filenamestringSuggested download name. Letters, numbers, space, dot, hyphen and underscore only.
optionsobjectPage 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: 4873

PDF options

OptionDefaultDescription
formatA4A4, A3, A5, Letter, Legal or Tabloid.
landscapefalseRotate the page.
printBackgroundtrueInclude CSS backgrounds and colors. Turn off to save ink on documents meant to be printed.
scale1Between 0.1 and 2.
margin10mm all roundObject with top, right, bottom, left. Units: mm, cm, in, px, pt.
pageRangesall pagese.g. "1-5" or "1,3,7".
headerHtml / footerHtmlnoneSmall HTML fragments repeated on every page. Chrome provides .pageNumber, .totalPages, .date, .title and .url classes.
waitForNetworkIdlefalseWait until network activity settles. Needed for pages that load content with JavaScript. Costs render time.
delayMs0Extra 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

GET/v1/documents/:id

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

GET/d/:id?exp=...&sig=...

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.

POST/v1/templates
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
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.

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

HTTPCodeMeaning
400invalid_requestValidation failed. details lists each field.
400invalid_jsonBody was not valid JSON.
400invalid_htmlHTML too large, empty, or referencing a blocked protocol.
400url_not_allowedThe URL is not a public http(s) address on port 80/443.
401missing_api_keyNo Authorization header.
401invalid_api_keyKey does not exist or was revoked.
402subscription_inactiveSubscription canceled or unpaid. Update billing.
403template_limit_reachedYour plan's saved-template limit is full.
404document_not_foundUnknown id, or it belongs to another account.
404template_not_foundNo template with that slug in your account.
409template_already_existsYou already have a template with that slug.
413payload_too_largeRequest body over 8 MB.
422render_failedThe document could not be rendered. See the message.
429rate_limit_exceededToo many requests per minute. Honor retry_after_seconds.
429quota_exceededMonthly 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

PlanDocuments/monthRequests/minuteSaved templatesFooter
Free100101Papyra footer
Starter5,0006010None
Pro50,000300100None
Scale500,0001,0001,000None

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:

Something unclear or missing? Tell us — documentation gaps are bugs.