Skip to content
v2

API Reference

Render templates, manage assets, and integrate PDF generation into any stack.

Overview

The pdfs.build API lets you render templates to PDFs, manage your template library, and retrieve render logs — all over HTTPS with JSON request bodies and standard HTTP status codes.

New here? The PDF generation API page covers the model in prose, generating PDFs from a template explains where templates come from, and the comparisons cover migrating from another tool.

Keep your templates in a repository? Promote from CI shows a CI job pushing a template's Typst, testing it in dev and staging, and promoting it to production with an API key.

Base URL https://api.pdfs.build
Auth Bearer token — see Authentication
Format JSON request body. PDF render returns application/pdf

Authentication

All API requests require a bearer token. Create and manage API keys from Developer tools → API keys (/developers/api-keys) in your dashboard. Keys are shown once on creation — store them securely.

The primary public REST API is versioned under /v2/organizations/:organizationId/* and accepts API keys only. API keys are organization-scoped, and the organization in the URL must match the key. The legacy /v1/* API remains supported.

Key format

Keys are prefixed with prs_ and scoped to your organization.

cURL
curl https://api.pdfs.build/v2/organizations/org_abc/templates \
-H "Authorization: Bearer prs_your_api_key"
POST /v2/organizations/:organizationId/templates

Create template

Creates a draft template owned by the user that minted the API key. A draft is not rendered until it is published, except as "version": "draft" on a plan with no published-template limit (see Render PDF). Template source is normalized through the same publishing pipeline used by the app and should define a top-level #let render(...) = { ... } function whose named parameters match the schema fields.

Request body

Field Type Description
externalId string Your organization-scoped template ID. Required and unique.
name string Display name. Required.
description string Optional one-line summary.
code string Template source. Must define a top-level #let render(...) entry function. Required.
schema object JSON Schema describing the data payload accepted by the render endpoint.
sampleData object Example data that satisfies the schema.
schemaLocked boolean Optional. Set to true at creation to freeze the data fields: nobody can add, remove, or retype one, whether in the editor, through the chat or MCP, in an embedded editor, or over the API. Wording, layout, sample values and static images stay editable. Defaults to false and cannot be changed later.
pageSettings object Page size, margins, and other layout settings.

Returns 201 with both the immutable generated internalId and your externalId. V2 URLs use the external ID; v1 continues to use the internal ID.

External IDs are case-sensitive, 1–128 characters, and may contain ASCII letters, digits, ., _, ~, and -. The values . and .. are reserved. IDs remain reserved after a template is deleted.

POST /v2/organizations/:organizationId/templates/:externalId/publish

Publish template

Promotes a draft template to published so it becomes visible to your organization and callable via the render endpoint. The caller must own the template or be an organization admin/owner. Enforces your plan's published-template limit. Publishing a template that is already published ships its current draft as a new version, so edits made since (for example in the embedded editor) reach renders; with no edits since, nothing changes. To test changes before they reach production, see how to test template changes before they reach production, or Promote from CI to do it from a pipeline.

Request body (optional)

Field Type Description
name string Rename the template on publish.
description string Update the description on publish.

Returns the updated template detail. Returns 402 template_limit_reached if the org's plan does not allow another published template, and 409 publish_conflict if the template changed while it was being checked: nothing was published, and publishing again checks the current version.

POST /v2/organizations/:organizationId/templates/:externalId/unpublish

Unpublish template

Moves a published template back to draft. Once unpublished, it is removed from organization template listings, and a render of it answers 403 draft_not_renderable whatever version it names, except "draft" on a plan with no published-template limit (see Render PDF). A caller that sends "draft" keeps rendering it there: to stop that too, change what it sends or revoke its API key. Existing render log entries are preserved.

Returns the updated template detail.

POST /v2/organizations/:organizationId/templates/:externalId/render

Render PDF

Compiles the specified template with the provided data payload and returns a binary PDF. Data is validated against the template's schema before rendering.

Request body

Field Type Description
data object Key-value pairs matching the template's schema.
version integer or string Optional. Pin a version number (3), name a channel ("staging"), or send "draft" for the template's working copy as it is now. Omit it to serve whatever is promoted to latest, which is production. A channel that does not exist is refused with 400. "draft" renders the draft of a published template. A template that is not published answers 403 draft_not_renderable whatever version says, with one exception: on a plan with no published-template limit, "draft" renders its draft for a key whose creator owns the template or is an owner or admin of the organization. A key whose creator may not edit it gets 404, as for a template that does not exist. So a CI job can render a new template before its first publish, while unpublishing still stops every render that leaves out version or names a version number or a channel. On a plan with a limit a template must be published to render at all, "draft" included, or rendering without publishing would get round the limit. A draft render counts toward your render quota like any other.

Versions

Publishing freezes a template into an immutable version and points the latest channel at it. Later edits stay in the draft until you promote them, so production keeps rendering the same document (and the same schema) while you iterate. To look at the draft as it is, render it with "version": "draft". To test it as it will ship, freeze it as a new version and render that number or a channel such as staging, then promote it and every caller that omits version picks it up. Promoting an older version back is an instant rollback. A publish answers with publishedVersion, the version latest now serves: publishing again with nothing changed freezes no new version and answers with the same number, so pin that rather than counting publishes. Promote from CI does all of this from a pipeline with an API key. For the same steps in the app, see how to test template changes before they reach production.

Response

Returns Content-Type: application/pdf on success, with two headers that say what was rendered: X-Template-Version, the frozen version (absent when the draft was rendered), and X-Template-Channel, the channel the request went through (latest when version was omitted, staging, or draft; absent when you sent a version number). Both are exposed to browsers through CORS. On error, returns JSON with an error object.

const response = await fetch(
'https://api.pdfs.build/v2/organizations/org_abc/templates/invoice-primary/render',
{
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
data: {
company: 'Acme Corp',
items: [
{ name: 'Consulting', qty: 3, price: 150 },
],
due_date: '2025-06-01',
}
}),
}
);

const pdf = await response.arrayBuffer();
POST /v2/organizations/:organizationId/templates/:externalId/render

Async renders

Add "async": true to the render request body to render in the background instead of waiting for the PDF. The API answers 202 Accepted immediately with a job handle; you then wait for the render.completed webhook or poll the job status. Omitting async (or sending false) keeps the synchronous behavior unchanged, and async renders count toward the same monthly quota as synchronous renders. The version is resolved when you submit, as for a batch: one that does not exist is refused with 400 before anything is queued, and a channel such as latest is held to the version it pointed at then. "draft" is not held: the job reads the draft when it runs.

Request body

Field Type Description
data object Key-value pairs matching the template's schema.
async boolean Set to true to render in the background. Defaults to false (synchronous).
webhookIds string[] Optional. Restrict this render's webhook events to the given registered endpoints.

Response

Returns 202 Accepted with the job handle. The quota admission check happens at enqueue; finalization happens when the job completes.

202 Accepted
{
"id": "job_7a1b...",
"status": "queued",
"statusUrl": "https://api.pdfs.build/v2/organizations/org_abc/renders/job_7a1b..."
}

Polling for the result

Endpoint Description
GET /v2/organizations/:organizationId/renders/:jobId Job status — queued, processing, success, or error — plus durationMs, error, and downloadUrl once finished. version and channel say what was rendered, as the headers of a synchronous render do.
GET /v2/organizations/:organizationId/renders/:jobId/pdf The rendered PDF (application/pdf). Returns 409 while the job is not finished and 404 for unknown jobs.
GET .../renders/:jobId — completed job
{
"id": "job_7a1b...",
"status": "success",
"templateExternalId": "invoice-primary",
"durationMs": 123,
"downloadUrl": "https://api.pdfs.build/v2/organizations/org_abc/renders/job_7a1b.../pdf",
"createdAt": "2026-08-30T12:00:00Z",
"completedAt": "2026-08-30T12:00:01Z"
}

The downloadUrl requires the same API key (Authorization: Bearer prs_...). The legacy POST /v1/render route remains sync-only.

POST /v2/organizations/:organizationId/templates/:externalId/batches

Batch renders

Render up to 500 documents from one template in a single request. Every item becomes an async render job, all against the same template version: the version is resolved when you submit, so promoting a channel mid-batch does not mix versions. The exception is a batch that renders the live draft ("version": "draft", a template with nothing promoted yet, or a legacy auto-publish template): the draft is not pinned, so edits made mid-batch can reach later items. As for a single render, a batch for a template that is not published is refused with 403 draft_not_renderable, unless it asks for "draft" on a plan with no published-template limit (see Render PDF). Items send no per-render webhooks; one batch.completed event goes out when every item has succeeded or failed (webhooks need a paid plan).

Request body

Field Type Description
items object[] 1 to 500 entries, each {"data": ...} matching the template's schema.
webhookIds string[] Optional. Endpoints to notify with batch.completed. Omitted: every enabled endpoint. Empty: none.
version integer | string Optional. Same selector as a single render.

Response

Returns 202 Accepted with id, status, total and statusUrl. The whole batch is admitted against your monthly quota at once: if it does not fit, the request fails with 429 and nothing is queued. Only successful items are charged. More than 500 items returns 422 invalid_batch_size; the body may be up to 10 MB.

Polling for the result

GET /v2/organizations/:organizationId/batches/:batchId returns the batch's status (processing or completed), succeeded, failed and pending counts, and every item in submission order as { index, jobId, status, downloadUrl?, error? }. Each downloadUrl is that item's render job PDF route and needs the same API key.

GET /v2/organizations/:organizationId/templates

List templates

Returns published templates in the API key's organization. Drafts created by the key owner remain available through the single-template endpoint.

Response
[
{
"internalId": "550e8400-e29b-41d4-a716-446655440000",
"externalId": "invoice-primary",
"name": "Invoice v3",
"description": "Standard invoice with line items",
"status": "published",
"createdAt": "2025-03-12T10:24:00Z",
"updatedAt": "2025-04-01T08:15:00Z"
}
]
GET /v2/organizations/:organizationId/templates/:externalId

Get template

Returns a single template's metadata, schema definition, sample data, and schemaLocked state. Use the schema field to know which keys your data payload must include when rendering.

Response
{
"internalId": "550e8400-e29b-41d4-a716-446655440000",
"externalId": "invoice-primary",
"name": "Invoice v3",
"status": "published",
"schemaLocked": true,
"schema": {
"type": "object",
"properties": {
"company": { "type": "string" },
"due_date": { "type": "string", "format": "date" },
"items": { "type": "array" }
}
},
"sampleData": { /* ... */ }
}
PATCH /v2/organizations/:organizationId/templates/:externalId

