API reference
Base URL https://pagerush.dev. Authenticate with your project's API key as a bearer token. Machine-readable spec: /v1/openapi.json.
Client libraries
Official SDKs cover templates and versions, sync and async rendering, job status and downloads, and verifying webhook signatures. Render calls carry an idempotency key automatically, so a retry never renders or charges twice. They have no dependencies beyond the language's standard library and are open source on GitHub.
| Language | Install | Requires |
|---|---|---|
| Node.js / TypeScript | npm install pagerush | Node 20+ |
| Python | pip install pagerush | Python 3.9+ |
| PHP | composer require pagerush/pagerush-php | PHP 8.1+ |
| Go | go get github.com/arsi-consultancy/pagerush-sdks/go@latest | Go 1.21+ |
import { PageRush } from "pagerush";
const pagerush = new PageRush(); // reads PAGERUSH_API_KEY
const result = await pagerush.templates.render("tpl_…", {
data: { number: "INV-001", customer: "Example Ltd" },
});
await result.save("invoice.pdf");How it works
You store a template once and render it with data for each document. A template is a Typst project: its main.typ (the source) plus any other files it imports or reads, such as more .typ modules, images or fonts. A template version renders the same document from the same data, unless it reads the current date or loads images from an address.
- Every change is a new version, numbered 1, 2, 3… Versions are immutable, so each job records exactly what it rendered.
- One version is live: renders use it unless they pass
version. Publishing an older version is how you roll back. - The render's data is available as a virtual
data.jsonat the project root. Read it with#let data = json("data.json").main.typanddata.jsonare reserved file paths. - Long documents are handled by Typst itself: page numbers, headers and footers, and table headers that repeat on every page.
#let data = json("data.json")
#set page(paper: "a4", margin: 2cm)
#set text(size: 10pt)
= Invoice #data.number
Billed to: *#data.customer*
#table(
columns: (1fr, auto, auto),
table.header[*Item*][*Qty*][*Amount*],
..data.items.map(i => (i.name, str(i.qty), [₹#(i.qty * i.price)])).flatten(),
){
"number": "INV-042",
"customer": "Acme Pvt Ltd",
"items": [{ "name": "Design", "qty": 2, "price": 4500 }]
}Endpoints
| GET /v1/templates | List the project's templates. |
| POST /v1/templates | Create a template. Its version 1 goes live. |
| GET /v1/templates/:id | A template with its version history. |
| PATCH /v1/templates/:id | Rename a template ({ name }). |
| DELETE /v1/templates/:id | Delete a template and all its versions. Past jobs keep their history. |
| POST /v1/templates/:id/versions | Add a version (live unless publish is false). |
| GET /v1/templates/:id/versions/:version | One version's source, files and sample data. |
| POST /v1/templates/:id/publish | Make a version live ({ version }). Also how you roll back. |
| POST /v1/templates/:id/render | Render the template with data. |
| GET /v1/jobs/:id | A job's status, the template version it used, and a download link. |
Create a template
POST /v1/templates with a name and source creates the template with version 1, live. Add versions with POST /v1/templates/:id/versions. Source plus files may be up to 1.5 MB per version.
| Field | Type | Description |
|---|---|---|
| name | string (required on create) | Up to 120 characters. |
| source | string | The template's main.typ, in Typst. Send this or document. |
| document | object | A template from the visual editor. Send this or source. Up to 20 images and fonts, embedded, at public https addresses, or added in the visual editor (fonts PageRush keeps). Images are PNG, JPEG, GIF, WebP or SVG, up to 1 MB each. |
| files | { path, content, encoding }[] | Other files the template imports or reads, at paths relative to main.typ (no leading "/", no ".."). encoding is utf8 (default) for text or base64 for images and fonts. Up to 50 files. |
| sample_data | any JSON | Example data for test renders in the dashboard. Not used by API renders. |
| publish | boolean | New versions only: make this version live. Default true; send false to stage a draft. |
curl -X POST https://pagerush.dev/v1/templates \
-H "Authorization: Bearer pk_live_your_key" \
-H "Content-Type: application/json" \
-d '{"name": "Invoice", "source": "#let data = json(\"data.json\")\n= Invoice #data.number"}'Starter templates
In the dashboard, New template can start from one of these, with sample data to test render it. Each is a single main.typ that reads everything from data.json: Indian number formatting (₹1,23,456.00), amounts in words in lakh and crore, and an "accent" colour you set in the data. Optional fields can be left out.
| GST tax invoice | HSN/SAC lines, CGST + SGST or IGST, per-rate tax summary, amount in words, bank and UPI. |
| Quotation | Itemised estimate with GST, validity date and a customer acceptance block. |
| Payment receipt | Compact half-page receipt: amount in words, payment mode and reference, invoices settled. |
| Account statement | Multi-page transactions with running balance, repeating header and Page X of Y. |
| Certificate | Landscape completion or achievement certificate in serif, with signatures and a seal. |
| Delivery challan | Packing slip without prices: items, quantities, packages, consignee and transporter. |
Render a PDF
POST /v1/templates/:id/render returns the PDF bytes directly, with the job id in X-Job-Id and the version used in X-Template-Version.
curl -X POST https://pagerush.dev/v1/templates/tpl_your_template/render \
-H "Authorization: Bearer pk_live_your_key" \
-H "Content-Type: application/json" \
-d '{"data": {"number": "INV-042", "customer": "Acme Pvt Ltd", "items": [{"name": "Design", "qty": 2, "price": 4500}]}}' \
-o invoice.pdfRequest body
| Field | Type | Description |
|---|---|---|
| data | any JSON (required) | The data for this document, up to 2 MB as JSON (1 MB with callback_url). The template reads it with json("data.json"). |
| version | integer | Render this version instead of the live one, e.g. to test a draft. |
| callback_url | https URL | Respond 202 immediately and POST the PDF to this public https URL when it is ready, signed. |
| metadata | object | Up to 20 string pairs, echoed on the job and in callbacks (X-PageRush-Metadata). At most 4 KB as JSON, where each non-ASCII character counts as 6 bytes (it is sent as a \uXXXX escape). |
| delete_after_download | boolean | Don't keep the PDF for 24 hours: sync responses aren't stored, async PDFs are deleted once delivered or downloaded. Defaults to the project's setting. |
| filename | string | File name in Content-Disposition. Defaults to the template's name. |
Async rendering & callbacks
Add callback_url and the API responds 202 with a job. When the PDF is ready we POST it to your URL as application/pdf (or a JSON pdf.failed event), with X-PageRush-Event, X-PageRush-Job-Id, X-PageRush-Timestamp and X-PageRush-Signature headers, plus X-PageRush-Metadata (your metadata as JSON) on a successful render. The signature covers the event, job id and metadata headers as well as the body, so none of them can be altered. Failed deliveries are retried for about 26 hours. A callback can occasionally arrive twice, so use X-PageRush-Job-Id to ignore repeats. When you rotate your webhook secret you can keep the old one working for 24 hours: callbacks then carry a signature for each secret, so accept a callback when any v2 matches. Poll GET /v1/jobs/:id any time for status and a signed download link.
import crypto from "node:crypto";
// X-PageRush-Signature: t=<unix seconds>,v2=<hex hmac>[,v2=<hex hmac>]
// Signed: "<t>\n<event>\n<job id>\n<X-PageRush-Metadata, or empty>\n" followed by the raw body.
// After a secret rotation there are two v2 values for 24 hours: accept the callback if any matches.
// rawBody must be the exact bytes received (e.g. express.raw()), not re-serialised JSON.
export function verify(rawBody, headers, secret) {
const parts = (headers["x-pagerush-signature"] ?? "").split(",").map((p) => p.trim().split("="));
const t = parts.find(([k]) => k === "t")?.[1];
if (!/^\d{1,15}$/.test(t ?? "") || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const signed = [t, headers["x-pagerush-event"], headers["x-pagerush-job-id"], headers["x-pagerush-metadata"] ?? ""];
const expected = Buffer.from(
crypto
.createHmac("sha256", secret)
.update(Buffer.concat([Buffer.from(signed.join("\n") + "\n"), rawBody]))
.digest("hex"),
);
return parts.some(
([k, v]) => k === "v2" && v.length === expected.length && crypto.timingSafeEqual(expected, Buffer.from(v)),
);
}Downloading a PDF again
Every successful PDF, sync or async, is normally kept for 24 hours. Use the job id from the X-Job-Id response header (or the 202 body) with GET /v1/jobs/:id: its download_url is a signed link valid for up to an hour, and you can request a fresh one at any time within the 24 hours. Downloading again never renders or charges again. Past jobs can also be downloaded from your dashboard. After 24 hours the file is deleted, download_url is null and old links return 410 FILE_EXPIRED.
Deleting PDFs as soon as you have them
If you'd rather we didn't keep your documents, send "delete_after_download": true, or turn on Delete PDFs after download in your project's settings to make it the default (a request's own value always wins). A sync PDF is then returned in the response and never stored. An async PDF is stored only until your callback accepts it with a 2xx; if the callback fails for good, you can still fetch it once from download_url, which deletes it after a complete download. Afterwards the job reports download_url: null, and download links and Idempotency-Key replays return 410 FILE_EXPIRED.
Reliability features
- Idempotency-Key header: retries return the original result without rendering or charging again.
- Prefer: respond-async: if the compile service is temporarily overloaded, get a 202 job instead of a 503.
- Failed renders are never charged; your quota is released automatically.
- Your data is used only to render: it is deleted from our database once the job finishes, whether it succeeded or failed (database backups expire within 30 days).
Limits
- Source plus files: 1.5 MB per version, up to 50 files.
- Sample data: 400 KB per version.
- Render data: 2 MB per request (1 MB with callback_url).
- Output: 10,000 pages and 25 MB per PDF.
- Render time: set by your plan.
- API requests: 100 per 10 seconds per API key. Beyond that you get a 429 with Retry-After.
- Concurrent renders: set by your plan. A sync render beyond it gets a 429
CONCURRENCY_LIMITwith Retry-After straight away and isn't charged; retry, or use callback_url so renders queue instead. In the rare case the render service is briefly unreachable, a sync render gets a 503 with Retry-After, and async renders retry on their own. - Billing: a PDF counts once against your quota or credits for every 50 pages started, worked out from the pages it produced. If a document needs more than your remaining quota (or credits) covers, it fails with
QUOTA_EXCEEDEDand nothing is charged. - Async renders run up to your plan's concurrent renders at a time, with up to 50 waiting per concurrent render. Beyond that you get a 429 with Retry-After.
Need more? Render time and concurrent renders can be raised for your account with a custom plan. The other limits apply to everyone, but if one gets in your way, email contact@pagerush.dev with what you're building.
Fonts
Renders use Typst 0.15.1 with these fonts. Set one with #set text(font: "Noto Sans"). Typst falls back to a font that has the character, so Hindi, Tamil or Bengali text renders correctly even when your template only names a Latin font. Every font is free to use: most are under the SIL Open Font License, New Computer Modern under the GUST Font License, and DejaVu Sans Mono under the Bitstream Vera license.
| Latin text (built in) | Libertinus Serif, New Computer Modern |
| Latin, Greek, Cyrillic and ₹ | Noto Sans, Noto Serif |
| Latin text in the visual editor | Inter |
| Code and tabular figures | Noto Sans Mono, JetBrains Mono, DejaVu Sans Mono |
| Hindi, Marathi, Nepali, Sanskrit | Noto Sans Devanagari |
| Bengali, Assamese | Noto Sans Bengali |
| Tamil | Noto Sans Tamil |
| Telugu | Noto Sans Telugu |
| Kannada | Noto Sans Kannada |
| Malayalam | Noto Sans Malayalam |
| Gujarati | Noto Sans Gujarati |
| Punjabi (Gurmukhi) | Noto Sans Gurmukhi |
| Odia | Noto Sans Oriya |
| Maths | New Computer Modern Math |
Packages
These Typst Universe packages are built in. Import them at exactly these versions, for example #import "@preview/tiaoma:0.3.0". Nothing is downloaded at render time, so renders stay fast and the output never changes under you. Other packages, or other versions, fail with an error that lists what's available. To use something else, copy its files into your template.
| @preview/tiaoma:0.3.0 | QR codes (UPI payment links) and barcodes: Code 128, EAN, GS1 and more |
| @preview/rustycure:0.2.0 | Fast, simple QR codes |
| @preview/cetz:0.5.3 | Drawing: shapes, lines and custom graphics |
| @preview/cetz-plot:0.1.4 | Bar, pie and line charts on top of CeTZ |
| @preview/lilaq:0.6.0 | Data plots with axes, legends and ticks |
| @preview/fletcher:0.5.8 | Diagrams with nodes and arrows (flows, processes) |
| @preview/zero:0.7.1 | Number formatting: digit grouping, rounding, units |
| @preview/oxifmt:1.0.0 | String formatting, e.g. strfmt("{:.2}", amount) |
Errors
Errors use application/problem+json with a stable code.
| Status | Code | Meaning |
|---|---|---|
| 400 | VALIDATION_FAILED | Fix the request; errors[] has field-level details. |
| 400 | INVALID_DATA | The render's data is missing, not JSON, or too large. |
| 400 | URL_NOT_ALLOWED | callback_url must be a public https URL. |
| 401 | INVALID_API_KEY | Missing, invalid or revoked key. |
| 402 | QUOTA_EXCEEDED | Monthly quota and credits exhausted. |
| 403 | PROJECT_DISABLED, ACCOUNT_SUSPENDED | The project or account has been disabled. |
| 404 | TEMPLATE_NOT_FOUND | No such template (or version) in this key's project. |
| 409 | TEMPLATE_NOT_PUBLISHED | The template has no live version. Publish one or pass version. |
| 409 | IDEMPOTENCY_CONFLICT | The Idempotency-Key was used for a different request. |
| 409 | CONFLICT | A request with this key is still being processed, or a limit was reached. |
| 410 | FILE_EXPIRED | The stored PDF expired after 24 hours, or was deleted after download. |
| 422 | TEMPLATE_COMPILE_FAILED | The template doesn't compile with this data. errors[] has Typst's diagnostics, with paths like main.typ:12:5. Not charged. |
| 422 | RENDER_TIMEOUT | The template ran longer than your plan allows. Not charged. |
| 429 | RATE_LIMITED, CONCURRENCY_LIMIT | Slow down; honour Retry-After. |
| 503 | RENDER_UNAVAILABLE | Temporary: the render service couldn't be reached or couldn't queue the job. Retry after Retry-After when present, or send Prefer: respond-async. Not charged. |