Skip to main content

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​

MethodPathScopesDescription
GET/v1/publisher/jobspublisher / publisher:readList generation jobs
POST/v1/publisher/jobspublisher / publisher:writeCreate a generation job
GET/v1/publisher/jobs/{id}publisher / publisher:readJob detail
GET/v1/publisher/jobs/{id}/filespublisher / publisher:readDownload the generated files, see File Types
POST/v1/publisher/merges/validatepublisher / publisher:writeCheck 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​

PropertyTypeRequiredDescription
tagstringNoOptional tag to identify or group jobs
inputobjectYesThe files to publish, see Input Object
outputobjectYesWhere and how to publish, see Output Object
url_headersarrayNoHeader rules for the job's URLs, at most 10: see Headers for the URLs of a job

Input Object​

PropertyTypeRequiredDescription
templateobjectYes, unless bundle is givenTypograph template JSON
template.urlstringYesURL to template file
template.headersobjectNoHTTP headers for downloading
manifestobjectWith template onlyTypograph manifest JSON
manifest.urlstringYesURL to manifest file
manifest.headersobjectNoHTTP headers for downloading
bundleobjectYes, unless template is givenTypograph template bundle JSON: template and manifest in one document
bundle.urlstringYesURL to bundle file
bundle.headersobjectNoHTTP headers for downloading
csvobjectNoCSV data file for batch processing
csv.urlstringNoURL to CSV file
csv.headersobjectNoHTTP headers for downloading
mappingobjectNoWith a csv: which column fills which field, column => field key. See Choosing columns and rows
rowsstringNoWith a csv: the rows to print, "3" or "1-10", counted from 1 without the header
Template and manifest, or a bundle

Give either a template with its manifest, or a bundle — never both, and no manifest next to a bundle: the bundle carries its own.

  • bundle is the template_bundle.json the Converter Service produces (or the templatebundle.json of a package made before that name) — { "version": "1.0.0", "template": {...}, "manifest": {...} } — so a converted package can be published from one URL.
  • template + manifest suits 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​

PropertyTypeRequiredDescription
uploadobjectYesTarget for generated files
upload.urlstringYesURL to upload generated files
upload.headersobjectNoHTTP headers for uploading
configobjectYesGenerator configuration
mergeobjectNoWith a csv: how the merge delivers its rows. See One file for the merge
merge.modestringNosingle (one PDF, the default for a PDF) or per_row (a ZIP with a file per row, always for images)
merge.filenamestringNoWith 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_overflowstringNofail (the default) or warn: whether a row whose text does not fit fails or prints cut off

Output Formats​

FormatDescription
pdfPDF document
pngPNG image (one per page)
jpg / jpegJPEG image (one per page)
webpWebP 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:

PropertyTypeDescription
typestringOutput format: pdf, png, jpg, jpeg, webp
dpiintegerResolution for images (36-600, default: 300)
qualityintegerQuality for lossy formats (0-100, default: 90)
color_modestringColor space: rgb, cmyk, or cmyk+spots (spot swatches as separations of their own)
bleednumberPrint bleed in millimeters, 0 or more
pagesstringPages to put out, e.g. "1-5, 8-10" — a comma and a space between parts
crop_marksbooleanInclude crop marks for printing
single_pagesbooleanSplit spread pages. spreads_as_single_pages, its name before, is still read as a deprecated alias
rotate_pageslistPages to turn, e.g. [{"page": 2, "degrees": 90}] — 90, 180 or 270
add_blank_last_pagebooleanAdd one empty page after the last
watermarkobjectWatermark 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
}
}
}
}
PropertyTypeDescription
typestringPositioning: center or repeat_horizontal
imageobjectWatermark image URL and optional headers
image.urlstringURL to PNG watermark image
image.headersobjectOptional HTTP headers for downloading (max 5)
widthnumberWidth in millimeters
heightnumberHeight in millimeters
opacitynumberTransparency (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_id to 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_rows while the rows print. row_count is the number of rows the merge prints, and printed_row_count and failed_row_count count those that printed and those that did not, so you can show progress by reading the parent with GET /v1/publisher/jobs/{id}. The three are null on a job without a CSV.
  • merging once every row printed, while the rows are put together into one file and uploaded.
  • completed once that file is uploaded to output.upload.url: see One file for the merge. It is also the parent's own output, downloaded with GET /v1/publisher/jobs/{id}/files, and each row's file stays its child job's.
  • failed once 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. Its message names the first rows and why, and its report lists them all.
  • One webhook for the merge: publication.completed or publication.failed for 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: failed for a row that did not print, warned for one that printed cut off with on_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 whole message. 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 .visible column says whether the element prints. It prints for true, yes, ja, 1, x, y or j, and is hidden for false, no, nee, 0 or n, in any case.
  • A row that cannot be filled gets no child job. A .visible value 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 its report lists 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's url_headers when 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_overflow is warn: then it prints cut off, and the report lists it as warned.
  • 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"
}
  • mapping names 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.
  • rows prints 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. filename names each file from the row's values, such as "{{name}}" for Jan 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 without filename a file is row-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_valid means 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) answers is_valid: false with the job's own message. plan.is_proof is true on a Free plan, whose merges print as a proof. A body without a csv, 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 with output.merge.on_overflow: "warn" is in the report as warned, which leaves the merge is_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_count counts the rows one of these limits left checked in part: their filled-in template, a picture, or their layout among the first check_overflow_rows. The rows after those are not laid out and not counted, so with the default of 0 nothing is laid out and unchecked_row_count can still be 0. message says when rows went unchecked, so is_valid: true is 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.

PlanRows per job
Free10, as a proof: every page prints with a "PROOF · typograph.nl" watermark
Pro1,000
Enterprise10,000, or what your contract says
  • A Free proof always carries the watermark. It replaces any watermark in your output.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​

TypeDescription
outputGenerated document (default)
templateOriginal template file
manifestOriginal manifest file
csvCSV 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:

PropertyValue
MethodPUT
URLExactly the URL you provided in output.upload.url (no redirects are followed)
Content-TypeDetected from the file — e.g. application/pdf, image/png, image/jpeg, image/webp, or application/zip for a merge's ZIP
User-AgentTypograph/Util-Upload
Custom headersWhatever 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)
BodyRaw file bytes, streamed (no multipart / form-data wrapper)
Connection timeout10 seconds
Transfer timeout300 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
}
}
}
}'