Rename external ID

Send {"externalId":"invoice-primary"}. The old v2 URL stops resolving immediately; the internal ID, v1 URL, and historical render-log foreign keys do not change. Requires template ownership or organization admin access.

GET /v2/organizations/:organizationId/logs

Render logs

Returns a paginated list of PDF render events for your organization. Each log exposes templateInternalId and the templateExternalId captured when that render occurred, so later renames do not rewrite audit history. Use GET /v2/organizations/:organizationId/logs/:logId for detail or GET /v2/organizations/:organizationId/templates/:externalId/logs for one template.

Query parameters

Parameter Type Description
limit integer Max results to return (default: 50)
offset integer Pagination offset
status string Filter by status: success or error
source string Filter by source: api, ui, or mcp
externalId string Filter by the external ID captured at generation time. Historical IDs continue to work after a rename.
search string Full-text search on template name
from string (ISO 8601) Start of time range
to string (ISO 8601) End of time range

Webhooks

Outbound webhooks notify your systems when API renders complete or fail — both synchronous renders and async render jobs. UI, MCP, and other in-app renders do not emit webhook events. Webhooks are available on every paid plan.

Register endpoints from Developers → Webhooks in the dashboard, over MCP (create_webhook and friends), or through the v2 API directly — all three surfaces manage the same endpoints with the same plan gating. An organization can register multiple endpoints, and every enabled endpoint receives every render event. To restrict a single render's events to specific endpoints, pass their ids in the optional webhookIds field on the render endpoint — ids must reference existing, enabled endpoints of the same organization, and unknown ids return 422. Webhook delivery never affects render responses.

