PaperPony

Templates

Stored Handlebars documents, so a render sends data rather than markup. List, create, read, update and archive them. Also product-agnostic.

List templates, newest first

gethttps://api.paperpony.dev/v1/templates

Parameters

NameInDescription
limitquery
cursorqueryThe `next_cursor` from the previous page. Opaque.
include_archivedquery

Example

curl https://api.paperpony.dev/v1/templates?limit=20 \
  -H "Authorization: Bearer $PAPERPONY_API_KEY"
import { PaperPony } from 'paperpony';

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

const page = await pony.templates.list({ limit: 20 });
for (const template of page.data) console.log(template.id, template.name);
import os
import requests

response = requests.get(
    "https://api.paperpony.dev/v1/templates?limit=20",
    headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
)
response.raise_for_status()
print(response.json())

Returns

listTemplates response
FieldTypeDescription
datarequiredobject[]
has_morerequiredboolean
next_cursorrequiredstring | null

Responses

StatusCodeMeaning
200A page of templates.
401unauthorized · invalid_api_keyNo credential, or the credential is malformed, unknown or revoked.
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.

Create a template

posthttps://api.paperpony.dev/v1/templates

The source is compiled when it is stored, so a template that could never render is refused here rather than the first time you use it. Only the helpers formatDate, formatCurrency, formatNumber, uppercase and lowercase are available, alongside the block helpers if, unless, each and with.

slug is derived from name when you do not send one.

Body

createTemplate request body
FieldTypeDescription
namerequiredstringmin length 1 · max length 120
slugstringUnique among this account's live templates. Derived from name when omitted.max length 120
product"pdf"default "pdf"
sourcerequiredstringmin length 1
default_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"

Example

curl https://api.paperpony.dev/v1/templates \
  -X POST \
  -H "Authorization: Bearer $PAPERPONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Invoice",
  "product": "pdf",
  "source": "<h1>Invoice {{number}}</h1>\n<p>{{customer}}</p>\n<p>{{formatCurrency total currency=\"USD\"}}</p>",
  "default_options": {
    "format": "A4"
  }
}'
import { PaperPony } from 'paperpony';

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

const template = await pony.templates.create({
  name: 'Invoice',
  product: 'pdf',
  source: '<h1>Invoice {{number}}</h1><p>{{customer}}</p>',
  default_options: { format: 'A4' },
});

console.log(template.id);
import os
import requests

response = requests.post(
    "https://api.paperpony.dev/v1/templates",
    headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
    json={
    "name": "Invoice",
    "product": "pdf",
    "source": "<h1>Invoice {{number}}</h1>\n<p>{{customer}}</p>\n<p>{{formatCurrency total currency=\"USD\"}}</p>",
    "default_options": {
        "format": "A4"
    }
},
)
response.raise_for_status()
print(response.json())

Returns

createTemplate response
FieldTypeDescription
idrequiredstring
productrequiredstring
namerequiredstring
slugrequiredstring
sourcerequiredstring
engine_versionrequiredstring
default_optionsrequiredobjectEvery 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"
created_atrequiredstringdate-time
updated_atrequiredstringdate-time
archived_atrequiredstring | nulldate-time

Responses

StatusCodeMeaning
201The template was created.
401unauthorized · invalid_api_keyNo credential, or the credential is malformed, unknown or revoked.
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.

Get a template

gethttps://api.paperpony.dev/v1/templates/{id}

Archived templates stay readable by id, because a job still refers to the template it was rendered from.

Parameters

NameInDescription
idrequiredpath

Example

curl https://api.paperpony.dev/v1/templates/tpl_01JZ3M8Q0000000000000000AB \
  -H "Authorization: Bearer $PAPERPONY_API_KEY"
import { PaperPony } from 'paperpony';

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

const template = await pony.templates.get('tpl_01JZ3M8Q0000000000000000AB');
import os
import requests

