PDFMint API reference

One HTTP call turns HTML, Markdown, a URL or a saved template into a PDF, and the bytes come back in the same response. No template editor, no second request to fetch the file.

Base URLhttps://pdf.mintapis.com
AuthAuthorization: Bearer pm_live_…
Free plan10 documents per month, no card

Your first PDF #

Create an account at /signup — the API key is shown immediately, with no email confirmation, and the free plan’s 10 documents are there straight away. Then paste this into a terminal:

curl -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<h1>Hello from PDFMint</h1>","options":{"margin":"20mm"}}' \
  -o hello.pdf

That is the whole thing. hello.pdf is on disk. The response also carries what happened, in headers:

response headers
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="document.pdf"
X-PDFMint-Pages: 1
X-PDFMint-Duration-Ms: 274
X-PDFMint-Credits-Remaining: 9
X-PDFMint-Credits-Limit: 10

Measured on 2026-08-23 against the live API: a one-page Markdown document rendered in 123–152 ms of server time (0.55–0.64 s wall clock from a laptop in Europe, network included). A one-page HTML document with a margin: 274 ms.

The same call, four ways #

curl -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<h1>Hello from PDFMint</h1>",
        "options": { "format": "A4", "margin": "20mm", "pageNumbers": true }
      }' \
  -o hello.pdf

Authentication #