Managing endpoints via the API

Create, rotate-secret, and test require a paid plan; list, update, delete, and deliveries stay open. Endpoint URLs must be public http(s) destinations — non-global addresses (loopback, private, link-local, CGNAT, and other reserved ranges) are rejected with 400 url_not_allowed at registration and re-validated before every delivery.

Endpoint Plan gate Description
GET /v2/organizations/:organizationId/webhooks — List endpoints (secrets are never returned)
POST /v2/organizations/:organizationId/webhooks Paid Create with { url, description?, enabled? }; returns the endpoint and the signing secret once
PATCH /v2/organizations/:organizationId/webhooks/:id — Update url / description / enabled
DELETE /v2/organizations/:organizationId/webhooks/:id — Soft delete
POST /v2/organizations/:organizationId/webhooks/:id/rotate-secret Paid New signing secret, shown once
POST /v2/organizations/:organizationId/webhooks/:id/test Paid Enqueue a webhook.test event
GET /v2/organizations/:organizationId/webhooks/:id/deliveries — Recent 50 deliveries

Events

Type When
render.completed API render finished successfully (sync or async)
render.failed API render failed (compilation error, upload failure, crashed async job)
batch.completed Every item of a batch render has succeeded or failed (data: batchId, total, succeeded, failed)
webhook.test Test event sent from the dashboard

Payload

POST to your endpoint
{
"id": "evt_9f2c...",
"type": "render.completed",
"createdAt": "2026-08-30T12:00:00Z",
"data": {
"renderId": "d4e5...",
"jobId": "job_7a1b...",
"organizationId": "org_...",
"templateExternalId": "invoice-primary",
"status": "success",
"durationMs": 123,
"errorMessage": null,
"downloadUrl": "https://api.pdfs.build/v2/organizations/org_.../renders/job_7a1b.../pdf"
}
}

id is unique per delivery — use it for consumer-side idempotency. jobId and downloadUrl are present for async renders only; the download URL requires the same API key. render.failed events set status: "error" and errorMessage.

Verifying signatures

Each delivery is signed with the endpoint secret using the Standard Webhooks scheme, so you can verify it with any svix-compatible library. The signing secret (whsec_...) is shown once when the endpoint is created.

Header Description
webhook-id The event id (evt_...).
webhook-timestamp Unix seconds when the delivery was sent.
webhook-signature v1,<base64 HMAC-SHA256> over ${webhook-id}.${webhook-timestamp}.${raw-body}, keyed by the base64 portion of the secret.

Acknowledgement and retries

Respond with any 2xx within 5 seconds; anything else — including timeouts — counts as a failed attempt and is retried with capped exponential backoff: up to 10 attempts, min(60, 2^attempts) minutes between attempts. A delivery is marked failed after the 10th attempt. You can inspect recent deliveries and send test events from the dashboard.

Promote from CI

If your templates live in a repository, a CI job can take each change to production with an API key and test it on the way: push the Typst to the template's draft, freeze the draft as a version, point dev and then staging at it, render staging, and promote it. Production keeps serving the version it has until the last step, and a change that would break production's callers stops there unless the job says otherwise.

Before the first run, publish the template once: that freezes v1 and points latest, which is production, at it. On a plan with no published-template limit, a new template's draft can be rendered before then with "version": "draft". A template from before versions may still publish its edits as they are saved: if GET …/draft answers "autoPublishEdits": true, turn on Freeze production in its Versions tab first, or step 1 changes production at once and step 5 is refused with 409 auto_publish_enabled. The API key's creator must own the template or be an owner or admin of the organization, or the calls that change it answer 403 forbidden.

The examples use curl and jq, a template with the external ID invoice-primary, and two variables:

API="https://api.pdfs.build/v2/organizations/$PDFS_ORG_ID/templates/invoice-primary"
AUTH="Authorization: Bearer $PDFS_API_KEY"

--fail-with-body makes curl print an error answer and exit with an error, so with set -e (a GitHub Actions run step has it) a refused step stops the job and says why. Set API and AUTH in every run step, and run steps 3 and 4 in one, since step 4 reads the VERSION step 3 sets. Steps 1 and 2 run in the job of the branch every change lands on first, main say. The later steps can run there too, or in the jobs of other branches (a push to stage points staging, a push to production promotes): each finds the version by the commit the freeze was told of, so no job hands a version number to another.

1. Push the Typst

PUT …/draft
jq -n --rawfile code invoice.typ \
      --slurpfile schema schema.json \
      --slurpfile sample sample.json \
      '{code: $code, schema: $schema[0], sampleData: $sample[0]}' |
