PDF tools
Formfeed can finish a PDF after it is rendered: append other documents, stamp a watermark across every page, and lock it with a password. There are two ways to use this.
- In the render: add a
postblock toPOST /v1/renders. You get one render and one download. - Afterwards: the
/pdf/merge,/pdf/protectand/pdf/watermarkendpoints work on PDFs you already have. Each call returns a new render./pdf/inforeads page count, page sizes and metadata.
A third endpoint makes the PDF in the first place: /pdf/convert turns a Word, Excel, PowerPoint, OpenDocument, RTF or HTML file, or the output of a Word or PowerPoint template, into a PDF render.
Sources
Every place that reads an existing PDF (post.merge_after, and sources or source of the tools) takes two kinds of reference, mixed in any order:
- A render id (
rnd_…) of a succeeded PDF render in the same workspace whose output has not expired or been deleted. A raw UUID is read as a render id too. - A PDF in the file library, by its name, such as
terms.pdforlegal/terms.pdf, or by its file id (fil_…). A library file does not expire, so a fixed terms-and-conditions page belongs there. A watermarkimageis a PNG or JPEG of the library, named the same way.
Formfeed resolves every reference inside your workspace before anything runs. A customer URL is never a source, and neither is a render of another workspace. Renders whose outputs are stored in different regions cannot be processed together, and merge_after cannot append outputs stored in another region than the one the render runs in (both 400 invalid_request). Library files have no region and combine with anything.
Cost, plans and scopes
| Operation | Units (live key) | Plans | Scope |
|---|---|---|---|
Each post step (merge, watermark, password) | 0.5 on top of the render | Starter and above | render:create |
/pdf/merge, /pdf/protect, /pdf/watermark | 0.5 per call, however many pages | Starter and above | render:create |
/pdf/convert | 1 per started 5 pages of the PDF, like a rendered PDF | Starter and above | render:create |
/pdf/info | free | every plan | render:read |
Test keys cost nothing, as for renders; see units. On the Free plan the tools answer 403 forbidden with "feature": "post_processing", and /pdf/convert answers it with "feature": "office", for test keys too. An e-invoice (post.einvoice) is a step of its own with its own price and plan rule; see below.
The /pdf/* endpoints count against your plan's requests per second like POST /renders (429 rate_limited above it), and a live key whose units are used up gets 402 quota_exceeded, as for a render.
Post-processing in the render
{
"template": "invoice",
"data": { "invoice": { "number": "2026-0042" } },
"post": {
"merge_after": ["rnd_3b52891defac4cea87d80abc195d2d3f", "terms.pdf"],
"watermark": { "text": "COPY", "opacity": 0.2 },
"password": { "user": "open-sesame", "permissions": ["print"] }
}
}
| Key | Type | What it does |
|---|---|---|
merge_after | array of 1 to 49 sources | Appended after the rendered document, in the given order. |
watermark | object | The fields of /pdf/watermark without source: text or image, opacity, rotation, color. |
password | object | user (needed to open), owner (lifts the restrictions) and permissions, as user_password, owner_password and permissions of /pdf/protect. Note the shorter names here. |
einvoice | object or false | Makes the PDF a ZUGFeRD or Factur-X e-invoice; see below. |
The steps always run in the same order, whatever order you write them in:
merge_after: the PDFs are appended after the rendered document (up to 49, so at most 50 documents in one file). The plan's page limit applies to the merged result, and the render'spage_countcounts the appended pages.watermark: drawn across the middle of every page, merged pages included.password: the finished document is encrypted with AES-256. This step comes last because an encrypted PDF cannot be edited any more.einvoice, where there is one: the e-invoice is made from the document as it will be delivered.
A render with post costs its normal units plus 0.5 per step; the units of the render itself are counted on the pages it rendered, not on the pages it appended. The example above renders one page (1 unit) and runs three steps (1.5 units), 2.5 units in total. post works only for output: "pdf", and an unknown key in post is refused with 400 invalid_request rather than ignored. Post-processed renders are never served from the deduplication cache, because their merge sources can change.
Async renders and batch items take post too. The references are resolved when you send the request, so a source that is deleted or expires before the render runs makes it fail with source_not_found.
With an e-invoice or PDF/UA-1
- E-invoice.
post.einvoice(or a template that declares an e-invoice) runs after the other steps and costs 1 unit on top. Live e-invoices need the Starter plan; test renders make a limited number a day on every plan (plans and units). A watermark goes with it, as text or as an image. A password does not:post.passwordwith an e-invoice answers400 invalid_requestwith"field": "post.password", because PDF/A forbids encryption. PDFs you append must meet PDF/A too; one whose fonts are not embedded fails the render withpdfa_conversion_failed. - PDF/UA-1.
merge_aftercannot be combined withsettings.pdf.ua(400 invalid_request,"field": "post.merge_after"): a merge drops the tags of the documents it appends. A watermark and a password do go with it; the watermark is marked as an artifact, so it does not disturb the tags, and the password is added after the file was checked. - The tools do not check. The results of
/pdf/merge,/pdf/watermarkand/pdf/protectare never validated again: theireinvoiceandaccessibilityarenull. Merging PDF/UA files does not give a PDF/UA file, and a merged e-invoice carries no XML any more.
The PDF tools
- curl
- TypeScript
- Python
# merge an earlier render and a library PDF into one file
curl https://api-eu.formfeed.dev/v1/pdf/merge \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-H "content-type: application/json" \
-d '{"sources": ["rnd_7da09ab6cd9946b798c187c2a9350852", "terms.pdf"], "filename": "bundle.pdf"}'
# watermark the merged render (its id is in the answer above), then protect the watermarked one
curl https://api-eu.formfeed.dev/v1/pdf/watermark \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "content-type: application/json" \
-d '{"source": "rnd_745a62a800e44beeb3993bb03c8956a3", "text": "DRAFT", "color": "#cc0000"}'
curl https://api-eu.formfeed.dev/v1/pdf/protect \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "content-type: application/json" \
-d '{"source": "rnd_50136336b95245628f661fdc527bbf8a", "user_password": "open-sesame", "permissions": ["print"]}'
import { Formfeed } from '@formfeed/sdk';
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
const data = { invoice: { number: '2026-0042' } };
const invoice = await formfeed.renders.create({ template: 'invoice', data });
const terms = await formfeed.renders.create({ template: 'terms', data: {} });
const bundle = await formfeed.pdf.merge([invoice, terms], {
filename: 'bundle.pdf',
});
const draft = await formfeed.pdf.watermark(bundle, {
text: 'DRAFT',
color: '#cc0000',
});
const locked = await formfeed.pdf.protect(draft, {
user_password: 'open-sesame',
permissions: ['print'],
});
const info = await formfeed.pdf.info(locked); // { source: 'rnd_…', page_count: 3, encrypted: true, … }
import os
from formfeed import Formfeed
client = Formfeed(os.environ["FORMFEED_API_KEY"])
data = {"invoice": {"number": "2026-0042"}}
invoice = client.renders.create(template="invoice", data=data)
terms = client.renders.create(template="terms", data={})
bundle = client.pdf.merge([invoice, terms], filename="bundle.pdf")
draft = client.pdf.watermark(bundle, "DRAFT", color="#cc0000")
locked = client.pdf.protect(draft, user_password="open-sesame", permissions=["print"])
info = client.pdf.info(locked) # page_count=3, encrypted=True, …
The SDKs accept a render object or its id, and a library PDF by its name ('terms.pdf'). They send an Idempotency-Key with every call that creates a render and reuse it on a retry, so a retry after the first answer is not charged again; see idempotency for the one case it does not cover, a retry while the first call still runs.
Merge, watermark and protect return a render like POST /v1/renders does, synchronously and with status 200: download_url, page_count, units (0.5, or 0 with a test key), template: null. It shows up in the render log and under GET /v1/renders/{id}. The sources stay as they are. The new file is stored in the region of its sources, or in your key's region when every source is a library file.
Output options
Every tool that creates a render, /pdf/convert included, takes these optional fields beside its own:
| Field | Type | Default | Meaning |
|---|---|---|---|
filename | string, up to 200 characters | merged.pdf, watermarked.pdf, protected.pdf; for convert see below | Name of the download. |
access | signed or public | the workspace's setting | A signed link expires; a public one lives as long as the file. See download links. |
expires_in | seconds | the retention of your plan and workspace | Keeps the result for a shorter time; it never lengthens the retention. Test-key results are kept 24 hours at most. |
meta | object | {} | Your own values, returned with the render and in its webhooks. |
storage | false or { "key": "…" } | the workspace's connection | The copy in your own bucket, as for a render. |
merge
sources takes 2 to 50 sources: render ids and library PDFs, in any mix. They are joined in the given order, and the same source may appear more than once. The merged PDF does not carry over the sources' titles or other metadata, their tags or their attachments; use filename to name the download.
watermark
| Field | Type | Default | Meaning |
|---|---|---|---|
source | string | required | A source: render id or library PDF. |
text | string, 1 to 200 characters | one of text, image | Set in Noto Sans Bold, which is embedded in the file. It covers Latin, Greek and Cyrillic letters; text with any other character answers 400 invalid_request, so use an image for other scripts. |
image | string | one of text, image | The name of a PNG or JPEG in the file library. Another type answers 422 source_invalid, an unknown name 422 source_not_found. |
opacity | number, 0 to 1 | 0.2 | 0 is invisible, 1 opaque. |
rotation | number, −360 to 360 | text -45, image 0 | Degrees clockwise as in CSS. -45 runs from bottom left to top right, 0 is horizontal. |
color | hex colour #rrggbb (the # is optional) | a neutral grey | The colour of the text, such as #cc0000. Ignored for an image. |
The text is sized to span about 70 % of the page diagonal, but never larger than 160 pt, so a short word such as COPY stays at 160 pt. It is centred on each page, so portrait and landscape pages in one document both look right. An image is scaled to fit within 60 % of the page's width and height, keeping its aspect ratio, and centred the same way. Pass text or image: both, or neither, answer 400 invalid_request. Text and image are marked as artifacts, so a tagged PDF stays tagged.
protect
| Field | Type | Default | Meaning |
|---|---|---|---|
source | string | required | A source: render id or library PDF. |
user_password | string, 1 to 127 characters, no line breaks | none | Needed to open the document. Without it anyone can open the PDF, and only the permissions apply. |
owner_password | string, 1 to 127 characters, no line breaks | a random one | Lifts the restrictions in viewers that respect them. A random one, which nobody knows, is used when you leave it out. |
permissions | array of print, copy, modify, annotate | all four allowed | What readers may do. Everything you do not list is forbidden; an empty list [] allows nothing. |
Send at least one of the three; an empty request answers 400 invalid_request. The encryption is AES-256. Permissions are honoured by standard PDF viewers but are not a security boundary against someone who can open the file. Use a user_password for confidentiality. A protected PDF cannot be merged, watermarked or protected again (422 source_encrypted), so protect as the last step.
info
curl https://api-eu.formfeed.dev/v1/pdf/info \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "content-type: application/json" \
-d '{"source": "rnd_50136336b95245628f661fdc527bbf8a"}'
{
"source": "rnd_50136336b95245628f661fdc527bbf8a",
"page_count": 3,
"pages": [
{
"width_pt": 595.28,
"height_pt": 841.89,
"width_mm": 210,
"height_mm": 297
}
],
"encrypted": true,
"metadata": {
"title": null,
"author": null,
"subject": null,
"producer": null,
"creator": null
}
}
Returns the page count, the size of every page in points and millimetres (one entry per page; the example is shortened), whether the PDF is encrypted, and its metadata: title, author, subject, producer and creator. An encrypted PDF reports its pages, but every metadata field is null. source takes a render id or a library PDF. It is free on every plan, needs only render:read and creates no render.
Convert office documents
POST /v1/pdf/convert turns a Word, Excel, PowerPoint, OpenDocument, RTF or HTML file into a PDF render. The conversion runs in LibreOffice, not in the Chromium engine that renders templates. It takes one of two sources:
- An uploaded file. Send
multipart/form-datawith the document asfile(up to 20 MB) and the options as form fields. The file is converted and not kept; only the PDF is stored, like any render. The conversion runs in your key's region. - The output of a Word or PowerPoint template. Send JSON with
source, the id of a succeeded render whose output isdocxorpptx(see Word and PowerPoint templates). Any other render answers422 source_invalid. The conversion runs in the region where that output is stored.
- curl
- TypeScript
- Python
# an uploaded file
curl https://api-eu.formfeed.dev/v1/pdf/convert \
-H "Authorization: Bearer $FORMFEED_API_KEY" \
-F file=@offer.docx \
-F page_ranges=1-3 \
-F filename=offer.pdf
# the output of a Word template render
curl https://api-eu.formfeed.dev/v1/pdf/convert \
-H "Authorization: Bearer $FORMFEED_API_KEY" -H "content-type: application/json" \
-d '{"source": "rnd_7da09ab6cd9946b798c187c2a9350852", "filename": "offer.pdf"}'
import { readFile } from 'node:fs/promises';
import { Formfeed } from '@formfeed/sdk';
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY! });
const report = await formfeed.pdf.convert(
{ file: { data: await readFile('report.xlsx'), name: 'report.xlsx' } },
{ single_page_sheets: true, filename: 'report.pdf' },
);
// or the output of a Word template render
const offer = await formfeed.renders.create({
template: 'offer',
output: 'docx',
data: { customer: { name: 'Olvarest GmbH' }, offer: { number: 'A-1001' } },
});
const offerPdf = await formfeed.pdf.convert(offer);
with open("report.xlsx", "rb") as f:
report = client.pdf.convert(file=f.read(), file_name="report.xlsx", single_page_sheets=True, filename="report.pdf")
# or the output of a Word template render
offer = client.renders.create(
template="offer",
output="docx",
data={"customer": {"name": "Olvarest GmbH"}, "offer": {"number": "A-1001"}},
)
offer_pdf = client.pdf.convert(offer)
From the command line, npx formfeed pdf convert offer.docx --page-ranges 1-3 uploads the file and saves offer.pdf; a render id in place of the file converts that render's output. In the app, Office to PDF on the Renders page converts an uploaded file.
| Field | Type | Default | Meaning |
|---|---|---|---|
file | file | one of file, source | The document, up to 20 MB (413 file_too_large above that). Multipart only. |
source | string | one of file, source | Render id of a succeeded render with output docx or pptx. JSON only. |
page_ranges | string, up to 100 characters | all pages | Pages to convert, such as 1-3,5: numbers, ranges with -, separated by ,, no spaces. |
landscape | boolean | false | Landscape for spreadsheets and other documents without their own page setup. |
single_page_sheets | boolean | false | Each spreadsheet sheet on one page. |
filename | string, up to 200 characters | the uploaded name with .pdf; for source, <render id>.pdf | Name of the PDF. |
access, expires_in, meta, storage | The output options. In a multipart request, meta is a JSON object as text (up to 16,000 characters), storage is JSON text too (false or {"key": "…"}), and the booleans are true or false. |
Accepted formats. The type is read from the file's content, never from its name:
- Word processing: DOCX, DOC, ODT, RTF
- Spreadsheets: XLSX, XLS, ODS
- Presentations: PPTX, PPT, ODP
- HTML, including Word's "save as web page" and report exports that carry a
.docname. The converter loads nothing from the web: references to images, stylesheets and other files outside the document are removed before the conversion, so they stay empty. For HTML you control, a render with anhtmlsource gives a better result.
Macro-enabled files, Office templates and slide shows (.dotx, .xltx, .potx, .ppsx), binary workbooks (.xlsb) and any other type answer 415 file_type_unsupported. Password-protected documents answer 422 source_encrypted, damaged files 422 office_document_invalid, and a document that unpacks to more than the converter takes (more than 2,000 parts, more than 200 MB in all, or a single part above 20 MB) 422 office_document_too_large.
Fonts. A font the document uses but neither embeds nor finds among the converter's fonts is replaced. Calibri, Cambria, Arial and Times New Roman have metric-compatible substitutes (Carlito, Caladea, Liberation), so lines and pages break the same; other fonts can change the layout. Embed fonts in Word (File › Options › Save › Embed fonts in the file) for an exact result.
Synchronous. The response is the finished render (output: "pdf", page_count, units); a conversion never answers 202. It may take up to 60 seconds; a document that takes longer answers 504 render_timeout. Chain /pdf/protect, /pdf/watermark or /pdf/merge on the returned id.
When the converter is busy, the API answers 503 office_unavailable with a Retry-After header, and nothing is charged. Retry after that many seconds with the same idempotency key; the SDKs do this for you.
Errors
| Code | Status | When |
|---|---|---|
invalid_request | 400 | A wrong or missing field, too few sources, sources stored in different regions, post on a non-PDF output, text and image together (or neither), watermark text the font cannot draw, protect without a password or permissions, a password longer than 127 characters or with a line break, post.password with an e-invoice, merge_after with pdf.ua. |
quota_exceeded | 402 | A live key whose units are used up, as for renders. |
forbidden | 403 | The plan does not include post-processing ("feature": "post_processing") or office conversion ("feature": "office"), or the key lacks the scope. |
file_too_large | 413 | The uploaded document is larger than 20 MB. |
file_type_unsupported | 415 | The upload is not an accepted document, or it contains macros. |
source_not_found | 422 | A render id does not exist in this workspace, its output was deleted or has expired, or no library file has that name. The problem names the field, the position in index and, for a render, its render_id. |
source_invalid | 422 | The render failed or has not finished, it produced something other than a PDF, the library file is not a PDF (or, for a watermark, not a PNG or JPEG), or the file cannot be read as a PDF. For /pdf/convert: the render's output is not docx or pptx, or the converter refused the document. |
source_encrypted | 422 | The source is password-protected. Protect as the last step. |
page_limit_exceeded | 422 | The merged or converted document has more pages than the plan allows. |
output_too_large | 422 | The result is larger than the plan's output size. |
office_document_invalid | 422 | The document is damaged or fails the safety checks. |
office_document_too_large | 422 | The document unpacks to more than the converter takes. |
office_conversion_failed | 422 | LibreOffice could not convert the document. Open it in its application, save it again and retry. |
tool_failed | 500 | The PDF could not be processed. Retry, or contact support with the request_id. |
office_unavailable | 503 | The converter is busy or not reachable. Retry after Retry-After seconds. |
region_unavailable | 503 | The region is busy or restarting. Retry after Retry-After seconds. |
render_timeout | 504 | A conversion did not finish within 60 seconds. |
A call that fails while the PDF is being processed is recorded as a failed render in the render log and costs nothing. A request refused before that (a wrong field, an unknown source, the plan) leaves no render behind.