Every /v1/* request needs an API key. Keys start with pm_live_ and are created for you the moment you sign up.

  1. Sign up at /signup — email and a password of at least 8 characters. No card.
  2. The key is shown once on the dashboard right after signing up. Store it.
  3. Send it on every request.
Authorization: Bearer pm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

An X-API-Key: pm_live_… header works too, if a client makes that easier. Both are accepted on every endpoint.

Testing a key #

GET/v1/me is the credential-test endpoint. It costs no credits, and the n8n credential uses exactly this call to tell you whether your key works before you build anything.

curl -s https://pdf.mintapis.com/v1/me \
  -H "Authorization: Bearer $PDFMINT_API_KEY"
200 OK
{
  "email": "you@example.com",
  "plan": "free",
  "plan_name": "Free",
  "credits_limit": 10,
  "credits_used": 1,
  "credits_remaining": 9,
  "period_resets_at": "2026-09-01T00:00:00.000Z",
  "dashboard_url": "https://pdf.mintapis.com/dashboard"
}

Additional keys #

POST/v1/keys issues another key on the same account — useful when you want one per environment and want to be able to revoke them separately. label is optional (defaults to default, truncated to 40 characters). Keys can also be created and listed on the dashboard.

curl -s -X POST https://pdf.mintapis.com/v1/keys \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"label":"n8n"}'

# {"api_key":"pm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"}

GET/v1/keys lists the active keys on the account — prefix, label, when it was created and when it was last used. Never the key itself: it is stored as a hash and cannot be read back by anyone, including support.

DELETE/v1/keys/{key_prefix} revokes one key. It stops working on the very next request — there is no cache to wait for — and any call still using it gets 401 invalid_api_key. Use the key_prefix exactly as GET /v1/keys reports it. This is what to reach for the moment a key leaks: create a replacement, move your workflows over, then revoke the old one. The dashboard has a Revoke button for the same thing.

curl -s https://pdf.mintapis.com/v1/keys \
  -H "Authorization: Bearer $PDFMINT_API_KEY"

# {"keys":[{"key_prefix":"pm_live_ab12cd34","label":"n8n","created_at":"...","last_used_at":"..."}]}

curl -s -X DELETE https://pdf.mintapis.com/v1/keys/pm_live_ab12cd34 \
  -H "Authorization: Bearer $PDFMINT_API_KEY"

# {"revoked":"pm_live_ab12cd34"}

The account's last remaining key cannot be revoked — that would lock you out of your own API with no way back in — so it answers 409 last_key. Create the replacement first.

Two failure modes to expect. No key at all gives 401 missing_api_key. A key that does not start with pm_live_, or one that was revoked, gives 401 invalid_api_key. Both name the problem in error.hint.

Quota & credits #

One credit is one document. A PDF, an image and a merge each cost 1, no matter how many pages come out. GET /v1/me and GET /v1/templates are free.

The free plan is 10 documents a month, permanently and with no card. It is sized to get a first PDF out of the API, not to run a workflow on. Checkout for a paid plan runs on Stripe; a VAT ID can optionally be added there if you need one on the invoice.

PlanPriceDocuments / month
Free$010
Starter$95,000
Pro$2950,000
Scale$99250,000
  • The credit is reserved before rendering and refunded automatically if the render fails, so a broken selector, an unreachable URL or a timeout does not cost you anything. One honest exception: if the renderer process is killed outright — a document large enough to exhaust its memory can do this — a synchronous request dies with it and the refund cannot run, because nothing is left to run it. Send documents that large with "async": true: a job that dies this way is retried once, then failed with renderer_crashed and refunded when the service comes back. If a synchronous call ever costs you a credit it should not have, quote the request_id.
  • Every successful render returns X-PDFMint-Credits-Remaining and X-PDFMint-Credits-Limit. JSON output modes include credits_remaining in the body.
  • The window is a calendar month in UTC. It rolls over on the 1st; period_resets_at on /v1/me gives you the exact instant.

When the quota is gone the API answers 402 quota_exceeded:

402 Payment Required
{
  "error": {
    "code": "quota_exceeded",
    "message": "You have used all 10 documents included in your free plan this month.",
    "hint": "The quota resets on the 1st of next month. To raise it now, upgrade at /dashboard.",
    "docs": "https://pdf.mintapis.com/docs#quota",
    "details": { "plan": "free", "credits_used": 10, "credits_limit": 10 },
    "request_id": "…"
  }
}

An account whose allowance is 0 — which is not what a new signup gets — is told 402 plan_required instead, because never having had a quota is a different event from spending one.

Choosing an input #

Send exactly one of these four fields. Sending none gives missing_content; sending two gives ambiguous_content.

FieldTypeWhat it does
htmlstringRenders the markup as-is. A full document or a bare fragment both work — Chrome wraps a fragment for you. Your own <style> is the only stylesheet applied.
markdownstringConverts GitHub-flavoured Markdown and applies a print stylesheet. The short path for LLM output.
urlstringLoads a publicly reachable page and prints it. See Rendering a URL for what is and is not reachable.
templatestringThe name of a template saved on your account, filled with data. PDF only — not supported by /v1/image.

Placeholders #

Any of html, markdown and template can carry {{placeholder}} markers. Pass the values in data — either a JSON object, or a string containing JSON. The syntax is a small mustache subset:

SyntaxMeaning
{{name}}Insert the value, HTML-escaped
{{customer.name}}Dotted path into a nested object
{{{name}}}Insert the value raw, without escaping — for markup you built yourself
{{#lines}}…{{/lines}}Repeat the block once per array item; inside it, keys resolve against the item first, then the outer object. Also works as an "if" for a truthy non-array value.
{{^lines}}…{{/lines}}Render only when the value is missing, falsy or an empty array
{{.}}The current item, inside a section over an array of strings or numbers

Placeholders never fail silently. If a marker has no matching value, the render still succeeds but the response carries X-PDFMint-Warning: 1 placeholder(s) had no value and rendered empty: missing (and a warnings array in the JSON output modes). Set "strict": true to turn that into a 400 unresolved_placeholders instead — the error lists every missing name in details.unresolved. The same applies when data is left out entirely, in which case the marker is printed on the page as you wrote it and details.data_supplied is false. That is one of four checks strict mode runs; the others catch a document that renders blank or prints its own markup.

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<h1>{{title}}</h1><p>{{missing}}</p>",
        "data": { "title": "Hi" },
        "strict": true
      }'
400 Bad Request
{
  "error": {
    "code": "unresolved_placeholders",
    "message": "The template uses 1 placeholder that \"data\" does not provide: missing.",
    "hint": "Check the spelling against your data, or set \"strict\": false to render them as empty instead.",
    "docs": "https://pdf.mintapis.com/docs#strict",
    "details": { "unresolved": ["missing"] },
    "request_id": "5490af9e4a650d19"
  }
}

Strict mode #

The complaint every PDF automation eventually produces is not an error. It is a green tick with a blank file behind it, or forty-seven pages of stylesheet, or {{client_name}} printed where the customer's name should be. Nothing failed, so nothing was retried, and the first person to notice is the customer.

"strict": true turns those into a 4xx with a request id, so the workflow stops where the mistake is. It is a per-request boolean, off by default, accepted on /v1/pdf, /v1/image and /v1/merge. The strings "true" and "false", and 1/0, are accepted too — automation tools send booleans as strings all the time. Anything else is invalid_option.

A rejected render costs nothing. The credit is spent when the call starts and refunded when strict refuses, on every endpoint. GET /v1/me will show the same credits_used before and after.

What it checks #

CheckEndpointsErrorFires when
Unresolved placeholders/v1/pdf, /v1/imageunresolved_placeholdersA {{marker}} in html, markdown or a template had no matching value in dataor no data was sent at all. Checked before anything is rendered, so it costs no render time.
Unprintable value/v1/pdf, /v1/imageinvalid_placeholder_valueA {{marker}} resolved to an object or an array of objects, so it would print as [object Object].
Blank document/v1/pdf, /v1/imageblank_documentThe page laid out with no visible text, no image, canvas, SVG, video or iframe with a real box, and no painted area at all.
Markup printed as text/v1/pdf, /v1/imageunrendered_markupThe visible text is overwhelmingly raw CSS, or literal <tags>, outside any code sample. Your markup was escaped on the way in.
Empty merge/v1/mergeblank_documentThe merged file has no pages, or one input was a valid PDF that contributed no pages. The message names the index.

With strict off, every one of these is still detected — it just arrives as a warning instead: the X-PDFMint-Warning response header, and a warnings array in the JSON output modes. Nothing is ever silently swallowed; strict only decides whether the finding is fatal.

Everything on the page is checked #

Placeholders are filled, and counted, in every piece of content a request carries — not only the body:

  • html, markdown, or the stored HTML of a template
  • headerHtml and footerHtml, including the ones a template stored with itself
  • watermark.text, which is stamped raw rather than HTML-escaped, so Smith & Sons prints as written

A header is content: an invoice number in a letterhead is exactly as wrong as one in the body, and it repeats on every page.

A section counts too. {{#lines}}…{{/lines}} where lines is absent from the data is reported — that is the line-item table disappearing because the field was renamed upstream. lines: [], present and empty, is not reported: an invoice with no extras is a real invoice. An inverted section {{^lines}} is never reported, because handling the missing case is the whole purpose of the syntax.

When no data was sent at all #

Worth stating on its own, because it is the complaint this feature was built for: a document that reaches the paper still saying {{client_name}}. That happens when the field carrying your values never arrived — the mapping was renamed, the step before returned nothing, or data was simply forgotten. The markers are then printed exactly as written, which is different from a marker whose value was missing (that one renders empty).

Both are caught. details.data_supplied tells you which happened, and the message says so in words:

RequestWith strictWithout
{"html": "Hi {{name}}", "data": {}}400, data_supplied: truerenders Hi , warning header
{"html": "Hi {{name}}"}400, data_supplied: falserenders Hi {{name}}, warning header
{"html": "Hi {{name}}", "data": null}400, data_supplied: falserenders Hi {{name}}, warning header
400 Bad Request
{
  "error": {
    "code": "unresolved_placeholders",
    "message": "The document still contains 1 unfilled placeholder and the request sent no \"data\": client_name.",
    "hint": "Send the values in \"data\", for example {\"data\": {\"client_name\": \"…\"}}. With no \"data\" at all the marker is printed on the page exactly as you wrote it.",
    "docs": "https://pdf.mintapis.com/docs#strict",
    "details": { "unresolved": ["client_name"], "data_supplied": false },
    "request_id": "ed755bf44f021edd"
  }
}

Without strict the bytes are exactly what they always were — nothing is substituted, nothing is removed — and the finding arrives only as X-PDFMint-Warning. /v1/image never fills placeholders at all, so there the check is the only thing standing between you and a screenshot of your own template syntax.

If you print {{…}} on purpose — documentation about templating, or Vue/Angular/Handlebars markup you want to survive onto the page — leave strict off. You will get one warning header per call, which costs nothing to ignore. The alternative, staying quiet, means the person who mistyped a field name never finds out.

How "blank" is measured #

Inside the same Chromium that prints the file, after the print stylesheet has been applied — so a page that hides itself on screen and shows itself on paper is measured as it will print. Three numbers come back, and all three must be zero before anything is refused:

  • Visible text. document.body.innerText, trimmed. It already excludes display:none, visibility:hidden and anything a @media print rule removed.
  • Images. Every img, canvas, svg, video, object, embed, iframe and picture with a layout box of at least 1 × 1 px. An image-only document — a chart, a scan, a QR code — is a real document and is never refused.
  • Painted area. Any element with a box that has a background colour, a background image, a border, an outline, a box shadow, or CSS-generated ::before/::after content, plus the backgrounds of <html> and <body> themselves.

The painted-area scan only runs when there is no text and no image, and only on documents of up to 5000 elements. A larger one is reported as "could not tell" and is never refused on this check.

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "html": "<div class=\"invoice\"></div>", "strict": true }'
400 Bad Request
{
  "error": {
    "code": "blank_document",
    "message": "The rendered document body contains no visible text and no images, so the pages would come out blank.",
    "hint": "Check that \"data\" really filled the template, that anything built by JavaScript has finished (use \"waitFor\"), and that a print stylesheet is not hiding the content. A header or footer does not count as content. Send \"strict\": false to receive the blank document anyway.",
    "docs": "https://pdf.mintapis.com/docs#strict",
    "details": { "visible_text_chars": 0, "images": 0, "painted_elements": 0 },
    "request_id": "cde4e87a40dae5dd"
  }
}

A header or footer is not counted as content: the check is about the body of the document. A page-numbers footer on an otherwise empty body is still refused, and details.header_or_footer is true so you can tell that case apart.

How "markup printed as text" is measured #

Text inside <pre>, <code>, <samp>, <kbd>, <xmp> and <textarea> is removed first — a code sample is the one place a real document shows CSS and tags on purpose. What is left has to trip one of two rules, both deliberately severe:

RuleFires when all of these hold
Leaked stylesheetat least 200 characters of text remain, at least 3 CSS declarations are present, and 85% or more of the non-whitespace characters sit inside a CSS rule
Escaped markupat least 8 literal HTML tags are visible, at least 3 of them are closing tags, and the tags themselves make up 30% or more of the non-whitespace characters
400 Bad Request
{
  "error": {
    "code": "unrendered_markup",
    "message": "The document shows 24 HTML tags as literal text — the markup was printed instead of being applied.",
    "hint": "The markup was almost certainly HTML-escaped somewhere on the way here, or a <style> tag lost its brackets, so the source arrived as text. In Zapier or Make, map the raw field rather than a formatted one. Send \"strict\": false to print it as it is.",
    "docs": "https://pdf.mintapis.com/docs#strict",
    "details": {
      "visible_text_chars": 233,
      "analysed_chars": 233,
      "markup_ratio": 0.735,
      "tag_ratio": 0.574,
      "css_declarations": 2,
      "literal_tags": 24
    },
    "request_id": "d02f60b88857d289"
  }
}

The one document this gets wrong. A page whose visible content really is markup — an XML sample, a stylesheet listing — and which does not put it in a <pre> or <code> element looks identical to the bug, because at that point there is nothing left to tell them apart. Wrap intentional markup in <pre> (you want the monospace anyway) and it is exempt, or leave strict off for that call. Prose that merely mentions a few tag names is well under the thresholds and is not affected.

A value that cannot be printed #

Triggers in Zapier and Make hand back nested objects. {{customer}} where customer is {"name": "Ada"} puts the literal text [object Object] on the invoice. Strict refuses it and names the path that would have worked; without strict it renders as it always did and you get a warning.

400 Bad Request
{
  "error": {
    "code": "invalid_placeholder_value",
    "message": "\"customer\" is an object, so it would print as \"[object Object]\".",
    "hint": "A placeholder can only print text or a number. Use a path into the object, such as {{customer.name}}, or build the string before you send it. Triggers in Zapier and Make hand back nested objects, which is where this usually comes from.",
    "docs": "https://pdf.mintapis.com/docs#strict",
    "details": { "unprintable": [ { "name": "customer", "type": "object" } ] },
    "request_id": "84d848349db60770"
  }
}

An array of plain values is not refused: {{tags}} over ["red", "blue"] prints red,blue, which is what the caller meant. Only an object, or an array containing objects, is a mistake every time.

Strict on a merge #

An input that is not a readable PDF is always invalid_pdf, strict or not. What strict adds is the input that is a valid PDF and contains no pages — it merges cleanly, contributes nothing, and the result looks like a success.

400 Bad Request
{
  "error": {
    "code": "blank_document",
    "message": "files[1] is a valid PDF with no pages, so it contributed nothing to the merge.",
    "hint": "That input is empty — check what produced it. Send \"strict\": false to merge the remaining inputs anyway.",
    "docs": "https://pdf.mintapis.com/docs#strict",
    "details": { "input_index": 1, "pages": 0, "page_counts": [1, 0] },
    "request_id": "6c81bb12ec017d20"
  }
}

What strict does not detect #

Worth reading before you rely on it. Strict is a floor, not a proofreader:

  • A page with any content on it at all. One heading is enough to pass. If your template rendered its title and lost the table under it, strict says nothing.
  • Wrong values. A placeholder that resolved to the wrong customer, an empty string in data, or a total that does not add up all render fine. Only a missing value is caught.
  • White text on white paper, content pushed off the page, or a z-index covering everything. Those are painted, so they are not blank.
  • Layout damage. A missing font, a table running off the right edge, a chart that drew nothing but kept its canvas box.
  • Pages after the first. The measurement is of the document, not per page, so a document that turns blank on page 4 is not caught.
  • Content only in a header or footer counts as blank, as described above.
  • An inverted section that fired by mistake. {{^lines}}Nothing to show{{/lines}} printing when there really were lines is indistinguishable from the same block printing on purpose.
  • The fields inside a section. A template reports lines, not lines[].description — the inner names repeat per item and are only resolved once there are items.
  • A URL you asked us to render. Placeholders are never applied to a page fetched by url; whatever that page serves is what prints.
  • Encrypted or merged input contents. On /v1/merge only page counts are checked; nothing looks inside the pages.

For anything past that floor, render with "output": "url" and check pages and size in the response, or fetch the file and assert on it in your own workflow.

Rendering a URL #

PDFMint fetches the page from our servers, not from yours. That has two consequences worth knowing before you debug anything.

The page must be reachable from the public internet. Anything behind a login, a VPN, a firewall or localhost is not. The fix is always the same: fetch the page in your own workflow, where you have the session, and send the resulting markup in html.

Private and internal addresses are refused, before any request is made, to stop the renderer being used to reach someone's internal network. Hostnames are resolved through DNS first, so evil.example.com → 169.254.169.254 is caught too. The same guard applies to every image, stylesheet and font the page tries to load.

RefusedError code
Anything that is not http:// or https://file:, data:, ftp:unsupported_url_scheme
Not parseable as a URL (no scheme, spaces, …)invalid_url
localhost, localhost.localdomain, metadata.google.internalprivate_address_blocked
IPv4 in 10/8, 127/8, 0/8, 169.254/16, 172.16–31, 192.168/16, 100.64–127 (CGNAT), or 224+ (multicast/reserved) — literal or via DNSprivate_address_blocked
IPv6 ::1, ::, fe80::/10, fc00::/7, and IPv4-mapped equivalentsprivate_address_blocked
A hostname that does not resolvedns_failed
A host that resolves but refuses, times out, or presents a bad certificateurl_unreachable
A page that answers with HTTP 400 or aboveurl_http_error

Sending headers with the request #

The top-level headers object is attached to every request the browser makes for that render — the page itself and its subresources. Use it for an API token or a Cookie on a page that accepts one.

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "url": "https://example.com",
        "headers": { "X-Report-Token": "abc123" },
        "options": { "format": "A4", "margin": "15mm", "pageNumbers": true },
        "output": "base64"
      }'

# {"filename":"document.pdf","pages":1,"size":21097,
#  "duration_ms":292,"credits_remaining":285,"base64":"JVBERi0…"}

Markdown styling #

When you send markdown, PDFMint parses it as GitHub-flavoured Markdown — tables with |---:| column alignment, fenced code, task lists, strikethrough and autolinks; a single newline is not a line break — and wraps it in a print stylesheet built for paper. GitHub's later extensions are not implemented: footnotes ([^1]), alert blockquotes (> [!NOTE]) and :emoji: shortcodes all come through as literal text, and a footnote definition short enough to look like a link definition turns the marker into a broken link. If you need footnotes, write that part as html. Everything that is supported needs no CSS from you:

  • 11 pt body text at 1.6 line height, Inter for prose and JetBrains Mono for code — both installed in the render container, so nothing waits on a font download.
  • Table headers repeat on every page (thead { display: table-header-group }) — up to a limit that is Chromium's, not ours. A <thead> taller than a quarter of the page's text area is drawn on the first page only, and every page after it gets no column names at all; no CSS turns that back on. On A4 with the default 12 mm margins the ceiling is 258 px, about 68 mm — measured, not guessed. A Markdown table has a single header row, so you only reach it if a header cell wraps to eleven lines or more. In hand-written html a multi-row <thead> gets there easily: keep it under a quarter of the page, or repeat the column names yourself.
  • No row is split across a page break (tr { break-inside: avoid }) — a row that will not fit moves to the next page whole. The one exception is a row taller than the page itself, which has to break somewhere.
  • Column alignment is honoured. |---:| right-aligns a column and |:---:| centres it, so a money column lines up on the right instead of being flattened to the left by the built-in table rules.
  • Headings never orphan at the foot of a page (break-after: avoid on every heading level).
  • Code blocks wrap instead of clipping (white-space: pre-wrap) and stay on one page where they fit (break-inside: avoid).
  • GitHub task lists (- [x] done) get a real drawn checkbox — no stray bullet, and no flat grey form control, which is what a browser widget turns into when printed.
  • Images are capped at max-width: 100%; blockquotes, rules and zebra-striped tables are styled; links are coloured and break on long URLs.
  • Noto CJK and Noto Color Emoji are installed, so Chinese, Japanese, Korean, Greek, Cyrillic and emoji come out as glyphs rather than boxes — verified on this deployment.
FieldTypeDefaultDescription
cssstringExtra CSS appended after the built-in stylesheet, so your rules win. Markdown source only.
googleFontsstringA Google Fonts family spec, exactly as it appears in the Google URL — e.g. Playfair Display:wght@400;700. Adds the stylesheet link for you. Markdown source only.
metadata.title
title
stringDocumentBecomes the HTML <title> — which is what {title} in a header/footer template prints.

css and googleFonts only apply to the markdown source. With html you already control the whole document, so put your styles in a <style> tag or a <link> to a public stylesheet.

Output modes #

The top-level output field decides how you get the file back.

ValueResponseUse it when
binary
default
application/pdf (or image/png / image/jpeg) bytes, with Content-Disposition: attachment. Metadata rides in the X-PDFMint-* headers.Almost always. No size limit, one round trip.
urlJSON with a temporary hosted link: { filename, pages, size, url, expires_in_minutes, duration_ms, credits_remaining }You want to email or Slack a link instead of moving bytes. Capped at 20 MB per file.
base64The same JSON plus a base64 string. Not supported by /v1/merge.Your client cannot deal with a binary body.

Hosted files #

expiresInMinutes (alias expiration) controls how long the link lives: default 60 minutes, minimum 1, maximum 10080 (7 days). Values above the maximum are clamped, not rejected. Expired files are deleted, and the link then shows an "expired" page rather than a stale document.

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"html":"<h1>Hosted</h1>","output":"url","expiresInMinutes":15}'
200 OK
{
  "filename": "document.pdf",
  "pages": 1,
  "size": 6668,
  "url": "https://pdf.mintapis.com/f/8DWbNOQ4SKr4ClZ8_36k10pM",
  "expires_in_minutes": 15,
  "duration_ms": 133,
  "credits_remaining": 296
}

A generated file larger than 20 MB cannot be hosted — output: "url" returns 400 file_too_large. The default binary mode has no such limit, so switch to it for very large documents.

Timeouts #

timeout (alias timeoutMs) is the whole budget for one render, in milliseconds: loading, waiting, and printing.

FieldTypeDefaultRange
timeoutnumber (ms)30000Minimum 1000. Anything above 120000 is silently clamped to 120000; anything below 1000 is rejected with invalid_option.

Running out of budget gives 504 render_timeout:

POST /v1/pdf → 504
{
  "error": {
    "code": "render_timeout",
    "message": "Rendering timed out while loading the URL.",
    "hint": "Raise \"timeout\", or set waitFor to a fixed number of milliseconds instead of \"networkidle\" if the page keeps polling in the background.",
    "docs": "https://pdf.mintapis.com/docs#timeout",
    "request_id": "935b22fbab54b64f"
  }
}

The commonest cause is not a slow page but waitFor: "networkidle" on a page that polls forever in the background — it can never go idle. Use a selector or a fixed number of milliseconds instead.

waitFor #

By default PDFMint already waits for the load event, for every declared web font to download, and for every <img> to finish (or fail) — up to 5 s per image and 8 s for the font set. So you only need waitFor for content that JavaScript produces after that: charts, maps, tables filled by fetch.

ValueBehaviour
2000A number (or a numeric string): wait that many milliseconds. Blunt, but never fails. Capped at the remaining timeout budget.
"#chart-ready"Any other string is treated as a CSS selector and waited on until the element is attached to the DOM. This is the good one — have your chart callback append a marker element.
"networkidle"Wait until there has been no network activity for 500 ms. Convenient, but a page that polls will never reach it.
"load" / "domcontentloaded"Wait for that lifecycle event. Rarely needed — load has already happened.

A selector that never appears gives 504 wait_for_timeout naming the selector, so you find out it was a typo instead of getting a half-drawn page:

400 Bad Request
{
  "error": {
    "code": "wait_for_timeout",
    "message": "Timed out waiting for \"#never\" to appear on the page.",
    "hint": "Check the selector, or use a number of milliseconds instead (for example waitFor: 1000).",
    "docs": "https://pdf.mintapis.com/docs#waitfor",
    "request_id": "8c78de2a7d723752"
  }
}

A failed waitFor is a failed render, so the credit is refunded.

Password protection #

Set password and the PDF comes back encrypted with AES-256. It is included on every plan, free ones too.

FieldTypeDefaultDescription
passwordstringThe user password: required to open the document. Setting it turns encryption on.
ownerPasswordstringrandomThe owner password, which governs permissions. If you do not set one, a random secret is generated so nobody can lift the restrictions.
allowPrintingbooleantrueWhether the reader may print the document.
allowCopyingbooleanfalseWhether the reader may extract text and images.

An encrypted PDF cannot be counted, so X-PDFMint-Pages is omitted and pages comes back null in the JSON output modes. Everything else (duration, credits, size) is still reported.

See the recipe for a runnable example.

Watermarks #

Set watermark and diagonal text is stamped across every page. It is applied after rendering, so it also covers pages that came from a merge, and it sits above the content rather than behind it.

A bare string is the shorthand for { "text": … }:

"watermark": "DRAFT"

"watermark": {
  "text": "CONFIDENTIAL",
  "color": "#c0392b",
  "opacity": 0.12,
  "rotation": 30,
  "font": "times-roman"
}
FieldTypeDefaultDescription
textstringrequiredThe words to stamp. Missing or blank gives invalid_option.
fontstringhelvetica-boldOne of helvetica, helvetica-bold, times-roman, courier. An unrecognised name falls back to helvetica-bold rather than failing.
fontSizenumber (pt)autoLeft out, the size is computed so the rotated text spans 85% of each page — so it fits A4 and Letter alike, and long words do not overflow. Never smaller than 14 pt.
colorstring#9aa3b2A six-digit hex colour, with or without the #. Anything unparseable falls back to mid-grey.
opacitynumber0.180.01–1. Values outside the range are clamped, not rejected.
rotationnumber (deg)45Counter-clockwise from horizontal. Use 0 for a straight banner across the middle.

The watermark is real text in the PDF, not a raster overlay — it stays crisp at any zoom, and it is selectable. If you need it to be un-removable, combine it with password protection and allowCopying: false.

Async & webhooks #

A slow page — a heavy dashboard, a chart that needs waitFor, a hundred-page report — can hold an HTTP connection open for a minute or more. Some clients time out first. Send "async": true (or a webhookUrl, which implies it) and POST /v1/pdf answers 202 straight away with a job id:

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<h1>Async report</h1>",
        "filename": "async.pdf",
        "async": true,
        "expiresInMinutes": 120
      }'
202 Accepted
{
  "job_id": "job_z25FB4zf4nb55i7Z",
  "status": "queued",
  "status_url": "https://pdf.mintapis.com/v1/jobs/job_z25FB4zf4nb55i7Z",
  "credits_remaining": 272
}

The request body is validated before the 202 — a bad option, an unknown template or a blocked URL still comes back as a normal 400, so you never queue a job that cannot possibly succeed. The credit is taken at enqueue time and refunded if the render fails.

An async job always produces a hosted file, whatever output you asked for — there is no open connection to stream bytes down. expiresInMinutes works as usual (default 60, max 7 days), so give yourself enough time to fetch it.

GET/v1/jobs/{id} #

Polls one job. Free, and scoped to the account that created it — someone else's job id is 404 job_not_found, as is a job that has been reaped.

statusMeaning
queuedAccepted, not started. started_at and finished_at are null.
runningA worker has claimed it; started_at is set.
succeededThe result fields are merged into the response: url, filename, pages, size, duration_ms, expires_in_minutes.
failedAn error object with code, message and sometimes hint — the same codes as the synchronous API.
cancelledYou cancelled it with DELETE /v1/jobs/{id}. The credit was given back.
# JOB_ID comes from the 202 above:
#   JOB_ID=$(curl -s -X POST $API/v1/pdf -H "Authorization: Bearer $PDFMINT_API_KEY" \
#     -H "Content-Type: application/json" \
#     -d '{"markdown":"# Report","async":true}' | jq -r .job_id)

curl -s https://pdf.mintapis.com/v1/jobs/$JOB_ID \
  -H "Authorization: Bearer $PDFMINT_API_KEY"
200 OK — a little over a second later
{
  "job_id": "job_z25FB4zf4nb55i7Z",
  "kind": "pdf",
  "status": "succeeded",
  "created_at": "2026-08-23T15:08:39.278Z",
  "started_at": "2026-08-23T15:08:39.364Z",
  "finished_at": "2026-08-23T15:08:40.454Z",
  "attempts": 0,
  "url": "https://pdf.mintapis.com/f/9xKR-JWGbkear8iiGJAVnBn9",
  "size": 8042,
  "pages": 1,
  "filename": "async.pdf",
  "duration_ms": 56,
  "expires_in_minutes": 120
}

DELETE/v1/jobs/{id} #

Cancels a job that has not finished, and gives the credit back. A queued job is stopped before it ever renders. A job that is already running is inside Chromium in some worker, and this cannot reach in and kill that render — what it does do is discard the result, stop the stalled-job recovery from ever picking it up again, and refund you. That matters for the one case this exists for: a document big enough to exhaust the renderer used to sit running forever, with no way to stop it short of an operator editing the database.

curl -s -X DELETE https://pdf.mintapis.com/v1/jobs/$JOB_ID \
  -H "Authorization: Bearer $PDFMINT_API_KEY"

# {"job_id":"job_z25FB4zf4nb55i7Z","status":"cancelled"}

A job that has already finished answers 409 job_already_finished and names the state it is in, rather than pretending to cancel something that is over. Another account's job is 404 job_not_found, the same as it is for GET.

Webhook delivery #

Give a webhookUrl and you do not have to poll at all. When the job finishes we POST JSON to it with Content-Type: application/json and User-Agent: PDFMint-Webhook/1.

success payload
{
  "job_id": "job_z25FB4zf4nb55i7Z",
  "status": "succeeded",
  "filename": "async.pdf",
  "pages": 1,
  "size": 8042,
  "url": "https://pdf.mintapis.com/f/9xKR-JWGbkear8iiGJAVnBn9",
  "expires_in_minutes": 120,
  "duration_ms": 56
}
failure payload
{
  "job_id": "job_…",
  "status": "failed",
  "error": {
    "code": "wait_for_timeout",
    "message": "Timed out waiting for \"#chart-ready\" to appear on the page.",
    "hint": "Check the selector, or use a number of milliseconds instead (for example waitFor: 1000)."
  }
}
  • The webhook URL goes through the same public-address checks as a rendered URL. In n8n that means the Webhook node's Production URL, never localhost.
  • Answer with any 2xx. Anything else — or no answer within 15 seconds — is a failed attempt.
  • Up to 3 attempts, backing off 4 s then 8 s. attempts on the job record tells you how many were made.
  • If all three fail the job is still succeeded and the file still exists — poll /v1/jobs/{id} to recover it.
  • Finished job records are deleted 7 days after they finish.

The queue lives in the database, not in process memory, so a redeploy mid-job does not lose it: a job left running for more than 10 minutes is requeued when the service comes back.

Verifying a callback #

Anything that learns your webhook URL could otherwise POST a fake “succeeded” to it. Every callback is signed, so you can tell ours from theirs:

X-PDFMint-Timestamp: 1787500123
X-PDFMint-Job-Id: job_kx5zzjIJ2fy-vN-U
X-PDFMint-Signature: sha256=9f2c…

The signature is HMAC-SHA256(secret, "<timestamp>." + rawBody), hex-encoded. The secret is per account and is shown on your dashboard. Verify against the raw body, before any JSON parsing, and compare in constant time:

Node.js
import crypto from 'node:crypto';

// express.raw({ type: 'application/json' }) so req.body is a Buffer
app.post('/pdf-ready', express.raw({ type: 'application/json' }), (req, res) => {
  const timestamp = req.get('X-PDFMint-Timestamp');
  const signature = req.get('X-PDFMint-Signature') || '';

  // Reject anything older than five minutes, so a captured callback cannot be replayed.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return res.sendStatus(400);

  const expected = 'sha256=' + crypto
    .createHmac('sha256', process.env.PDFMINT_WEBHOOK_SECRET)
    .update(`${timestamp}.${req.body}`)
    .digest('hex');

  const a = Buffer.from(signature);
  const b = Buffer.from(expected);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.sendStatus(401);

  const job = JSON.parse(req.body);
  // job.status is "succeeded" or "failed"; job.url is the finished document.
  res.sendStatus(200);
});

In n8n, a Webhook node accepts the call without verifying it. If the workflow does anything consequential with the result, add a Code node that repeats the check above using the secret from your dashboard.

Delivery is attempted three times with backoff, and each attempt is signed with a fresh timestamp. If all three fail the job is still succeeded — poll GET /v1/jobs/{id} to collect it.

Debugging a render #

When a PDF comes out blank, or an image is missing, or a placeholder produced nothing, the question is always the same: what did the browser actually see? Send "debug": true and the answer comes back with the document.

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<h1>Dbg</h1><script>notAFunction()</script>",
        "debug": true,
        "output": "base64"
      }'
200 OK (base64 elided)
{
  "filename": "document.pdf",
  "pages": 1,
  "size": 6220,
  "duration_ms": 38,
  "credits_remaining": 273,
  "debug": {
    "renderedHtml": "<html><head></head><body><h1>Dbg</h1><script>notAFunction()</script></body></html>",
    "finalUrl": "about:blank",
    "pageErrors": ["notAFunction is not defined"]
  }
}
FieldWhat it tells you
renderedHtmlThe DOM as it stood the instant before printing — after placeholder substitution, after your JavaScript ran, after waitFor. If a {{placeholder}} is still sitting there, or a container is empty, you can see it.
finalUrlWhere the browser ended up — about:blank for HTML and Markdown sources, the post-redirect URL for a url source.
pageErrorsUp to 5 uncaught JavaScript errors thrown by the page, each truncated to 200 characters. A chart library that failed to load shows up here.

With the JSON output modes the block is in the body under debug. With the default binary output there is no body to put it in, so the page errors come back in the X-PDFMint-Page-Errors header instead (joined with |, truncated to 900 characters).

renderedHtml can be as large as your document. Turn debug on while you are diagnosing something, not in production.

POST/v1/pdf #

Renders one document. Costs 1 credit. Returns the PDF bytes by default.

Request body #

FieldTypeDefaultDescription
htmlstringMarkup to render. Exactly one source is required.
markdownstringMarkdown to typeset with the built-in print stylesheet.
urlstringPublic page to load and print. See Rendering a URL.
templatestringName of a saved template. Its stored options are merged under the ones you send here.
dataobject | stringValues for {{placeholders}}. A string must contain valid JSON, or you get invalid_data.
strictbooleanfalseRefuse the call instead of returning a document that is wrong: an unmatched placeholder, a page that renders blank, or markup printed as text. See Strict mode.
optionsobject{}Page setup — see the PDF options reference. If you omit this object entirely, the option names are also read from the top level of the body.
outputstringbinarybinary, url or base64. See Output modes.
filenamestringdocument.pdfName in Content-Disposition and in the JSON response. \ / : * ? " < > | and newlines are replaced with _, the name is cut to 120 characters, and .pdf is appended if missing.
expiresInMinutesnumber60Lifetime of a hosted link, 1–10080. Alias: expiration. Only used with output: "url".
timeoutnumber (ms)30000Whole render budget, 1000–120000. Alias: timeoutMs. See Timeouts.
waitFornumber | stringMilliseconds, a CSS selector, or networkidle. See waitFor.
javascriptbooleantrueSet false to render with JavaScript disabled — faster and safer for untrusted markup.
emulateDarkModebooleanfalseReport prefers-color-scheme: dark to the page.
headersobjectExtra HTTP headers sent with every request the page makes. See Sending headers.
cssstringExtra CSS. Markdown source only.
googleFontsstringGoogle Fonts family spec. Markdown source only.
metadataobjectDocument properties: title, author, subject, keywords (array or comma-separated string), creator. Producer is always set to PDFMint. Metadata is written best-effort — a failure here never fails the render.
watermarkstring | objectStamp diagonal text across every page. A bare string is the text. See Watermarks.
asyncbooleanfalseQueue the render and answer 202 immediately with a job id. See Async & webhooks.
webhookUrlstringA public URL we POST the finished job to. Setting it implies async. Alias: webhook_url.
debugbooleanfalseReturn the HTML the browser actually rendered, the final URL and any page errors. See Debugging a render.
passwordstringEncrypt with AES-256. See Password protection.
ownerPasswordstringrandomOwner password governing permissions.
allowPrintingbooleantruePrinting permission on an encrypted PDF.
allowCopyingbooleanfalseText/image extraction permission on an encrypted PDF.

Booleans accept real booleans and the strings/numbers true, false, 1, 0. Anything else is invalid_option. An empty string always means "not set".

Response #

HeaderWhenMeaning
X-PDFMint-PagesUnless encryptedPage count of the finished document
X-PDFMint-Duration-MsAlwaysServer-side render time in milliseconds
X-PDFMint-Credits-RemainingAlwaysCredits left this month
X-PDFMint-Credits-LimitAlwaysCredits included in the plan
X-PDFMint-WarningIf anything was oddNon-fatal notes, joined with | — unresolved placeholders, conflicting page-size options
X-PDFMint-Page-ErrorsWith "debug": trueJavaScript errors the page threw, joined with |, truncated to 900 characters
X-Request-IdAlways, every endpointQuote this if you report a problem

PDF options reference #

Everything below goes inside options. (If you send no options object at all, these names are read from the top level of the body instead — handy for a quick curl.)

OptionTypeDefaultDescription
formatstringA4Paper size: A0, A1, A2, A3, A4, A5, A6, Letter, Legal, Tabloid, Ledger. Case-insensitive. Anything else is invalid_option.
width
height
lengthCustom page size. Both are required together, or you get invalid_option. If given, they override format and a warning says so.
landscapebooleanfalseRotate the page.
marginlength | object12mmOne length for all four sides, or { "top": …, "right": …, "bottom": …, "left": … } — any side you leave out of that object is 0. Omit margin entirely and you get 12 mm on all four sides, because content run hard against the paper edge looks broken. For full bleed, send "margin": 0 (or "") explicitly.
scalenumber1Print scale, 0.1–2. Use 0.8 to fit a wide table. Outside the range: invalid_option.
printBackgroundbooleantrueChrome omits background colours and images when printing; PDFMint turns them back on. Set false for the browser default.
headerHtmlstringMarkup repeated at the top of every page. Alias: headerTemplate. See Headers and footers.
footerHtmlstringMarkup repeated at the bottom of every page. Alias: footerTemplate.
pageNumbersboolean | stringfalsetrue gives a centred Page {page} of {total} footer. A string is used as the format. Ignored if you already set footerHtml.
pageRangesstringKeep only these pages: comma-separated numbers and ranges, e.g. 1-5, 8, 11-13. 3- means "from 3 to the end" and -3 "up to 3". Pages are numbered from 1. A range that is malformed, runs backwards, or starts past the last page is invalid_option, not a rendering failure.
mediaTypestringprintprint or screen. Use screen when your CSS was written for the browser and @media print rules would hide things.
preferCssPageSizebooleanfalseLet an @page { size: … } rule in your CSS win over format. Alias: preferCSSPageSize.
taggedbooleantrueEmit a tagged (accessible) PDF with document structure. Set false for a slightly smaller file.
outlinebooleanfalseBuild a PDF bookmark outline from the document headings.

Lengths #

Anywhere a length is accepted, give a number with a CSS unit — px, in, cm, mm, pt or pc. A bare number is read as pixels at 96 dpi. Anything else ("20 furlongs", "2em", "50%") is rejected with invalid_option rather than silently ignored.

Headers and footers #

Chrome renders header and footer templates in a separate document with a default font-size of zero and no page padding — which is why so many PDF tools give you a blank strip where your header should be. PDFMint fixes both for you:

  • Your markup is wrapped in a container with a readable 9 pt sans-serif at color:#555, and print-color-adjust: exact so backgrounds show. Styles you set on your own elements still win — they are nested inside.
  • The wrapper gets side padding of at least 12 mm so the header lines up with the body text instead of running to the paper edge.
  • Chrome draws the header inside the top margin and, when it does not fit, prints it straight over your first lines of body text — no error, nothing in the response to tell you. So PDFMint lays your header out in the same Chromium before printing, measures how tall it actually comes out, and reserves a margin that fits it: at least 15 mm, and more when the header needs more. A larger margin you set yourself is never reduced. The footer and the bottom margin are handled the same way.
  • A header or footer is never given more than a third of the page. One that wants more gets a third, and the response carries an X-PDFMint-Warning naming the height it needed, the height it got, and the fact that the rest will print over the body. Shorten it, or set the margin yourself.

Inside a header or footer, Chrome substitutes these classes: <span class="pageNumber">, totalPages, date, title, url. The pageNumbers shorthand writes them for you from a format string:

Token in pageNumbersBecomes
{page}Current page number
{total}Total page count
{date}Print date
{title}Document title (from metadata.title for Markdown, or the <title> in your HTML)
{url}Source URL
"options": {
  "margin": "18mm",
  "pageNumbers": "Invoice 2026-014 — page {page} of {total}"
}

POST/v1/image #

The same renderer, screenshotted instead of printed. Costs 1 credit. Sources are html, markdown or url — a saved template is not supported here and gives unsupported_source. Everything else works as it does on /v1/pdf, including data placeholders and strict.

FieldTypeDefaultDescription
typestringpngpng or jpeg. Anything else is invalid_option.
qualitynumber85JPEG quality 1–100. Ignored for PNG.
widthnumber (px)1280Viewport width.
heightnumber (px)800Viewport height. Only bounds the image when fullPage is off.
deviceScaleFactornumber2Pixel density multiplier. 2 gives a retina-sharp image at twice the pixel dimensions.
fullPagebooleantrueCapture the whole scrollable page rather than just the viewport.
omitBackgroundbooleanfalseLeave the page background transparent instead of white. PNG only, in practice.
filenamestringimage.<type>Same sanitising as for PDFs. The extension matching type is appended if it is missing, and corrected — with a warning — if it contradicts the bytes, so chart as a PNG comes back chart.png.
outputstringbinarybinary, url or base64.
dataobjectValues for the {{placeholders}} in html or markdown, exactly as on /v1/pdf.
strictbooleanfalseRefuse with unresolved_placeholders, invalid_placeholder_value, blank_document or unrendered_markup rather than screenshot a page that is wrong. See Strict mode.
waitFor, timeout, javascript,
css, googleFonts, expiresInMinutes
Behave exactly as on /v1/pdf.

Responses carry X-PDFMint-Duration-Ms and X-PDFMint-Credits-Remaining.

curl -s -X POST https://pdf.mintapis.com/v1/image \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<div style=\"font:600 48px sans-serif;padding:60px\">Chart</div>",
        "type": "png", "width": 800, "height": 400, "fullPage": false,
        "output": "base64"
      }'

# {"filename":"image.png","size":11234,"base64":"iVBORw0KGgo…","credits_remaining":297}

POST/v1/merge #

Joins 2–50 PDFs into one, in the order given. Costs 1 credit for the whole call, however many files go in.

FieldTypeDefaultDescription
filesarrayrequired2–50 entries. Each is a public http(s) URL string, an object {"base64": "…"}, or a bare base64 string. Aliases for this field: urls, pdfs.
filenamestringmerged.pdf.pdf appended if missing.
outputstringbinarybinary, url or base64.
strictbooleanfalseRefuse with blank_document when the merge produces no pages, or when one input is a valid PDF that contributed none. See Strict mode.
metadataobjectTitle, author, subject, keywords, creator for the merged document.
expiresInMinutesnumber60Only with output: "url".
  • URLs go through the same SSRF checks as a rendered URL, and each download gets 30 seconds.
  • A download that fails or answers non-2xx gives download_failed, naming the index: files[0].
  • An entry that is not a readable PDF gives invalid_pdf naming the position — the usual cause is a URL that returned an HTML error page.
  • Encrypted inputs are read where possible, but a password-protected file may still fail.
# Hosted links expire, so build the two documents first and merge what you just made.
A=$(curl -s -X POST $API/v1/pdf -H "Authorization: Bearer $PDFMINT_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"markdown":"# Cover","output":"url"}' | jq -r .url)
B=$(curl -s -X POST $API/v1/pdf -H "Authorization: Bearer $PDFMINT_API_KEY" \
     -H "Content-Type: application/json" \
     -d '{"markdown":"# Terms","output":"url"}' | jq -r .url)

curl -s -X POST $API/v1/merge \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"files\": [\"$A\", \"$B\"], \"filename\": \"contract.pdf\", \"output\": \"url\"}"
200 OK
{
  "filename": "contract.pdf",
  "pages": 2,
  "size": 10140,
  "url": "https://pdf.mintapis.com/f/e4AJQ048aQPLrLc5chiZsvIO",
  "expires_in_minutes": 60,
  "credits_remaining": 290
}

GET/v1/me #

Plan and quota for the key you authenticated with. Free — it costs no credits, which is why it doubles as the credential test.

FieldTypeDescription
emailstringAccount the key belongs to
planstringfree, starter, pro or scale
plan_namestringHuman-readable plan name
credits_limitnumberDocuments included this month
credits_usednumberDocuments used so far this month
credits_remainingnumberWhat is left, never below 0
period_resets_atstringISO 8601 UTC instant when the counter goes back to zero
dashboard_urlstringLink to the account dashboard

/v1/templates #

Templates are optional. They exist so you can keep a piece of HTML on your account and render it by name — not by an opaque id you have to copy out of a web app. You never need one: sending html or markdown directly works just as well.

Template names match ^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$ — 1–64 characters, starting with a letter or digit, then letters, digits, spaces, dots, dashes and underscores. Anything else gives invalid_template_name. All template endpoints are free.

PUT/v1/templates/{name} #

Creates or replaces a template. Body: html (required) and an optional options object, which is validated immediately — a bad option is rejected now rather than at render time. When you later render the template, options you send on the render request override the stored ones.

curl -s -X PUT https://pdf.mintapis.com/v1/templates/invoice \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<h1>Invoice {{number}}</h1><p>For {{customer.name}}</p>",
        "options": { "format": "A4", "margin": "18mm" }
      }'

# {"template":{"name":"invoice","updated_at":"2026-08-23T14:57:06.125Z"},
#  "usage":{"template":"invoice","data":{"example":"value"}}}

Rendering one #

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "template": "invoice",
        "data": { "number": "2026-014", "customer": { "name": "Acme GmbH" } },
        "output": "base64"
      }'

# {"filename":"document.pdf","pages":1,"size":13997,
#  "duration_ms":64,"credits_remaining":295,"base64":"JVBERi0…"}

The fields a template wants #

PUT and GET on a single template both answer with a placeholders array: every distinct marker the template uses, in the stored HTML and in a stored headerHtml, footerHtml or watermark. It is what an integration builds its input fields from, so the person filling the form picks a name from a list instead of retyping one from memory — which is what causes the misspelling in the first place.

GET /v1/templates/invoice
{
  "name": "invoice",
  "html": "…",
  "options": { },
  "updated_at": "2026-08-25T09:14:02.113Z",
  "placeholders": [
    { "name": "invoice_number", "kind": "scalar" },
    { "name": "customer.name",  "kind": "scalar" },
    { "name": "lines",          "kind": "section" }
  ]
}

kind is scalar for {{name}}, raw for {{{name}}}, section for {{#name}} and inverted for {{^name}}. The list is produced by running the template through the same code that fills it, so it can never name a field the renderer does not look for. Names inside a section are not listed: they repeat per item and belong to the array you map into lines.

GET/v1/templates #

Lists every template on the account, alphabetically. This is what fills the template dropdown in the n8n node.

{"templates":[{"name":"invoice","options":{"format":"A4","margin":"18mm"},
               "html_bytes":55,"updated_at":"2026-08-23T14:57:06.125Z"}]}

GET/v1/templates/{name} #

Returns { name, html, options, updated_at } — the full body included. Unknown name: 400 template_not_found.

DELETE/v1/templates/{name} #

Returns {"deleted":"invoice"}. Unknown name: 400 template_not_found.

When you render a template that does not exist, the error lists the templates you do have — so a typo is obvious from the response alone.

Hosted files & health #

GET/f/{token} — serves a file created by output: "url". No authentication: the token is the secret. Served inline with Cache-Control: private, max-age=300. After expiry the file is deleted and the link shows an "expired" page.

GET/healthz — no authentication, no credits. {"ok":true,"active":0,"queued":0}: how many renders are running and how many are queued behind them right now.

Recipes #

Every one of these was executed against the live API while writing this page. Set PDFMINT_API_KEY and paste.

An invoice with a line-item table and page numbers #

The pattern: a {{#lines}} section for the rows, tr { break-inside: avoid } so no row is cut in half by a page break, and pageNumbers with a custom format so a multi-page invoice is still identifiable page by page.

Note the tfoot { display: table-row-group }. Chromium repeats a <tfoot> on every page, exactly as it repeats a <thead>. Without that one rule an invoice long enough to run to three pages prints its grand total three times — once at the foot of each page, mid-way through the line items — and the customer reading it cannot tell which one is the amount due. The rule puts the total back in document order, so it prints once, after the last line item. Verified with this exact recipe and 60 line items: three pages, and the word Total and the grand-total figure each appear exactly once in the whole document.

invoice.json
{
  "html": "<style>body{font:13px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a}h1{font-size:26px;margin:0 0 4px}table{width:100%;border-collapse:collapse;margin-top:18px}th{text-align:left;border-bottom:2px solid #12a37a;padding:8px 6px;font-size:11px;text-transform:uppercase;letter-spacing:.04em}td{padding:8px 6px;border-bottom:1px solid #e3e6ec}tr{break-inside:avoid}.r{text-align:right}tfoot{display:table-row-group}tfoot td{font-weight:700;border-bottom:0;border-top:2px solid #12a37a}</style><h1>Invoice {{number}}</h1><p>{{customer.name}} &middot; {{date}}</p><table><thead><tr><th>Description</th><th class=r>Qty</th><th class=r>Unit</th><th class=r>Amount</th></tr></thead><tbody>{{#lines}}<tr><td>{{desc}}</td><td class=r>{{qty}}</td><td class=r>{{unit}}</td><td class=r>{{amount}}</td></tr>{{/lines}}</tbody><tfoot><tr><td colspan=3 class=r>Total</td><td class=r>{{total}}</td></tr></tfoot></table>",
  "data": {
    "number": "2026-014",
    "date": "23 August 2026",
    "customer": { "name": "Acme GmbH" },
    "lines": [
      { "desc": "Design retainer", "qty": 1,  "unit": "€2,400.00", "amount": "€2,400.00" },
      { "desc": "Implementation",  "qty": 12, "unit": "€150.00",   "amount": "€1,800.00" }
    ],
    "total": "€4,200.00"
  },
  "filename": "invoice-2026-014.pdf",
  "options": {
    "format": "A4",
    "margin": "18mm",
    "pageNumbers": "Invoice 2026-014 — page {page} of {total}"
  },
  "metadata": { "title": "Invoice 2026-014", "author": "Acme GmbH" }
}
curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @invoice.json \
  -o invoice.pdf -D - | grep -i x-pdfmint

# x-pdfmint-pages: 1
# x-pdfmint-duration-ms: 168
# x-pdfmint-credits-remaining: 288

A report straight from LLM output #

An LLM emits Markdown. Send it as-is — no HTML templating step, no CSS. The built-in stylesheet keeps table rows and headings intact across page breaks.

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "markdown": "# Quarterly Report\n\nRevenue grew **18%**.\n\n| Region | Revenue |\n|---|---|\n| EMEA | 1.2M |\n| AMER | 2.4M |\n",
        "output": "base64",
        "options": { "margin": "18mm", "pageNumbers": true }
      }'

# {"filename":"document.pdf","pages":1,"size":48919,
#  "duration_ms":256,"credits_remaining":298,"base64":"JVBERi0xLjQ…"}

To brand it, add googleFonts and a few lines of css — they land after the built-in rules, so they win:

"googleFonts": "Playfair Display:wght@400;700",
"css": "h1{font-family:'Playfair Display',serif;color:#0b7a5b} h2{border-bottom-color:#12a37a}"

A header with a logo, and a footer with page numbers #

Reference the logo by public URL (inline base64 works but bloats the request). PDFMint measures the header in the renderer and reserves a top margin tall enough for it, so it is neither clipped nor printed over your first paragraph — you can leave margin.top out entirely. The explicit 28mm below simply gives the header more room than it needs.

header.json
{
  "html": "<h1>Annual report</h1><p>Body text.</p>",
  "options": {
    "margin": { "top": "28mm", "bottom": "20mm", "left": "18mm", "right": "18mm" },
    "headerHtml": "<div style='display:flex;justify-content:space-between;align-items:center;width:100%'><img src='https://pdf.mintapis.com/favicon.svg' style='height:14px'><span>Annual report 2026</span></div>",
    "footerHtml": "<div style='text-align:center;width:100%'>Page <span class='pageNumber'></span> of <span class='totalPages'></span></div>"
  }
}
curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @header.json -o report.pdf -w "%{http_code}\n"

# 200

Header and footer markup is rendered by Chrome in its own little document — your page's stylesheet does not reach it. Style it with inline style= attributes, as above.

Controlling page breaks with CSS #

Page breaks are pure CSS — PDFMint has no options for them because the browser already does it properly. The three rules that matter:

RuleEffect
break-inside: avoidKeep this element on one page if it fits — cards, table rows, figures, code blocks
break-after: pageStart whatever comes next on a fresh page — chapters, one-invoice-per-page batches
break-before: pageStart this element on a fresh page
curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<style>.card{break-inside:avoid;padding:12px;border:1px solid #ccc;margin:8px 0}.chapter{break-after:page}</style><section class=chapter><h1>One</h1><div class=card>Kept together</div></section><section><h1>Two</h1></section>",
        "options": { "margin": "15mm" },
        "output": "base64"
      }'

# {"filename":"document.pdf","pages":2,"size":13990,
#  "duration_ms":48,"credits_remaining":286}

Two sections, two pages — the break-after: page took effect. Add orphans: 3; widows: 3 on p to stop single lines stranding at a page boundary. Remember that the default margin is 12 mm — set "margin": 0 if your own CSS is doing the page padding, or you get both.

Waiting for a chart #

Have the drawing code append a marker element when it is done, then wait for that selector. It is exact, and it fails loudly with wait_for_timeout if the chart never renders — instead of handing you a blank rectangle.

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<div id=\"chart\"></div><script>setTimeout(function(){document.getElementById(\"chart\").innerHTML=\"<b>rendered</b>\";document.body.insertAdjacentHTML(\"beforeend\",\"<div id=chart-ready></div>\");},800)</script>",
        "waitFor": "#chart-ready",
        "output": "base64"
      }'

# {"filename":"document.pdf","pages":1,"size":6373,
#  "duration_ms":1037,"credits_remaining":289}

The 1037 ms duration is the 800 ms chart plus the render — proof the wait actually happened. In your own page:

myChart.render().then(() => {
  document.body.insertAdjacentHTML('beforeend', '<div id="chart-ready"></div>');
});

A password-protected document #

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<h1>Confidential</h1>",
        "password": "s3cret",
        "allowPrinting": true,
        "allowCopying": false
      }' \
  -o secret.pdf -D - | grep -i x-pdfmint

# x-pdfmint-duration-ms: 71
# x-pdfmint-credits-remaining: 293
# (no x-pdfmint-pages — an encrypted document cannot be counted)

Opening secret.pdf asks for s3cret. Printing is allowed; selecting and copying text is not.

A DRAFT watermark on every page #

curl -s -X POST https://pdf.mintapis.com/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "html": "<h1>Contract</h1>",
        "watermark": { "text": "CONFIDENTIAL", "color": "#c0392b",
                       "opacity": 0.12, "rotation": 30, "font": "times-roman" },
        "output": "base64"
      }'

# {"filename":"document.pdf","pages":1,"size":6540,
#  "duration_ms":47,"credits_remaining":274}

Combine it with encryption when the point is that nobody removes it:

"watermark": "DRAFT",
"password": "s3cret",
"allowCopying": false

A long render, answered by webhook #

For a document that needs a real wait, hand over a webhook and stop holding the connection. The 202 comes back in milliseconds; the finished file arrives at your URL.

# WEBHOOK_URL is the Production URL of your n8n Webhook node, or any public
# endpoint you control. A host that does not resolve is refused with dns_failed
# rather than queued, so set this before running the call.
WEBHOOK_URL="https://your-n8n.example.com/webhook/pdf-done"

curl -s -X POST $API/v1/pdf \
  -H "Authorization: Bearer $PDFMINT_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{
        \"url\": \"https://example.com/heavy-dashboard\",
        \"waitFor\": \"#chart-ready\",
        \"timeout\": 90000,
        \"expiresInMinutes\": 1440,
        \"webhookUrl\": \"$WEBHOOK_URL\"
      }"

# 202 {"job_id":"job_…","status":"queued",
#      "status_url":"https://pdf.mintapis.com/v1/jobs/job_…",
#      "webhook_url":"https://your-n8n.example.com/webhook/pdf-done",
#      "credits_remaining":271}

Without a webhook, poll instead — this is the loop, measured against a live queue:

API=https://pdf.mintapis.com
ID=$(curl -s -X POST $API/v1/pdf \
      -H "Authorization: Bearer $PDFMINT_API_KEY" -H "Content-Type: application/json" \
      -d '{"html":"<h1>Async report</h1>","filename":"async.pdf","async":true}' | jq -r .job_id)

# Poll until the job reaches a final state. "queued" and "running" are both
# still in flight, so wait for succeeded or failed rather than for "not queued".
while :; do
  STATUS=$(curl -s $API/v1/jobs/$ID -H "Authorization: Bearer $PDFMINT_API_KEY" | jq -r .status)
  case "$STATUS" in
    succeeded|failed) break ;;
  esac
  sleep 2
done
curl -s $API/v1/jobs/$ID -H "Authorization: Bearer $PDFMINT_API_KEY" | jq .

Merging a cover page onto a document #

Generate the parts with output: "url", then hand the links to /v1/merge. Three calls, three credits.

API=https://pdf.mintapis.com
AUTH="Authorization: Bearer $PDFMINT_API_KEY"
JSON="Content-Type: application/json"

U1=$(curl -s -X POST $API/v1/pdf -H "$AUTH" -H "$JSON" \
     -d '{"html":"<h1>Cover</h1>","output":"url"}' | jq -r .url)
U2=$(curl -s -X POST $API/v1/pdf -H "$AUTH" -H "$JSON" \
     -d '{"html":"<h1>Terms</h1>","output":"url"}' | jq -r .url)

curl -s -X POST $API/v1/merge -H "$AUTH" -H "$JSON" \
  -d "{\"files\":[\"$U1\",\"$U2\"],\"filename\":\"contract.pdf\",\"output\":\"url\"}"

# {"filename":"contract.pdf","pages":2,"size":10140,
#  "url":"https://pdf.mintapis.com/f/e4AJQ048aQPLrLc5chiZsvIO",
#  "expires_in_minutes":60,"credits_remaining":290}

If the parts are already in memory, skip the hosting step and send {"base64": "…"} entries instead.

Errors #

Every failure has the same shape. code is stable and safe to branch on; message names what happened; hint says what to change; docs links to the section that explains it; request_id identifies the call in our logs.

{
  "error": {
    "code": "invalid_option",
    "message": "\"format\" must be one of A0, A1, A2, A3, A4, A5, A6, Letter, Legal, Tabloid, Ledger — got \"A9\".",
    "hint": "Use \"A4\" for metric paper or \"Letter\" for US paper. To use a custom size, set \"width\" and \"height\" instead of \"format\".",
    "docs": "https://pdf.mintapis.com/docs#options",
    "details": { },
    "request_id": "2a9310a5d2dd1281"
  }
}

hint, docs and details are only present when they add something.

Every code the API can return #

CodeHTTPWhat it meansWhat to change
missing_api_key401No Authorization or X-API-Key header.Send Authorization: Bearer pm_live_…. In n8n, pick the credential on the node.
invalid_api_key401The key does not start with pm_live_, is unknown, or was revoked.Copy the current key from /dashboard.
plan_required402The account has no allowance at all: details.credits_limit is 0. A new signup gets the free plan, so this is not what you see after signing up.Choose a plan on the dashboard. See Quota.
quota_exceeded402All documents for this month are used. details carries plan, used and limit.Wait for the 1st, or upgrade on the dashboard. See Quota.
missing_content400No source field — or, on PUT /v1/templates/{name}, no html.Send one of html, markdown, url, template. See Choosing an input.
ambiguous_content400Two or more source fields were sent; the message names which.Keep one. For a template plus values, use template + data.
unsupported_source400/v1/image was asked to render a saved template.Use html, markdown or url on /v1/image.
invalid_option400One option failed validation — an unknown format, scale outside 0.1–2, a bad length, mediaType other than print/screen, a non-boolean boolean, output outside binary/url/base64, image type other than png/jpeg, a watermark with no text, a malformed pageRanges, or timeout below 1000 ms.The message names the field and the accepted values. See PDF options.
invalid_data400data was a string that is not valid JSON. details.parse_error says where.Send an object, or fix the JSON. In n8n use an expression such as {{ $json }}.
unresolved_placeholders400Only with "strict": true: placeholders had no value. details.unresolved lists them.Fix the spelling in data, or drop strict to render them empty with a warning. See Strict mode.
blank_document400Only with "strict": true: the rendered body had no visible text, no image and no painted area — or, on /v1/merge, the result had no pages. Refunded.Check what the page actually renders; drop strict to receive it anyway. See Strict mode.
invalid_placeholder_value400Only with "strict": true: a placeholder resolved to an object or an array of objects, which would print as [object Object]. details.unprintable names each one.Use a path into the object, {{customer.name}}, or build the string before you send it. See Strict mode.
unrendered_markup400Only with "strict": true: the visible text is overwhelmingly raw CSS or literal tags, so the markup was printed rather than applied. Refunded.Stop the markup being HTML-escaped upstream, or wrap an intentional sample in <pre>. See Strict mode.
invalid_json400The request body is not parseable JSON.Look for a trailing comma or an unescaped quote.
invalid_url400The url could not be parsed.Include the scheme: https://example.com/report.
unsupported_url_scheme400Not http:// or https://.file:, data: and ftp: are refused — pass the content in html.
private_address_blocked400The URL points at localhost, a private/link-local range, or a hostname that resolves to one. See Rendering a URL.Fetch the page in your own workflow and send the markup in html.
dns_failed400The hostname does not resolve from our network.Check the spelling and that the host is publicly resolvable.
url_unreachable502DNS worked but the host refused, did not answer, or presented an invalid TLS certificate.Confirm the page is reachable from the public internet; otherwise send html.
url_http_error502The page answered HTTP 400 or above. 401/403 usually means it needs a login.Fetch it where you have the session and pass the markup in html.
wait_for_timeout504The waitFor selector never appeared. The credit is refunded.Check the selector, or wait a fixed number of milliseconds. See waitFor.
render_timeout504The render ran past timeout. Refunded.Raise timeout (up to 120000), or replace networkidle with a selector.
unknown_field400A field name the endpoint does not accept — usually a typo, sometimes another vendor's spelling.The hint names the field that was probably meant, for example formattype or templateIdtemplate.
template_too_large400A stored template over 2 MB.Reference images by URL rather than inlining them as base64.
rate_limited429More than 120 requests a minute, or more than 30 at once, from this account.Wait the number of seconds in Retry-After, then retry. See Rate limits.
renderer_busy503Both render slots are full and 40 jobs are already queued.Retry after a few seconds. In n8n, enable Retry On Fail on the node.
renderer_crashed500Chrome died mid-document — almost always a huge or pathological page. Refunded.Split the document, shrink the images, or lower scale.
render_failed500A rendering failure that did not match any of the specific cases above; the browser's own message is included. Refunded.Read the message — it names the failing resource. If it looks like ours, send us the request_id.
html_too_large400The assembled HTML is over 10 MB.Inline base64 images are the usual cause. Host them and reference by URL.
request_too_large413The whole request body is over 12 MB.Same fix — get the images out of the JSON.
file_too_large400The generated file is over 20 MB, so it cannot be hosted.Use output: "binary", which has no size limit. See Output modes.
invalid_input400/v1/merge: files is not an array of at least two entries, or an entry is neither a URL nor {"base64": …}.See /v1/merge.
too_many_files400More than 50 files in one merge.Merge in batches of 50, then merge the results.
download_failed400A merge input could not be downloaded, or answered non-2xx. The message names the index.Check that the URL is public and still alive — hosted PDFMint links expire.
invalid_pdf400A merge input is not a readable PDF. The message names its position.Usually the URL returned an HTML error page. Check what it actually serves.
template_not_found400No template with that name on this account. On a render, the hint lists the names you do have.Check the name, or create it with PUT /v1/templates/{name}.
invalid_template_name400The name does not match ^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$.1–64 characters: start with a letter or digit, then letters, digits, spaces, dot, dash, underscore.
encryption_unavailable501This deployment has no qpdf, so it cannot encrypt. Not the case on the hosted API.Only reachable on a self-hosted instance — install qpdf, or drop password.
encryption_failed500Encryption was attempted and failed.Retry; if it persists, send us the request_id.
job_not_found404No job with that id on this account. Job ids look like job_XXXX and belong to the account that created them.Use the job_id from the 202 response, with the same API key. See Async & webhooks.
account_gone404A queued job outlived the account that created it. Delivered on the job record, never as a live response.Nothing to do — requeue from a live account.
unknown_endpoint404No such route under /v1/. The hint lists the real ones.Check method and path — e.g. POST, not GET, on /v1/pdf.
internal_error500An unhandled failure on our side.Retry. If it repeats, send us the request_id from the response.

Three further codes — unknown_plan, no_subscription and billing_unavailable — belong to the dashboard's Stripe checkout flow and are never returned by a /v1/ endpoint.

What is worth retrying #

  • Retry: rate_limited (429, after Retry-After), renderer_busy (503), url_unreachable and url_http_error (502, the page you pointed at failed, not your request), internal_error (500), and render_timeout (504) if you also raise timeout.
  • Do not retry any 400 or 401 — the request itself needs changing; a retry fails identically.
  • 402 only clears on the 1st of the month, or on an upgrade.

Limits #

LimitValueExceeding it gives
Assembled HTML, per render10 MBhtml_too_large
Request body12 MBrequest_too_large
Hosted file size (output: "url")20 MBfile_too_large
Binary output sizeno limit
Render timeout, default30 srender_timeout
Render timeout, maximum120 shigher values are clamped
Minimum timeout1000 msinvalid_option
Hosted link lifetime, default / max60 min / 7 daysout-of-range values are clamped
Files per merge2–50invalid_input / too_many_files
Per-file download during a merge30 sdownload_failed
Template name1–64 charsinvalid_template_name
Requests per minute, per account120rate_limited
Burst above that rate30rate_limited
Concurrent renders per instance2
Render queue depth40renderer_busy
Font loading wait, before printing8 sfalls back to system fonts
Per-image wait, before printing5 sprints without that image
Webhook delivery attempts3job stays succeeded/failed, poll it instead
Webhook request timeout15 scounts as a failed attempt
Job record retention after finishing7 daysjob_not_found
Documents per month10 free · 5,000 Starter · 50,000 Pro · 250,000 Scalequota_exceeded

Two renders run at once per instance and up to 40 more can queue. GET /healthz reports both numbers live, so you can see whether a slow response is your page or the queue.

Rate limits #

Every authenticated response carries the current state, so you never have to guess:

X-RateLimit-Limit: 120        # sustained requests per minute, per account
X-RateLimit-Burst: 30         # how many you may fire at once
X-RateLimit-Remaining: 27     # tokens left in your bucket right now

The bucket refills continuously at 2 tokens per second. Go past it and you get 429 rate_limited with a Retry-After header in whole seconds — wait that long and the next request goes through. The limit is per account, not per key, so issuing extra keys does not raise it.

These are not the same thing as the render queue. The rate limit governs how fast you may ask; the queue governs how fast documents actually come out. A long-running batch that respects Retry-After can still meet renderer_busy (503) if the renderer is saturated, and both are worth retrying.

Where requests come from #

When you render a url, or PDFMint downloads a file for /v1/merge, or it calls your webhookUrl, the connection comes from Frankfurt with a source address in 74.220.51.0/24 or 74.220.59.0/24.

Read this before you use those ranges in a firewall. They are the shared egress ranges of the hosting provider, not addresses reserved for PDFMint. Allowlisting them lets in every other service hosted there too, so treat them as a convenience for reaching a low-risk internal report — never as an access control. If the page is genuinely sensitive, fetch it yourself and send the HTML.

Pages behind a login, on a VPN, or on a private IP range are not reachable — see Rendering a URL for the full list and the workaround.

The n8n node #

n8n-nodes-pdfmint is a thin client over this API with zero runtime dependencies, built and tested against n8n 2.35 on Node.js 20 and 22. It is marked usableAsTool, so an n8n AI Agent can call it directly.

Install #

Self-hosted n8n only, for now. n8n's own rule is that “unverified community nodes aren't available on n8n cloud and require self-hosting n8n.” PDFMint's node is submitted for verification and is currently in n8n's automated review, so n8n Cloud cannot install it yet. On a self-hosted instance it installs normally:

Settings → Community Nodes → Install, then enter n8n-nodes-pdfmint.

Or from the CLI:

cd ~/.n8n/nodes
npm install n8n-nodes-pdfmint

Then restart n8n. The package is published with an npm provenance attestation, so npm audit signatures can verify it was built by the GitHub Actions workflow in the repository, from a specific commit.

If you are on n8n Cloud, the HTTP API works today with an HTTP Request node — see POST /v1/pdf. It returns the PDF bytes, so set the node's response format to File and you get the same binary the PDFMint node would have handed you.

Then restart n8n.

Credential #

  1. Create an account at /signup — the key is shown immediately.
  2. In n8n, add a PDFMint API credential and paste the key (it starts with pm_live_).
  3. Base URL defaults to https://pdf.mintapis.com. Change it only if you run your own instance.
  4. The credential tests itself with GET /v1/me, so you know immediately whether the key works.

The four operations #

OperationCallsNotes
Generate PDFPOST /v1/pdfSource: HTML, Markdown, URL or Saved Template. Runs once per input item.
Generate ImagePOST /v1/imagePNG or JPEG. No Saved Template source.
Merge PDFsPOST /v1/mergeRuns once for the whole branch, not once per item.
Get UsageGET /v1/mePlan, quota and documents remaining, on json.

How the file comes out #

With the default File (Binary) output, the node attaches the file to the output item's binary field — data unless you change Put Output File in Field — so Gmail, Drive, S3, Slack and HTTP Request nodes can consume it directly. No download step.

Alongside it, on json:

FieldFrom
fileName, mimeType, sizeThe request and the response body
pagesX-PDFMint-Pages
durationMsX-PDFMint-Duration-Ms
creditsRemainingX-PDFMint-Credits-Remaining
warningX-PDFMint-Warning — present when a placeholder went unfilled

Choose Hosted URL or Base64 in JSON instead and the item carries the API's JSON response with no binary attached.

Merging in a workflow #

All Input Items takes one PDF from the named binary field of each incoming item and merges them in order — so a loop that generates several documents can be joined by wiring it straight into this node. List of URLs takes one public PDF URL per line. If an item has no such binary field, the error names the fields it does have.

Errors in n8n #

The node unpacks the API's error body: message becomes the error title, and hint, the docs link and the request_id become the description. So the red box tells you what to change instead of Request failed with status code 400. Turn on Settings → Continue On Fail to route failed items down the error branch instead.

Package: n8n-nodes-pdfmint · Issues: GitHub

Something here not matching what the API does? Send us the request_id from the response — it identifies the exact call. · Home · Dashboard