curl -sS --fail-with-body -X PUT "$API/draft" \
  -H "$AUTH" -H "Content-Type: application/json" --data-binary @-

The answer is the draft as the write left it (code left out here):

200 OK
{
  "externalId": "invoice-primary",
  "status": "published",
  "schema": { "type": "object", "properties": { ... }, "required": ["invoiceNo"] },
  "sampleData": { "invoiceNo": "INV-0119" },
  "pageSettings": {},
  "generatedFromBlocks": false,
  "autoPublishEdits": false,
  "updatedAt": "2026-11-04T11:29:25.140Z",
  "changed": true,
  "frozenAsLatest": null
}

Production has not moved. "changed": false means the draft held all of this already, and nothing was compiled or written; code that differs only by spaces, tabs and line breaks at its end counts as the same. To have the push refused when someone changed the template in the app since you last pulled it, add the updatedAt you pulled as expectedUpdatedAt: the answer is then 409 template_changed, and your own pushes since that pull do not count as changes. If the Typst does not compile against the sample data and the default fixtures, the answer is 422 compile_gate_failed with the diagnostics, and the draft stays as it was. 503 compiler_unavailable and 429 rate_limited say nothing about your template: send the same request again, after Retry-After seconds when the header is there.

2. Freeze it and point dev at it

POST …/versions
jq -n --arg sha "$GITHUB_SHA" '{changelog: ("Commit " + $sha), ref: $sha, promoteTo: "dev"}' > freeze-body.json
curl -sS --fail-with-body --retry 20 --retry-max-time 1200 -X POST "$API/versions" \
  -H "$AUTH" -H "Content-Type: application/json" --data-binary @freeze-body.json -o freeze.json ||
  { cat freeze.json; exit 1; }
freeze.json, 201 Created
{
  "version": 5,
  "contentHash": "sha256:331c5e4f...",
  "created": true,
  "ref": "3f2a9c1e5b7d4a20c9e1f6b3d8a2c7e4f1b9d063",
  "promotedTo": "dev",
  "promotion": {
    "name": "dev",
    "version": 5,
    "updatedAt": "2026-11-04T11:29:28.299Z",
    "updatedBy": "s1dSGJv3...",
    "previousVersion": 4,
    "moved": true,
    "forced": false,
    "schemaChanges": { "breaking": [], "notable": [] }
  },
  "latestCreated": false
}

v5 cannot change from here on. dev is made the first time it is named, and previousVersion is null then. If the draft has not changed since the newest version, nothing new is frozen: the answer is 200 with that version and "created": false. That freeze still compiles and counts against the freeze limit, and it is what records this commit as a ref of the version: skip it, and the later steps answer 404 ref_not_found for the commit. So a job that promotes by commit freezes every template it pushes, on every push. It meets 429 once it has frozen more than the limit takes at once (about ten, then one a minute). --retry 20 waits out a 429 or a 503 for its Retry-After (curl 7.66 or later) and sends the freeze again, from the file, as stdin cannot be sent twice, and --retry-max-time 1200 stops retrying after 20 minutes (a request under way may still finish later). Sending it again is safe: a freeze that went through already answers the same version, 200 with "created": false, and records nothing new.

ref is recorded on the version the freeze answers with, whether it made one or not, so every commit names the version it was frozen as. A ref names one version for good: a freeze that would record it on another (a job for an old commit run again after newer ones changed the draft) is refused with 409 ref_conflict, and nothing is frozen. Run this branch's job one push at a time (in GitHub Actions, concurrency: { group: "pdfs-freeze-${{ github.ref }}", cancel-in-progress: false }): two runs whose steps 1 and 2 interleave would record one commit's ref on the other's draft, and a ref is not moved once recorded. Not every commit of the branch is frozen: a push of several commits runs the job for the last of them, and with the group a run still waiting is replaced by a newer push's.

3. Point staging at it

Once your checks against dev pass, in the same job or the one a push to your stage branch starts:

PUT …/channels/staging
curl -sS --fail-with-body -X PUT "$API/channels/staging" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"ref\": \"$GITHUB_SHA\"}" -o staging.json || { cat staging.json; exit 1; }
VERSION=$(jq -er .version staging.json)
200 OK
{
  "name": "staging",
  "version": 5,
  "updatedAt": "2026-11-04T11:29:28.399Z",
  "updatedBy": "s1dSGJv3...",
  "previousVersion": 4,
  "moved": true,
  "forced": false,
  "schemaChanges": { "breaking": [], "notable": [] }
}

Staging moves to the version that commit was frozen as, wherever dev has gone since. In a branch's own job $GITHUB_SHA is the commit the branch is at, so move the stage and production branches by fast-forward to commits the first branch froze (git merge --ff-only, or git push origin <commit>:refs/heads/stage), once that commit's job on the first branch has succeeded. Before then, for a commit it never ran for, and for a merge, squash or rebase, which makes a commit no freeze was told of, the answer is 404 ref_not_found. refs in GET …/versions lists the commits there are. A channel can have any name, so qa or uat work the same way.

4. Test against staging

POST …/render
curl -sS --fail-with-body -X POST "$API/render" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"version": "staging", "data": {"invoiceNo": "INV-0120"}}' \
  -D headers.txt -o staging.pdf || { cat staging.pdf; exit 1; }

grep -i '^x-template' headers.txt
# x-template-version: 5
# x-template-channel: staging
test "$(grep -i '^x-template-version:' headers.txt | tr -d '\r' | cut -d' ' -f2)" = "$VERSION" ||
  { echo "staging did not render v$VERSION"; exit 1; }

