Publisher Service
The Publisher service generates PDFs and images from Typograph templates. It supports batch processing with CSV data for generating multiple documents from a single template.
Base URL: /v1/publisher/
Scopes: publisher (full), publisher:read, publisher:write
Token type: any — publisher endpoints accept either a user token or a client token. See Token Types.
Endpoints
| Method | Path | Scopes | Description |
|---|---|---|---|
GET | /v1/publisher/jobs | publisher / publisher:read | List generation jobs |
POST | /v1/publisher/jobs | publisher / publisher:write | Create a generation job |
GET | /v1/publisher/jobs/{id} | publisher / publisher:read | Job detail |
GET | /v1/publisher/jobs/{id}/files | publisher / publisher:read | Download the generated files, see File Types |
POST | /v1/publisher/merges/validate | publisher / publisher:write | Check a merge before printing it, see Checking a merge before printing |
GET | /health/publisher | — | Service health (public, unauthenticated) |
Create a Generation Job
Generate a document from a Typograph template:
curl -X POST https://api.typograph.nl/v1/publisher/jobs \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag": "invoice-alpha",
"input": {
"template": {
"url": "https://example.com/templates/invoice.json"
},
"manifest": {
"url": "https://example.com/templates/manifest.json"
}
},
"output": {
"upload": {
"url": "https://example.com/webhook/upload"
},
"config": {
"type": "pdf",
"color_mode": "cmyk",
"bleed": 3
}
}
}'
Response
{
"id": "01234567-89ab-cdef-0123-456789abcdef",
"tag": "invoice-alpha",
"status": "queued",
"created_at": "<ISO 8601 timestamp>"
}
Request Structure
| Property | Type | Required | Description |
|---|---|---|---|
tag | string | No | Optional tag to identify or group jobs |
input | object | Yes | The files to publish, see Input Object |
output | object | Yes | Where and how to publish, see Output Object |
url_headers | array | No | Header rules for the job's URLs, at most 10: see Headers for the URLs of a job |
Input Object
| Property | Type | Required | Description |
|---|---|---|---|
template | object | Yes, unless bundle is given | Typograph template JSON |
template.url | string | Yes | URL to template file |
template.headers | object | No | HTTP headers for downloading |
manifest | object | With template only | Typograph manifest JSON |
manifest.url | string | Yes | URL to manifest file |
manifest.headers | object | No | HTTP headers for downloading |
bundle | object | Yes, unless template is given | Typograph template bundle JSON: template and manifest in one document |
bundle.url | string | Yes | URL to bundle file |
bundle.headers | object | No | HTTP headers for downloading |
csv | object | No | CSV data file for batch processing |
csv.url | string | No | URL to CSV file |
csv.headers | object | No | HTTP headers for downloading |
mapping | object | No | With a csv: which column fills which field, column => field key. See Choosing columns and rows |
rows | string | No | With a csv: the rows to print, "3" or "1-10", counted from 1 without the header |
Give either a template with its manifest, or a bundle — never both, and no manifest next to a bundle: the bundle carries its own.
bundleis thetemplate_bundle.jsonthe Converter Service produces (or thetemplatebundle.jsonof a package made before that name) —{ "version": "1.0.0", "template": {...}, "manifest": {...} }— so a converted package can be published from one URL.template+manifestsuits a manifest whose image and font URLs expire, such as presigned storage URLs: keep one template and send a freshly generated manifest with every job.
Either way, the per-URL headers apply to the bundle, template and manifest files themselves, not to the image and font URLs the manifest names. When those assets need a header, add a url_headers rule that covers them.
Fonts
Every font the template's text is set in must be in the manifest, with its files. When text is set in a font or style the job does not have, the job fails instead of printing without that text, and its message names the text and the font:
The text "Hello" is set in "Lato" (bold-italic), which is not among the job's fonts, so it cannot be printed.
Add the font's files to the manifest, or set the text in a font the manifest has, then submit the job again. A space or other invisible character in a missing font does not fail the job.
Output Object
| Property | Type | Required | Description |
|---|---|---|---|
upload | object | Yes | Target for generated files |
upload.url | string | Yes | URL to upload generated files |
upload.headers | object | No | HTTP headers for uploading |
config | object | Yes | Generator configuration |
merge | object | No | With a csv: how the merge delivers its rows. See One file for the merge |
merge.mode | string | No | single (one PDF, the default for a PDF) or per_row (a ZIP with a file per row, always for images) |
merge.filename | string | No | With per_row: each row's file name, with field keys in {{…}}. file_name, its name before, is still read as a deprecated alias |
merge.on_overflow | string | No | fail (the default) or warn: whether a row whose text does not fit fails or prints cut off |
Output Formats
| Format | Description |
|---|---|
pdf | PDF document |
png | PNG image (one per page) |
jpg / jpeg | JPEG image (one per page) |
webp | WebP image (one per page) |
An image job of more than one page makes a child job per page, each with the parent's id as
its parent_id. The parent writes no image of its own: it completes once its pages are queued,
uploads nothing, and downloading its output answers 400. Each page's image is fetched from its child job (see
Listing Child Jobs and Download Generated Files).
A job with pages set to one page makes one image.
Generator Configuration
Configure the output with the output.config object:
| Property | Type | Description |
|---|---|---|
type | string | Output format: pdf, png, jpg, jpeg, webp |
dpi | integer | Resolution for images (36-600, default: 300) |
quality | integer | Quality for lossy formats (0-100, default: 90) |
color_mode | string | Color space: rgb, cmyk, or cmyk+spots (spot swatches as separations of their own) |
bleed | number | Print bleed in millimeters, 0 or more |
pages | string | Pages to put out, e.g. "1-5, 8-10" — a comma and a space between parts |
crop_marks | boolean | Include crop marks for printing |
single_pages | boolean | Split spread pages. spreads_as_single_pages, its name before, is still read as a deprecated alias |
rotate_pages | list | Pages to turn, e.g. [{"page": 2, "degrees": 90}] — 90, 180 or 270 |
add_blank_last_page | boolean | Add one empty page after the last |
watermark | object | Watermark overlay configuration |
The exact syntax of every option — how pages are numbered, what pages accepts, the rules for
rotate_pages — is on PDF Output Options.
Watermark Configuration
Add a watermark overlay to generated documents:
{
"output": {
"config": {
"type": "pdf",
"watermark": {
"type": "center",
"image": {
"url": "https://example.com/watermark.png",
"headers": {
"Authorization": "Bearer your-storage-token"
}
},
"width": 50,
"height": 20,
"opacity": 0.3
}
}
}
}
| Property | Type | Description |
|---|---|---|
type | string | Positioning: center or repeat_horizontal |
image | object | Watermark image URL and optional headers |
image.url | string | URL to PNG watermark image |
image.headers | object | Optional HTTP headers for downloading (max 5) |
width | number | Width in millimeters |
height | number | Height in millimeters |
opacity | number | Transparency (0.0-1.0, default: 0.5) |
A CSV job on the Free plan prints with the "PROOF · typograph.nl" watermark instead of this one: see How many rows your plan merges.
Text Laid Out Again Before Printing
A template stores where each word of its text sits, as the editor laid it out. The publisher prints that layout, and lays the text out again first when the stored layout no longer matches the job:
- the job's manifest binds a font to another family than the template was laid out in, for example a theme's font;
- a frame's text has changed since it was laid out, for example by a form or an API caller that wrote new values into the template.
When text no longer fits its frame after that layout, for example a longer name or a wider font, the
job fails instead of printing the text cut off. Its message names the frames, by name:
The template was laid out again, but its text does not fit in "Name", so it would print cut off.
Make the frame larger or shorten the text, then submit the job again.
Only text that was laid out again counts. With other fonts, that is every frame. With changed text alone, it is the frames whose text changed: a frame you did not change prints as the editor shows it, also when its text does not fit there. A template printed with the layout it was saved with is not checked.
Batch Processing with CSV
Generate multiple documents by providing a CSV data file:
curl -X POST https://api.typograph.nl/v1/publisher/jobs \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag": "invoice-batch-alpha",
"input": {
"template": {
"url": "https://example.com/templates/invoice.json"
},
"manifest": {
"url": "https://example.com/templates/manifest.json"
},
"csv": {
"url": "https://example.com/data/invoices.csv"
}
},
"output": {
"upload": {
"url": "https://example.com/webhook/upload"
},
"config": {
"type": "pdf"
}
}
}'
When a CSV file is provided:
- A parent job is created for the batch
- Child jobs are automatically created for each row in the CSV
- Use
parent_idto filter and list child jobs
Following a merge
The parent job is the merge. It ends once every row has, not once the rows are queued:
waiting_for_rowswhile the rows print.row_countis the number of rows the merge prints, andprinted_row_countandfailed_row_countcount those that printed and those that did not, so you can show progress by reading the parent withGET /v1/publisher/jobs/{id}. The three arenullon a job without a CSV.mergingonce every row printed, while the rows are put together into one file and uploaded.completedonce that file is uploaded tooutput.upload.url: see One file for the merge. It is also the parent's own output, downloaded withGET /v1/publisher/jobs/{id}/files, and each row's file stays its child job's.failedonce every row ended and at least one did not print. A print run with holes is worse than none, so the merge fails as a whole. Itsmessagenames the first rows and why, and itsreportlists them all.- One webhook for the merge:
publication.completedorpublication.failedfor the parent, carrying the counts and the report. The rows send none of their own.
The report tells what the merge has to say about your data:
{
"warnings": [{ "column": "Telefoon", "reason": "no_field" }],
"rows": [
{ "row": 2, "status": "failed", "message": "The value \"misschien\" of \"photo.visible\" is not yes or no." },
{ "row": 5, "status": "warned", "message": "The text does not fit in \"naam\", so it printed cut off." }
]
}
warnings, from the moment the CSV is read: each column that fills no field of the template (no_field), so a mistyped header does not go unnoticed.rows, once the merge ended: every row that did not simply print, numbered as in the CSV from 1 (the header is not a row), with the reason:failedfor a row that did not print,warnedfor one that printed cut off withon_overflow: "warn". A reason longer than 300 characters is cut off with…;GET /v1/publisher/jobs?parent_id=…lists the rows' own jobs with their wholemessage. A merge that printed every row has an empty list.
What the CSV holds
The first line is the header, and each column it names fills a merge field of the template: a named text frame, a named image, <name>.visible, or a {{key}} placeholder. GET /v1/file/templates/{id}/fields lists them. A column matches a field by its key or its label, ignoring case.
naam;photo;photo.visible;plaats
Jan de Vries;https://example.com/jan.jpg;ja;Utrecht
Piet;;nee;
- Save it as your spreadsheet does. The delimiter is the one the header uses most of
,,;or a tab, so a Dutch Excel export with;works as it is. UTF-8 (with or without a byte order mark), Excel's "Unicode text" (UTF-16) and Windows-1252 are read. A quoted cell may hold the delimiter, quotes and line breaks. - An empty cell leaves the element as the template has it. In a placeholder's column it removes the placeholder, and its line when nothing else is on it.
- A column that fills no field is ignored, and named in the report's
warnings. The job fails before any row is queued when no column fills anything, or when a{{key}}of the template has no column: it would print as it stands. - A
.visiblecolumn says whether the element prints. It prints fortrue,yes,ja,1,x,yorj, and is hidden forfalse,no,nee,0orn, in any case. - A row that cannot be filled gets no child job. A
.visiblevalue outside those, or an image value that is not an http(s) URL, makes such a row; the other rows still print. The merge then fails once they ended, and itsreportlists the row and the reason. - A picture that does not answer fails its row, rather than print as a grey box:
The picture of "photo" does not answer at its URL, so it cannot be printed.Its URL is asked with the job'surl_headerswhen the row prints, and must be public, as every image of a template must. - A frame whose text changed is laid out again, and a row whose text then does not fit its frame fails rather than printing cut off, unless
output.merge.on_overflowiswarn: then it prints cut off, and the report lists it aswarned. - Your plan sets the number of rows, below.
Choosing columns and rows
"input": {
"template": { "url": "https://example.com/templates/badge.json" },
"manifest": { "url": "https://example.com/templates/manifest.json" },
"csv": { "url": "https://example.com/data/people.csv" },
"mapping": { "Naam": "name", "Toon foto": "photo.visible" },
"rows": "1-10"
}
mappingnames the field a column fills when its header does not match it, column => field key, at most 500. A column it leaves out still matches by key or label. A mapping that names a column the CSV does not have, or a field the template does not have, fails the job before any row is queued.rowsprints one row ("3") or a range ("1-10"), counted from 1 without the header. The report numbers the rows as the CSV does, so row 7 is row 7 in your spreadsheet. Only these rows count against your plan, so a Free plan can proof rows 1–10 of a larger file. A range past the end fails the job:The data has no row 5: it has 2 rows.
Both go with a csv: without one, the job is refused.
One file for the merge
When every row printed, the merge uploads one file to output.upload.url, and output.merge says which:
mode: "single", the default for a PDF: every row in one PDF, in row order, with the trim, bleed and crop boxes of each page kept. A merge over 500 rows or 150 MB uploads a ZIP of such PDFs instead,rows-1-500.pdf,rows-501-1000.pdf, ….mode: "per_row", and always for an image merge: a ZIP with a file per row.filenamenames each file from the row's values, such as"{{name}}"forJan de Vries.pdf. The file brings its own extension; a name that occurs twice gets-2, the pages of a multi-page image-p1,-p2, and withoutfilenamea file isrow-1.pdf,row-2.pdf, ….
single for an image merge, and filename without per_row, are refused when the job is created. A ZIP is uploaded with Content-Type: application/zip.
Checking a merge before printing
POST /v1/publisher/merges/validate takes the body of the job that would start the merge and answers at once, without printing anything:
curl -X POST https://api.typograph.nl/v1/publisher/merges/validate \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "input": { "template": {...}, "manifest": {...}, "csv": {...}, "mapping": { "Naam": "name" } }, "check_overflow_rows": 5 }'
{
"is_valid": false,
"message": "1 of the 250 rows would not print: the report says why.",
"plan": { "tier": "pro", "max_rows": 1000, "is_proof": false },
"row_count": 250,
"unchecked_row_count": 0,
"fields": [{ "key": "name", "label": "name", "type": "text", "required": false, "max_length": null, "choices": null, "targets": [...] }],
"mapping": [{ "column": "Naam", "field": "name" }, { "column": "notes", "field": null }],
"report": {
"warnings": [{ "column": "notes", "reason": "no_field" }],
"rows": [{ "row": 7, "status": "failed", "message": "The value \"maybe\" of \"badge.visible\" is not yes or no." }]
}
}
is_validmeans the check found nothing that would stop the merge. A merge the job would refuse whole (a file that cannot be downloaded, a mapping that does not fit, more rows than your plan merges) answersis_valid: falsewith the job's ownmessage.plan.is_proofis true on a Free plan, whose merges print as a proof. A body without acsv, which is no merge to check, or one the job would refuse too, is a 422.- Every row has its values checked. The template is filled in and validated for up to 25 different sets of fields the rows fill.
- The pictures the rows name are asked whether they answer, as the job asks them: each URL once, for up to 25 URLs and 15 seconds. A row whose picture does not answer would not print.
- Text that does not fit needs a layout, which takes a second or so a row.
check_overflow_rows(0 to 20, default 0) lays out that many rows, the first that pass the other checks, for up to 30 seconds. A row whose text does not fit would not print, or withoutput.merge.on_overflow: "warn"is in the report aswarned, which leaves the mergeis_valid. A layout that takes over 20 seconds, or that the layout service does not make, leaves its row unchecked rather than failed. unchecked_row_countcounts the rows one of these limits left checked in part: their filled-in template, a picture, or their layout among the firstcheck_overflow_rows. The rows after those are not laid out and not counted, so with the default of 0 nothing is laid out andunchecked_row_countcan still be 0.messagesays when rows went unchecked, sois_valid: trueis not read as more than was checked.- The check takes
publisher:write, as creating the job does, and counts as one request.
How many rows your plan merges
The plan of the organization your token works for decides how many rows one job may print. What counts is the rows the job prints: every row of the CSV, or only those of its rows range. The job fails before any row is queued when they are more.
| Plan | Rows per job |
|---|---|
| Free | 10, as a proof: every page prints with a "PROOF · typograph.nl" watermark |
| Pro | 1,000 |
| Enterprise | 10,000, or what your contract says |
- A Free proof always carries the watermark. It replaces any
watermarkin youroutput.config, so a proof is never mistaken for the print run. A single job without a CSV prints without it. - Too many rows fails the job with a message that names the limit, for example
Your plan merges a proof of at most 10 rows, and this data has more. - The plan could not be read: the job fails with
The organization's plan could not be checked. Try again in a moment.Nothing merges unchecked. Create the job again.
Listing Child Jobs
curl "https://api.typograph.nl/v1/publisher/jobs?parent_id=PARENT_JOB_ID" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Download Generated Files
Download the output once the job is completed:
curl "https://api.typograph.nl/v1/publisher/jobs/{id}/files?file_type=output" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-o document.pdf
File Types
| Type | Description |
|---|---|
output | Generated document (default) |
template | Original template file |
manifest | Original manifest file |
csv | CSV data file (if used) |
Upload Request Format
When a job completes successfully and you've configured output.upload.url, Typograph sends the generated file as a single HTTP PUT request to that URL. Your upload target must accept this request shape:
| Property | Value |
|---|---|
| Method | PUT |
| URL | Exactly the URL you provided in output.upload.url (no redirects are followed) |
Content-Type | Detected from the file — e.g. application/pdf, image/png, image/jpeg, image/webp, or application/zip for a merge's ZIP |
User-Agent | Typograph/Util-Upload |
| Custom headers | Whatever you passed in output.upload.headers, or else the headers of the url_headers rule that covers the upload URL (merged in, can't override Content-Type or User-Agent) |
| Body | Raw file bytes, streamed (no multipart / form-data wrapper) |
| Connection timeout | 10 seconds |
| Transfer timeout | 300 seconds |
Your endpoint must respond with an HTTP status in the 200–299 range to be considered successful; anything else marks the job as failed.
Minimal receiver (Node.js / Express)
const express = require('express');
const fs = require('fs');
const app = express();
app.put('/uploads/:filename',
// Use a raw body parser — the request body is the file itself.
express.raw({ type: '*/*', limit: '200mb' }),
(req, res) => {
const filename = req.params.filename;
fs.writeFileSync(`./uploads/${filename}`, req.body);
console.log(`Received ${req.headers['content-type']} (${req.body.length} bytes)`);
res.status(200).send();
},
);
Minimal receiver (PHP)
<?php
// PUT /uploads/{filename}
$rawBody = file_get_contents('php://input');
$filename = basename($_SERVER['REQUEST_URI']);
file_put_contents('/var/uploads/' . $filename, $rawBody);
http_response_code(200);
Uploading Directly to Cloud Storage
You can point output.upload.url straight at a presigned URL from S3, GCS, Azure Blob, Cloudflare R2, etc. — each provider accepts signed PUT requests with a raw body. Set output.upload.headers to whatever the presigner expects (e.g. specific Content-Type, x-amz-* metadata). Protected headers (Content-Type, User-Agent) can't be overridden, so make sure the presigned URL is signed against the content type Typograph will send (see the list above).
Custom Headers
For URLs that need a header, such as a private CDN, give the job url_headers: one list of rules whose headers go with every request to a URL a rule covers, including the fonts and images the manifest names.
{
"url_headers": [
{
"match": "https://storage.example.com/private/",
"headers": { "Authorization": "Bearer your-storage-token" }
}
],
"input": {
"template": { "url": "https://storage.example.com/private/template.json" },
"manifest": { "url": "https://storage.example.com/private/manifest.json" }
},
"output": {
"upload": { "url": "https://example.com/webhook/upload" },
"config": { "type": "pdf" }
}
}
The headers of a single URL are the shorthand for a rule that matches exactly that URL. They win over every prefix rule, and still work as before:
{
"input": {
"template": {
"url": "https://example.com/private/template.json",
"headers": {
"Authorization": "Bearer your-storage-token"
}
},
"manifest": {
"url": "https://example.com/private/manifest.json",
"headers": {
"Authorization": "Bearer your-storage-token"
}
}
},
"output": {
"upload": {
"url": "https://example.com/webhook/upload",
"headers": {
"X-API-Key": "your-api-key"
}
}
}
}
Complete Example
Generate a PDF invoice with watermark and custom headers:
curl -X POST https://api.typograph.nl/v1/publisher/jobs \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag": "invoice-premium-001",
"input": {
"template": {
"url": "https://storage.example.com/templates/invoice.json",
"headers": {
"Authorization": "Bearer storage-token"
}
},
"manifest": {
"url": "https://storage.example.com/templates/manifest.json",
"headers": {
"Authorization": "Bearer storage-token"
}
}
},
"output": {
"upload": {
"url": "https://webhook.example.com/documents",
"headers": {
"X-Webhook-Secret": "secret-key"
}
},
"config": {
"type": "pdf",
"color_mode": "cmyk",
"bleed": 3,
"crop_marks": true,
"watermark": {
"type": "center",
"image": {
"url": "https://storage.example.com/draft-watermark.png",
"headers": {
"Authorization": "Bearer storage-token"
}
},
"width": 100,
"height": 30,
"opacity": 0.2
}
}
}
}'