response = requests.get(
    "https://api.paperpony.dev/v1/templates/tpl_01JZ3M8Q0000000000000000AB",
    headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
)
response.raise_for_status()
print(response.json())

Returns

getTemplate response
FieldTypeDescription
idrequiredstring
productrequiredstring
namerequiredstring
slugrequiredstring
sourcerequiredstring
engine_versionrequiredstring
default_optionsrequiredobjectEvery 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"
created_atrequiredstringdate-time
updated_atrequiredstringdate-time
archived_atrequiredstring | nulldate-time

Responses

StatusCodeMeaning
200The template.
401unauthorized · invalid_api_keyNo credential, or the credential is malformed, unknown or revoked.
404template_not_foundNo route matches the path (not_found), or the resource does not exist on this account (template_not_found, job_not_found).
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.

Update a template

patchhttps://api.paperpony.dev/v1/templates/{id}

Parameters

NameInDescription
idrequiredpath

Body

updateTemplate request body
FieldTypeDescription
namestringmin length 1 · max length 120
slugstringmax length 120
sourcestringmin length 1
default_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"

Example

curl https://api.paperpony.dev/v1/templates/tpl_01JZ3M8Q0000000000000000AB \
  -X PATCH \
  -H "Authorization: Bearer $PAPERPONY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "name": "Invoice (2026)"
}'
import { PaperPony } from 'paperpony';

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

await pony.templates.update('tpl_01JZ3M8Q0000000000000000AB', { name: 'Invoice (2026)' });
import os
import requests

response = requests.patch(
    "https://api.paperpony.dev/v1/templates/tpl_01JZ3M8Q0000000000000000AB",
    headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
    json={
    "name": "Invoice (2026)"
},
)
response.raise_for_status()
print(response.json())

Returns

updateTemplate response
FieldTypeDescription
idrequiredstring
productrequiredstring
namerequiredstring
slugrequiredstring
sourcerequiredstring
engine_versionrequiredstring
default_optionsrequiredobjectEvery 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"
created_atrequiredstringdate-time
updated_atrequiredstringdate-time
archived_atrequiredstring | nulldate-time

Responses

StatusCodeMeaning
200The updated template.
401unauthorized · invalid_api_keyNo credential, or the credential is malformed, unknown or revoked.
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.

Archive a template

deletehttps://api.paperpony.dev/v1/templates/{id}

Archives rather than deletes. The template stops being usable for new renders and disappears from the default listing, but stays readable by id so job history keeps making sense.

Parameters

NameInDescription
idrequiredpath

Example

Archiving is reversible in the sense that nothing is deleted: rendered jobs keep pointing at the template, and its id stays valid in job history. It stops being usable for new renders.

curl https://api.paperpony.dev/v1/templates/tpl_01JZ3M8Q0000000000000000AB \
  -X DELETE \
  -H "Authorization: Bearer $PAPERPONY_API_KEY"
import { PaperPony } from 'paperpony';

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

await pony.templates.archive('tpl_01JZ3M8Q0000000000000000AB');
import os
import requests

response = requests.delete(
    "https://api.paperpony.dev/v1/templates/tpl_01JZ3M8Q0000000000000000AB",
    headers={"Authorization": f"Bearer {os.environ['PAPERPONY_API_KEY']}"},
)
response.raise_for_status()
print(response.status_code)

Returns

archiveTemplate response
FieldTypeDescription
idrequiredstring
productrequiredstring
namerequiredstring
slugrequiredstring
sourcerequiredstring
engine_versionrequiredstring
default_optionsrequiredobjectEvery 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"
created_atrequiredstringdate-time
updated_atrequiredstringdate-time
archived_atrequiredstring | nulldate-time

Responses

StatusCodeMeaning
200The archived template.
401unauthorized · invalid_api_keyNo credential, or the credential is malformed, unknown or revoked.
404template_not_foundNo route matches the path (not_found), or the resource does not exist on this account (template_not_found, job_not_found).
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.
Every error uses one envelope, and every response carries an X-Request-Id. Quote it if you report a problem.