The headers say what was rendered. The last line fails the job unless staging rendered $VERSION, since someone may have moved staging in the meantime. Your staging environment renders the same way, with "version": "staging". Test renders count toward your monthly quota like any other.

5. Promote to production

PUT …/channels/latest
curl -sS --fail-with-body -X PUT "$API/channels/latest" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"ref\": \"$GITHUB_SHA\"}"
200 OK
{
  "name": "latest",
  "version": 5,
  "updatedAt": "2026-11-04T11:29:29.526Z",
  "updatedBy": "s1dSGJv3...",
  "previousVersion": 4,
  "moved": true,
  "forced": false,
  "schemaChanges": { "breaking": [], "notable": [] }
}

Every render that leaves out version gets v5 from now on. To roll back, move latest to the old number: -d '{"version": 4}'. A promotion that runs as a job of its own, behind a manual approval say, can send {"fromChannel": "staging"} to ship exactly what staging serves, and "expectedVersion": 4 to be refused with 409 channel_moved if production has moved since it was read.

When the change would break production

Say v5 adds a required poNumber field. Production's callers do not send it yet, so v5 would refuse their requests. Steps 2 and 3 still go through, because dev and staging are where such a change is tested, and their answers list it:

"schemaChanges": {
  "breaking": [
    { "path": "poNumber", "kind": "required_added", "message": "\"poNumber\" is now required" }
  ],
  "notable": []
}

Step 5 is refused with 409, and production stays on v4:

409 Conflict
{
  "error": "breaking_schema_change",
  "message": "Moving production (\"latest\") from v4 to v5 changes the data a request must send, so a request that works today could fail: \"poNumber\" is now required. Nothing was changed. Send force: true to move it anyway.",
  "currentVersion": 4,
  "targetVersion": 5,
  "breaking": [
    { "path": "poNumber", "kind": "required_added", "message": "\"poNumber\" is now required" }
  ],
  "notable": []
}

A change is breaking when a request that works today could fail on it: a new required field, an optional field made required, a changed type, or fewer enum values. A removed field or a new optional one is listed under notable and does not stop the move. Read those too: a template written in Typst refuses a field it does not take, so callers that still send a removed field fail with unexpected argument.

Ship the callers first. While they roll out, they can pin "version": 5 and send the new field. Then promote with force:

curl -sS --fail-with-body -X PUT "$API/channels/latest" \
  -H "$AUTH" -H "Content-Type: application/json" \
  -d "{\"ref\": \"$GITHUB_SHA\", \"force\": true}"

The answer has "forced": true and lists the same change under schemaChanges.breaking.

The same steps are in the app's Versions tab and over MCP: see how to test template changes before they reach production.

GET /v2/organizations/:organizationId/templates/:externalId/draft

Get template draft

Returns the template's draft: the working copy the editor shows, which production does not render until it is frozen and promoted. The answer has the Typst code, schema, sampleData and pageSettings, and updatedAt, the draft's version to the millisecond. generatedFromBlocks is true for a template built from blocks in the visual editor, whose code cannot be written over the API. autoPublishEdits is true for a template from before versions, where production serves the draft as it is. Pictures in sampleData are storage keys: one sent back as it came stays the same picture.

A key sees its creator's own templates, every published template, and any template its creator may edit.

PUT /v2/organizations/:organizationId/templates/:externalId/draft

Update template draft

Replaces any of the draft's code, schema, sampleData and pageSettings; what you leave out stays as it is. This is how a CI job pushes the Typst its repository holds. It changes the draft and nothing else: production keeps rendering the version latest points at, no channel moves, and the editor's History gets an entry for the write. The exception is a template with autoPublishEdits, whose production serves the draft, so a write there is live at once. The key's creator must own the template or be an organization owner or admin (403 forbidden).

Request body

Field Type Description
code string The whole Typst source, with a top-level #let render(...) entry function.
schema object JSON Schema of the render data. A template with a locked schema refuses a change to its fields (400 schema_locked).
sampleData object Example data the compile check renders with. Send a new picture as a data:image/...;base64, URL.
pageSettings object Page size, margins and other layout settings.
expectedUpdatedAt string Optional. The draft's updatedAt from your last read or write. If anyone else has changed the draft since, nothing is written and the answer is 409 template_changed with the current updatedAt. Writes this API key made with PUT …/draft since do not count, as long as nothing else wrote in between, so a pipeline can send the updatedAt it last pulled with every push until it pulls again (up to 100 pushes that change the draft). The exception is per key: give each pipeline that pushes a key of its own. A publish whose lint fixes rewrite the code also counts as a change: pull again after one.

Send at least one of the first four (400 empty_draft_update). Any other field is refused (400 invalid_draft_update).

Compile check

Nothing is written unless the draft the write would leave compiles against its sample data and the default fixtures. If it does not, the answer is 422 compile_gate_failed with failed_fixtures and diagnostics. If the compile check could not run, the answer is 503 compiler_unavailable: send the write again, after Retry-After seconds when the header is there. The write is also refused for a link to anything but an http:, https:, mailto: or tel: address (400 unsafe_link_target), a picture stored for another template (400 foreign_image_key), and a template built from blocks (409 template_has_blocks).

Response

Returns 200 with the draft as the write left it, plus changed and frozenAsLatest. A write the draft already holds answers "changed": false at once, with no compile and no rate-limit cost, so a pipeline can push every template on every merge. Code that differs from the draft's only by spaces, tabs and line breaks at its very end, such as the final newline a file gets, is the draft's code: the draft keeps its own, and the write changes nothing for it. frozenAsLatest is a version number only for the first write to a published template that has never had a version: production was serving that draft, so it is frozen as v1 and latest is pointed at it before the write goes in. Otherwise it is null.

Writes that compile are limited per organization, to about 60 at once and then two a minute: past the limit the answer is 429 rate_limited with Retry-After. Someone with the template open in the editor is told it changed when they next save.

GET /v2/organizations/:organizationId/templates/:externalId/versions

List versions

Returns the template's frozen versions, newest first, each with the channels on it and the refs freezes recorded on it, in the order they were recorded; every channel with the version it points at; and draft, which says whether the draft holds changes the newest version does not. A key sees the template as Get template draft does: its creator's own templates, every published template, and any template its creator may edit, so an organization owner's or admin's key sees a member's unpublished template.

Response
{
  "versions": [
    {
      "version": 5,
      "contentHash": "sha256:331c5e4f...",
      "changelog": "Commit 3f2a9c1",
      "createdAt": "2026-11-04T11:29:28.299Z",
      "createdBy": "s1dSGJv3...",
      "channels": ["dev", "staging"],
      "refs": ["3f2a9c1e5b7d4a20c9e1f6b3d8a2c7e4f1b9d063"]
    },
    {
      "version": 4,
      "contentHash": "sha256:d7e3e108...",
      "changelog": "Commit 9b04e7d",
      "createdAt": "2026-11-02T16:02:11.870Z",
      "createdBy": "s1dSGJv3...",
      "channels": ["latest"],
      "refs": ["9b04e7d2c1a8f3e6b5d4c7a9e2f1b8d3c6a5e4f7", "0d8c1f2e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d"]
    }
  ],
  "channels": [
    { "name": "dev", "version": 5, "updatedAt": "2026-11-04T11:29:28.299Z", "updatedBy": "s1dSGJv3..." },
    { "name": "latest", "version": 4, "updatedAt": "2026-11-02T16:40:05.112Z", "updatedBy": "s1dSGJv3..." },
    { "name": "staging", "version": 5, "updatedAt": "2026-11-04T11:29:28.399Z", "updatedBy": "s1dSGJv3..." }
  ],
  "draft": { "newestVersion": 5, "hasUnfrozenChanges": false }
}
POST /v2/organizations/:organizationId/templates/:externalId/versions

Freeze a version

Compiles the draft against its sample data and the default fixtures, as publishing does, and freezes it as a new immutable version. Production does not move. Send promoteTo to point a channel at the new version in the same call. The key's creator must own the template or be an organization owner or admin.

Request body (optional)

Field Type Description
changelog string A note on what changed, up to 2000 characters.
promoteTo string A channel to point at the version, moved with the checks of Move a channel.
force boolean With "promoteTo": "latest", move production through a breaking schema change.
ref string Your name for what is frozen, typically the commit: 1-200 ASCII letters, digits, ., _, / and -. Recorded on the version this answers with, so Move a channel can find it by ref.

Response

Returns 201 with version, contentHash, created, ref, promotedTo and promotion (what moving the channel did, as Move a channel answers it). If the draft has not changed since the newest version, nothing new is frozen: the answer is 200 with that version and "created": false. It still compiles, and counts against the limit below. latestCreated is true when the template was published with no version yet: the freeze also points latest at the version, which is the draft production was already serving.

A draft that does not compile answers 422 compile_gate_failed and freezes nothing. A compile check that could not run answers 503 compiler_unavailable, which says nothing of the draft: send the request again, after Retry-After seconds when the header is there. With promoteTo, a move that would be refused freezes nothing either: 409 breaking_schema_change (only for latest), 409 template_not_published or 409 auto_publish_enabled. 409 publish_conflict means the draft changed while it was being compiled: freeze again. promoteTo takes no expectedVersion; to guard against a move made at the same moment, freeze without it and move the channel with expectedVersion.

ref is recorded on the version the answer names, a new one or, with "created": false, the newest. A version can carry many refs, and within a template a ref names one version: the same ref sent again for the same version changes nothing, and sent when the answer would be another version it is refused with 409 ref_conflict, which names the version it is on in refVersion, and nothing is frozen. A freeze refused for any reason records no ref. A ref that breaks the rule above is 400 invalid_ref.

Freezing compiles, so an organization may freeze about ten versions at once and then one a minute. Past that the answer is 429 rate_limited with Retry-After.

PUT /v2/organizations/:organizationId/templates/:externalId/channels/:channelName

Move a channel

Points a channel at a version, and makes the channel if it does not exist yet. A channel is a name you choose, such as dev, staging or qa: up to 64 lowercase letters, digits, ., _ and -, starting with a letter or a digit, and neither draft nor only digits, so it never shadows a version (400 invalid_channel_name). latest is production: moving it is a promotion, and moving it to an older version is a rollback. Renders that leave out version follow at once. The template must be published (409 template_not_published), and the key's creator must own it or be an organization owner or admin.

Request body

Field Type Description
version integer The version to point the channel at. Send exactly one of version, fromChannel and ref.
fromChannel string A channel whose version this channel takes: {"fromChannel": "staging"} on latest promotes exactly what staging serves.
ref string The ref a freeze recorded: {"ref": "3f2a9c1e..."} points the channel at the version that commit was frozen as.
expectedVersion integer or null Optional. The version the channel is on as you read it, or null if it must not exist yet. If it has moved, nothing changes and the answer is 409 channel_moved with currentVersion.
force boolean Optional. Move latest through a breaking schema change.

Schema changes

Every move compares the schema of the target version with the schema of the version the channel serves now, rollbacks included. For latest, a change a request that works today could fail on (a new required field, an optional field made required, a changed type, fewer enum values) is refused with 409 breaking_schema_change, which lists each change in breaking, and nothing moves. Send force: true to move it anyway. Any other channel moves and lists what breaks in schemaChanges.breaking, because that is where such a change is tested. Show each change's message: new kind values may be added.

Response

Returns 200 with the channel (name, version, updatedAt, updatedBy) and previousVersion (null for a new channel), moved (false when it was on that version already), forced and schemaChanges (breaking and notable). A version the template does not have is 404 version_not_found, a fromChannel that does not exist is 404 channel_not_found, and a ref no freeze of this template recorded is 404 ref_not_found. On a template that publishes its edits as they are saved (one from before versions), latest does not move (409 auto_publish_enabled): turn that off in the template's Versions tab first.

DELETE /v2/organizations/:organizationId/templates/:externalId/channels/:channelName

Remove a channel

Removes a channel and answers with the version it pointed at: { "name": "dev", "version": 5 }. The version is kept and can still be rendered by number. A render that names the removed channel is refused with 400 until the channel is set again. latest cannot be removed (400 channel_protected): unpublish the template instead. A channel that does not exist answers 404 channel_not_found.

Migrate from v1

V1 remains supported and continues to identify templates by the generated internal ID. V2 adds an organization-scoped external ID that you control. Existing templates initially use their internal ID as the external ID; rename it in the dashboard or with PATCH /v2/organizations/:organizationId/templates/:externalId.

V1V2
POST /v1/renderPOST /v2/organizations/:organizationId/templates/:externalId/render
Send templateId and dataMove the template identifier into the URL and send only data
Generated internal IDClient-managed external ID, unique within the organization

Importing a .docx

An existing Word document can be converted into a template instead of being rebuilt by hand. The import reads the document's structure and produces template code plus a matching JSON Schema, which is then editable like any other template — including through the chat agent.

Available in the app under Templates → Import, and over MCP with import_docx, process_docx_import and convert_docx_to_template.

A hyperlink to anything but an http:, https:, mailto: or tel: address (sms:, file:) keeps its text and loses its address, and the import says which addresses went (droppedLinks in the response, and in the tool's result over MCP).

Documents whose structure is expressed with real Word styles — headings, tables, lists — import most cleanly, because those are what map onto template structure. A file formatted by hand, with spacing and alignment instead of styles, carries no structure to read and is usually faster to redraw from one of the starting designs.

MCP server (for AI agents)

The same authentication layer also serves a Model Context Protocol endpoint. Agents that connect to it can design, publish, and render PDFs inside a single conversation — without API keys or dashboard handoffs.

Endpoint https://backend.pdfs.build/mcp
Transport Streamable HTTP
Auth OAuth 2.1 with dynamic client registration (RFC 7591). API keys are not accepted on this endpoint — they remain for the REST API only.

Tool surface

The MCP server exposes the entire template lifecycle plus authenticated rendering. MCP renders count toward the same monthly PDF render quota as UI, authenticated form, automation, and REST API renders.

Tool Purpose
list_templates / get_template / browse_templates Discover templates and load their code, schema, and sample data.
create_template / duplicate_template Start a fresh draft or clone an existing template.
write_document / edit_document / write_schema / write_sample_data Author the template code, schema, and data.
compile_document Compile the active template against default edge fixtures and optional custom fixtures, returning per-fixture diagnostics.
render_template Render a saved template without an API key. Pass public_share=true to create share/download URLs (not on a delegated connection), and expires_in_days to customize the expiry.
save_template Persist the active session's changes after the compile gate passes; draft WIP saves can explicitly skip the gate.
publish_template / unpublish_template Move templates between draft and published. Publishing requires the compile gate to pass.
list_template_versions List a template's frozen versions and the channels pointing at them.
create_template_version Compile-check the draft and freeze it as a new version. Production does not move.
promote_template_version Point any channel at a version, by number or with fromChannel. Moving latest promotes or rolls back production, and is refused for a breaking schema change unless force is true.
remove_template_channel Remove a channel other than latest. The version it pointed at is kept.
create_api_key / list_api_keys / delete_api_key Mint, audit, and revoke REST API keys for external integrations.
list_webhooks / create_webhook / update_webhook / delete_webhook / rotate_webhook_secret / test_webhook / list_webhook_deliveries Manage webhook endpoints (paid plans) and debug deliveries. Endpoint ids (whe_...) are what the REST API async render's webhookIds field targets.
document_reference Look up template code reference inline.
list_fonts / upload_font / confirm_font_upload Browse org fonts and upload custom TTF/OTF files.
search_google_fonts / install_google_font Browse Google Fonts and install a family into the org.

The four version tools are not available on a delegated connection.

Sessions and parallel agents

Each MCP connection gets its own isolated session on initialize, holding one active template (the one last loaded with get_template or created with create_template). Editing tools like edit_document and save_template operate on that session's active template.

Every editing tool takes a templateId and is refused when it does not match the session's active template, so a write can never land on the wrong document.

To build many templates in parallel, give each agent its own MCP connection — parallel agents then work on separate templates with fully independent state. Do not fan multiple agents out over a single shared connection: their calls run one at a time against one active template, so whichever agent loads a template last makes every other agent's calls fail. If two sessions do end up editing the same template, save_template detects the external modification and refuses to silently overwrite it — reload with get_template and re-apply your changes.

Connect from Claude Desktop, Cursor, or any MCP-compatible client. See the MCP integration guide for an end-to-end example.

Errors

All errors return a JSON body whose top-level error is a string — a machine-readable code for most failures — usually alongside a human-readable message. Validation and compilation failures instead carry a structured details or diagnostics array.

{
"error": "draft_not_renderable",
"message": "This template is not published. Publish it to render it through the API. On a plan with no published-template limit, you can also render its draft with \"version\": \"draft\"."
}
Status Code Description
400 invalid_request Missing or malformed request parameters
400 invalid_external_id External ID is not URL-safe or exceeds 128 characters
400 unsafe_link_target The code adds a link to anything but an http:, https:, mailto: or tel: address, or at publish the compiled pages have one the code does not say (an address built in code or read from sample data); message names the link
400 already_draft Trying to unpublish a template that is already a draft
400 invalid_url Webhook endpoint URL is malformed or not http(s)
400 url_not_allowed Webhook endpoint URL resolves to a non-public address
400 invalid_draft_update A draft write is not JSON, has a field of the wrong type, or has a field the route does not take. Carries issues[] rather than a message.
400 empty_draft_update A draft write has none of code, schema, sampleData and pageSettings
400 invalid_expected_updated_at expectedUpdatedAt is not a date and time
400 schema_locked The write would add, remove or retype a field of a locked schema
400 foreign_image_key sampleData names a picture stored for another template
400 too_many_images One write brings more new pictures than a template save may
400 image_too_large A new picture in sampleData is over the size limit; message names it
400 invalid_channel_name A channel name is not lowercase letters, digits, ., _ and -, or is draft or a number
400 invalid_version The version of a channel move is not a positive whole number
400 invalid_ref A ref is not 1-200 ASCII letters, digits, ., _, / and -
400 channel_protected latest cannot be removed. Unpublish the template instead.
401 unauthorized Missing or invalid API key
403 forbidden The key's creator may not change this template: publish or unpublish it, write its draft, freeze a version, or move or remove a channel
403 organization_scope_mismatch Organization URL does not match the API key organization
403 plan_gated Webhook create/rotate/test requires a paid plan
403 draft_not_renderable The template is not published. A render or a batch of it is refused whatever version says, except "draft" from a key whose creator may edit it, on a plan with no published-template limit.
409 external_id_conflict External ID is already reserved in the organization
409 publish_conflict The template changed or was deleted while it was being checked, so nothing was published or frozen. Publish or freeze again to check the current version.
409 template_changed The draft changed after the expectedUpdatedAt a write names (other than by the same API key's own PUT …/draft writes), or while the write was being checked. Nothing was written; updatedAt is the draft's version now.
409 template_has_blocks The template is built from blocks in the visual editor, so its code cannot be written over the API
409 template_not_published A channel cannot move on a template that is not published
409 auto_publish_enabled latest cannot move on a template that publishes its edits as they are saved. Turn that off in its Versions tab first.
409 channel_moved The channel is not on the expectedVersion the move names. Carries currentVersion.
409 breaking_schema_change Moving latest would change the data a request must send. Carries breaking[] and notable[]; send force: true to move it anyway.
409 ref_conflict A freeze's ref names another version already. Carries refVersion, the one it names; nothing was frozen.
404 not_found Template not found or not accessible
404 version_not_found A channel move names a version the template does not have
404 channel_not_found The channel to remove, or the fromChannel of a move, does not exist
404 ref_not_found No freeze of the template recorded the ref of a move
402 template_limit_reached Org plan's published-template limit is reached. Unpublish a template or upgrade the plan.
402 api_renders_not_allowed_on_free The REST render API needs a paid plan. Subscribe to Starter or higher.
400 Validation failed Data payload doesn't match the template schema. Carries details[] rather than a message.
422 Compilation failed The template failed to compile with this data. Carries diagnostics[] rather than a message.
422 compile_gate_failed The draft does not compile against its sample data or the default fixtures. Carries failed_fixtures[] and diagnostics[]; nothing was written or frozen.
429 render_quota_exceeded This billing period's PDF render quota is used up — see Rate limits
429 rate_limited Too many compile-checked draft writes or freezes for the organization. Wait Retry-After seconds.
500 render_failed Internal rendering error
503 compiler_unavailable The compile check of a draft write, a freeze or a gallery install could not run, which says nothing of the template. Nothing was written, frozen or published; send it again, after Retry-After seconds when given (a gallery install's copy is kept as a draft: publish it instead).

Rate limits

Requests are not throttled per API key. The limit that applies is your plan's monthly PDF render quota, shared by every render surface — REST API, app, MCP and the embedded editor. Once it is used up, render requests return 429 with render_quota_exceeded until the billing period rolls over or you buy a top-up pack; async renders are counted when they are queued.

The two calls that compile a template without rendering it are limited per organization, however many keys it has: a draft write (about 60 at once and then two a minute) and a freeze (about ten at once and then one a minute). Past the limit they answer 429 rate_limited with Retry-After. A draft write the draft already holds costs nothing.

Everything else (template management, logs and webhook endpoints) is not metered.

API keys

API keys are scoped to your organization and grant access to all templates your organization can see. Keys are prefixed with prs_ and displayed only once on creation — copy them immediately and store them in a secret manager or environment variable.

A key belongs to the member who created it, and keeps working after they leave the organization so that integrations do not break. Owners and admins see who created each key, can rename or revoke any key in the organization, and are emailed when someone leaves with keys that still work. Members manage their own keys.

A key acts with the role of the member who created it. Writing a template's draft, freezing a version and moving or removing a channel need a key whose creator owns the template or is an owner or admin of the organization, so a CI job that promotes templates other people own needs a key made by an owner or admin.

Manage your API keys from Developer tools → API keys (/developers/api-keys) in the dashboard. You can create multiple keys for different environments and revoke them individually.