REST API
API Documentation (v2)
On this page: Quickstart, Authentication, Endpoints, Parameters, Responses, Convert, Background jobs, Check a key, Errors, Limits, Code samples.
Building with an AI agent? The same key works with the PrintFriendly MCP server.
Try it in Postman: a public workspace with every endpoint, ready to run. Set the bearerToken variable to your key, then send GET /v2/usage.
Quickstart
- Get a free API key. It includes 100 documents a month and needs no credit card.
- Send the key as
Authorization: Bearer YOUR_API_KEY. - POST a
page_url(orhtml) to/v2/pdf/create. The reply is JSON with afile_url.
curl -X POST https://api.printfriendly.com/v2/pdf/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"page_url": "https://en.wikipedia.org/wiki/Eclipse"}'On success you get status 200 and a JSON body:
{
"status": "success",
"file_url": "https://storage.printfriendly.com/data-production/pdf_1531957353_e94df5Letter.pdf"
}All requests go over HTTPS to https://api.printfriendly.com. The major version is part of the path (/v2/...). Requests are synchronous: the reply arrives when the file is ready, within 27 seconds.
Authentication
Every request needs an API key. Find yours on your API key page. Keep it secret: call the API from a server, never from a web page or app that other people can inspect.
| Method | How | Notes |
|---|---|---|
| Bearer (recommended) | Authorization: Bearer YOUR_API_KEY | Replies with JSON. |
| X-API-Key | X-API-Key: YOUR_API_KEY | Same as Bearer, for clients that cannot set an Authorization header. Replies with JSON. |
| Query parameter | ?api_key=YOUR_API_KEY | Works, but URLs end up in logs and browser history. Replies with JSON. |
| HTTP Basic (legacy) | Key as the username, empty password | Changes the reply: you get the file itself, not JSON. See below. |
| Body field | api_key | In a form or JSON body. Replies with JSON. |
If a request carries more than one, the first in this order is used: query parameter, Bearer, X-API-Key, Basic, body.
HTTP Basic returns the file, not JSON
Basic is the one method that changes the reply. On v2, a request authenticated with Basic gets the file's bytes back (as if you had sent output_type=attachment) instead of a JSON body. Errors are still JSON. It stays so integrations built before Bearer existed keep working. If you want JSON, use Bearer.
curl -u YOUR_API_KEY: https://api.printfriendly.com/v2/pdf/create \
-d page_url=https://en.wikipedia.org/wiki/Eclipse \
-o article.pdfEndpoints
| Endpoint | Input | Output |
|---|---|---|
| /v2/convert | url, html or markdown | A PDF, a Word file, Markdown, JSON, HTML or plain text, chosen with output. Add async: true to run it in the background. See Convert endpoint. |
| /v2/jobs/{id} | The job_id from an async convert | The job's state and, once it has finished, its result. GET only. See Background jobs. |
| /v2/usage | Nothing | Your key's plan and how many documents it has used, includes and has left. Free: it does not use a document. GET only. See Check a key. |
| /v2/pdf/create | page_url or html | A PDF file (application/pdf). Also available as /v2/pdfs/create. |
| /v2/html/create | page_url or html | A standalone, printable HTML document (text/html). |
| /v2/text/create | page_url or html | The article as plain text (text/plain). |
POST is the usual choice; GET works too, with parameters in the query string. Send parameters as a JSON body (Content-Type: application/json), as a form body (application/x-www-form-urlencoded or multipart/form-data), or in the query string. If a parameter is in both the query string and the body, the query string wins.
Parameters
Send page_url or html. Everything else is optional.
| Parameter | Default | What it does |
|---|---|---|
| page_url | none | Address of a public web page (http or https). We fetch it, pull out the article and render it. Send this or html. Addresses ending in .pdf are rejected. |
| html | none | Your own HTML, rendered as it is with no article extraction. Up to 50 MB. Send this or page_url; if you send both, page_url wins. |
| title | Print Friendly | Document title, used for the PDF title and the file name. With page_url the page's own title is used and this is only the fallback. |
| page_size | Letter | PDF page size: A4 or Letter. Any other value gives Letter. Has no effect on html or text output. |
| css_url | none | Address of a stylesheet to apply to the PDF or HTML output. Alias: custom_css_url. Ignored by text/create. |
| custom_css_url | none | Alias for css_url. If both are sent, css_url is used. |
| header_image_url | none | Address of an image to show in the document header. Only used with page_url, and only for PDF and HTML output. |
| header_text | none | A line of text (for example a tagline) shown in the document header. Only used with page_url, and only for PDF and HTML output. |
| dir | none | Text direction, ltr or rtl. With page_url the direction we detect on the page wins and this is the fallback. |
| lang | none | Language code for the document, for example en or ar. Sets the lang attribute on the output. |
| hostname | host of page_url | Host name used when naming the output file. Only changes the file name. |
| output_type | JSON reply | attachment, download or inline: answer with the file itself instead of JSON. attachment and download send it as a download, inline lets a browser display it. Any other value is ignored. |
| api_key | none | Your API key, as a query parameter or a body field. Prefer the Authorization header. |
These are accepted without an error so older integrations keep working, but they change nothing: url, platform, file_name, css_selector, api_version, status_key, preview, text_preview.
Examples
Form body with options:
curl -X POST https://api.printfriendly.com/v2/pdf/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-d page_url=https://en.wikipedia.org/wiki/Eclipse \
-d page_size=A4 \
-d header_text="Prepared for Acme"Your own HTML as a JSON body (nothing is fetched or extracted, up to 50 MB):
curl -X POST https://api.printfriendly.com/v2/html/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"html": "<h1>Title</h1><p>Some text.</p>", "title": "My document"}'Plain text of an article:
curl -X POST https://api.printfriendly.com/v2/text/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-d page_url=https://en.wikipedia.org/wiki/EclipseResponses
By default a successful request answers 200 with JSON. Read file_url and download the file from there:
{
"status": "success",
"file_url": "https://storage.printfriendly.com/data-production/pdf_1531957353_e94df5Letter.pdf"
}The link is public: anyone who has it can open the file. Files are deleted from our servers within 48 hours, so download yours and keep your own copy.
Getting the file in the reply
Add output_type=attachment (or download) to get the file itself back with a download disposition, or inline to let a browser show it. The Content-Type follows the endpoint: PDF, HTML or text.
curl -X POST https://api.printfriendly.com/v2/pdf/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-d page_url=https://en.wikipedia.org/wiki/Eclipse \
-d output_type=download \
-o article.pdfOther ways to send the key
curl -X POST https://api.printfriendly.com/v2/pdf/create \
-H "X-API-Key: YOUR_API_KEY" \
-d page_url=https://en.wikipedia.org/wiki/Eclipsecurl "https://api.printfriendly.com/v2/pdf/create?api_key=YOUR_API_KEY" \
-d page_url=https://en.wikipedia.org/wiki/EclipseConvert endpoint
POST /v2/convert takes a web page address, some HTML or some Markdown and gives you back a PDF, a Word (docx) file, Markdown, HTML, JSON or plain text. By default you get the answer in the same request, within 27 seconds. For slow pages and large documents you can run it in the background instead.
Send your key in the Authorization header as Bearer YOUR_API_KEY. The X-API-Key header works too. No key yet? Get a free one on your API key page: 100 documents a month, no card needed.
The full description is in the OpenAPI file. AI agents can read llms-full.txt.
What you send
Send exactly one of url, html or markdown. The body can be JSON or a form, and the same names work in the query string.
| Parameter | Meaning |
|---|---|
| url | The web page to convert. We fetch it and keep the article. Also accepted as page_url. |
| html | HTML to convert. It is treated as the article body, and scripts and anything unsafe are removed. Up to 2 MB of markup; embedded images do not count. |
| markdown | Markdown to convert. Headings, lists, tables and code blocks all work. Up to 1 MB. |
| title | Heading to print above html or markdown. Ignored for url, which uses the page's own title. |
| source_url | With html only. The address the HTML came from. It appears under the title and is used for relative links. |
What you can choose
| Parameter | Meaning |
|---|---|
| output | pdf (the default), docx (Word), markdown, html, json or text. |
| page_size | letter (the default) or a4. For PDF and Word. |
| css_url | Address of a stylesheet to apply. For PDF and HTML. Also accepted as custom_css_url. |
| header_text | A line of text shown above the title. For PDF and HTML. |
| header_image_url | Address of an image shown above the title, such as a logo. For PDF and HTML. |
| lang | Language of the content, such as en or pt-BR. Defaults to the page's own. |
| dir | ltr or rtl. Defaults to the page's own. |
| include_links | true keeps link and image addresses in markdown and text. The default is false: links keep their words and lose their addresses. |
What you get back
| Output | What you get |
|---|---|
A PDF file. The answer has file_url. | |
| docx | A Word file. The answer has file_url. For url input the page's pictures are fetched and included: up to 20 of them, 5 MB each, 20 MB in all, within 10 seconds. Large pictures are scaled down to fit a page. A picture that cannot be fetched is left out, never the document. For html and markdown input only images embedded as data: addresses are included. |
| markdown | The article as Markdown, in content. Tables stay tables. |
| html | A standalone page with our print styling, in content. |
| json | The article in pieces, in document: title, author, date, excerpt, main image, word count, content_html and content_markdown. |
| text | Plain text, in content, with the Markdown syntax removed. |
The answer
A good request answers 200 with status set to success.
{
"status": "success",
"output": "pdf",
"file_url": "https://storage.printfriendly.com/data-production/pdf_1760380000000_a1b2c3Letter.pdf",
"expires_at": "2026-10-15T12:00:00.000Z",
"size_bytes": 184233,
"document": {
"title": "Eclipse",
"url": "https://en.wikipedia.org/wiki/Eclipse",
"site": "en.wikipedia.org",
"excerpt": "An eclipse is an astronomical event that occurs when an astronomical object is temporarily obscured...",
"image": "https://upload.wikimedia.org/wikipedia/commons/thumb/eclipse.jpg",
"word_count": 6120
},
"usage": { "used_this_period": 3, "included": 100, "plan": "free" }
}file_urllinks stay valid for 48 hours. Download the file before then.documentdescribes the article. Forjsonoutput it also holds the content.usageshows how many documents this key has used this period, and how many it includes.- Only a successful conversion counts toward your plan, one per request, whatever the format. A failed request costs nothing.
Background jobs
Add async: true to any convert request and it is queued instead of run while you wait. You get a 202 straight away and read the result when it is ready. Use it for slow pages and large documents, or whenever your own request should not have to wait. The same input, options and outputs work, and a job counts toward your plan just like a normal request.
What you add
| Parameter | Meaning |
|---|---|
| async | true queues the conversion and answers 202 with a job_id at once, instead of waiting for the result. The default is false. Read the result with GET /v2/jobs/{id}, or have it sent to webhook_url. |
| mode | Another way to write async: "async" or "sync". If you send both, they must agree. |
| webhook_url | An https address to POST the finished job to. Only with async. Public hosts only. The delivery is signed and retried: see Webhooks below. |
Start a job
The 202 answer names the job and says where to look at it. expires_at is when the job and its result are deleted, 48 hours after you start it.
{
"status": "queued",
"job_id": "5b1f6a52-0c0e-4d8e-9d8f-3f0a6a7d1c21",
"status_url": "https://api.printfriendly.com/v2/jobs/5b1f6a52-0c0e-4d8e-9d8f-3f0a6a7d1c21",
"expires_at": "2026-10-15T12:00:00.000Z"
}Check a job
GET /v2/jobs/{id}, with the same key. A job can only be read by the key that started it. For any other key, and once the 48 hours are up, the answer is 404 with job_not_found.
| State | Meaning |
|---|---|
| queued | Accepted and waiting for a worker. |
| processing | A worker is converting it. |
| succeeded | Done. result is exactly the body a normal 200 answer carries, usage included. |
| failed | It did not work. error has the same code, message and retryable a failed request carries. A failed job costs nothing. |
queued and processing are not final: look again in a couple of seconds (the answer carries Retry-After while a job is unfinished). succeeded and failed never change. Checking has its own limit, 120 a minute on the free plan and 600 on paid plans, apart from the limit on starting work.
{
"status": "succeeded",
"job_id": "5b1f6a52-0c0e-4d8e-9d8f-3f0a6a7d1c21",
"created_at": "2026-10-13T12:00:00.000Z",
"finished_at": "2026-10-13T12:00:07.412Z",
"result": {
"status": "success",
"output": "pdf",
"file_url": "https://storage.printfriendly.com/data-production/pdf_1760380000000_a1b2c3Letter.pdf",
"expires_at": "2026-10-15T12:00:00.000Z",
"size_bytes": 184233,
"document": {
"title": "Eclipse",
"url": "https://en.wikipedia.org/wiki/Eclipse",
"site": "en.wikipedia.org",
"excerpt": "An eclipse is an astronomical event that occurs when an astronomical object is temporarily obscured...",
"image": "https://upload.wikimedia.org/wikipedia/commons/thumb/eclipse.jpg",
"word_count": 6120
},
"usage": {
"used_this_period": 3,
"included": 100,
"plan": "free"
}
}
}{
"status": "failed",
"job_id": "5b1f6a52-0c0e-4d8e-9d8f-3f0a6a7d1c21",
"created_at": "2026-10-13T12:00:00.000Z",
"finished_at": "2026-10-13T12:00:04.118Z",
"error": {
"code": "fetch_failed",
"message": "Print technology timed-out while trying to connect to this page",
"retryable": true
}
}Limits and counting
- A job counts once, when it succeeds. The free plan's monthly limit and the per-minute request limit are checked when you start the job. The free limit is checked again when the job runs, so jobs queued together cannot take a free key past its month.
- A key can have 3 unfinished jobs on the free plan and 20 on paid plans. Past that, starting another answers
429withRetry-Afteruntil one finishes. The service as a whole also limits how many jobs can wait, and answers the same way. - A job has up to 55 seconds to convert, where a normal request has 27. If the server running a job is replaced, the job is picked up again, so it is not lost. If it still cannot finish, it fails with
retryableset totrue.
Webhooks
Add webhook_url and we POST the finished job to it, so you do not have to keep checking. It must be an https address on a public host. An address that points at a private or internal network is refused with 400.
- The body is JSON: the same object
GET /v2/jobs/{id}returns, withstatusset tosucceededorfailed. - Each delivery has an
X-PrintFriendly-Signatureheader:sha256=followed by the hex HMAC-SHA256 of the raw request body, keyed with your API key. Compute it over the bytes you received, compare in constant time, and ignore a request that does not match. - Answer with any
2xxto confirm. Anything else, or no answer within 10 seconds, is tried again three times, after 2, 10 and 60 seconds. Redirects are not followed. - Delivery is best effort and never delays the job: the result is saved first and stays at
status_urlfor 48 hours. If a webhook does not arrive, check the job. A retry can arrive after a slow success, so usejob_idto ignore repeats.
POST /your/webhook HTTP/1.1
Content-Type: application/json
User-Agent: PrintFriendly-Webhooks/1.0
X-PrintFriendly-Job-Id: 5b1f6a52-0c0e-4d8e-9d8f-3f0a6a7d1c21
X-PrintFriendly-Signature: sha256=0a5f0f7c3d1e9b6a2c8e4f7d1b3a5c9e0f2d4b6a8c1e3f5a7b9d0c2e4f6a8b1c
{"status":"succeeded","job_id":"5b1f6a52-0c0e-4d8e-9d8f-3f0a6a7d1c21", ... }Start a job and wait for it
curl
# 1. Start the job. The answer is 202 with a job_id.
curl -X POST https://api.printfriendly.com/v2/convert \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "docx", "async": true}'
# 2. Check on it with the job_id from step 1. Repeat until status is succeeded or failed.
curl https://api.printfriendly.com/v2/jobs/5b1f6a52-0c0e-4d8e-9d8f-3f0a6a7d1c21 \
-H "Authorization: Bearer YOUR_API_KEY"Python
import time
import requests
headers = {"Authorization": "Bearer YOUR_API_KEY"}
start = requests.post(
"https://api.printfriendly.com/v2/convert",
headers=headers,
json={"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "docx", "async": True},
)
status_url = start.json()["status_url"]
while True:
time.sleep(2)
job = requests.get(status_url, headers=headers).json()
if job["status"] in ("succeeded", "failed"):
break
if job["status"] == "succeeded":
print(job["result"]["file_url"])
else:
print(job["error"]["code"], job["error"]["retryable"])Node
const headers = {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
};
const start = await fetch("https://api.printfriendly.com/v2/convert", {
method: "POST",
headers,
body: JSON.stringify({
url: "https://en.wikipedia.org/wiki/Eclipse",
output: "docx",
async: true,
}),
});
const { status_url } = await start.json();
let job;
do {
await new Promise((resolve) => setTimeout(resolve, 2000));
job = await (await fetch(status_url, { headers })).json();
} while (job.status === "queued" || job.status === "processing");
if (job.status === "succeeded") console.log(job.result.file_url);
else console.error(job.error.code, job.error.retryable);Check a webhook's signature
Python
import hashlib
import hmac
def is_from_printfriendly(raw_body: bytes, signature_header: str, api_key: str) -> bool:
expected = "sha256=" + hmac.new(api_key.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature_header or "")Node
const { createHmac, timingSafeEqual } = require("node:crypto");
// rawBody is the request body exactly as it arrived (a Buffer or a string), not parsed JSON.
function isFromPrintFriendly(rawBody, signatureHeader, apiKey) {
const expected = "sha256=" + createHmac("sha256", apiKey).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(signatureHeader ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}
module.exports = { isFromPrintFriendly };Copy and paste
Replace YOUR_API_KEY with your key.
Web page to PDF
curl
curl -X POST https://api.printfriendly.com/v2/convert \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "pdf"}'Python
import requests
response = requests.post(
"https://api.printfriendly.com/v2/convert",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "pdf"},
)
print(response.json()["file_url"])Node
const response = await fetch("https://api.printfriendly.com/v2/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://en.wikipedia.org/wiki/Eclipse",
output: "pdf",
}),
});
const { file_url } = await response.json();
console.log(file_url);Web page to Markdown
curl
curl -X POST https://api.printfriendly.com/v2/convert \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "markdown"}'Python
import requests
response = requests.post(
"https://api.printfriendly.com/v2/convert",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "markdown"},
)
print(response.json()["content"])Node
const response = await fetch("https://api.printfriendly.com/v2/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://en.wikipedia.org/wiki/Eclipse",
output: "markdown",
}),
});
const { content } = await response.json();
console.log(content);Web page to JSON
curl
curl -X POST https://api.printfriendly.com/v2/convert \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "json"}'Python
import requests
response = requests.post(
"https://api.printfriendly.com/v2/convert",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"url": "https://en.wikipedia.org/wiki/Eclipse", "output": "json"},
)
document = response.json()["document"]
print(document["title"], document["word_count"])
print(document["content_markdown"][:500])Node
const response = await fetch("https://api.printfriendly.com/v2/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://en.wikipedia.org/wiki/Eclipse",
output: "json",
}),
});
const { document } = await response.json();
console.log(document.title, document.word_count);
console.log(document.content_markdown.slice(0, 500));Markdown to Word
curl
curl -X POST https://api.printfriendly.com/v2/convert \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"markdown": "# Notes\n\n- First point\n- Second point", "title": "Meeting notes", "output": "docx"}'Python
import requests
response = requests.post(
"https://api.printfriendly.com/v2/convert",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={
"markdown": "# Notes\n\n- First point\n- Second point",
"title": "Meeting notes",
"output": "docx",
},
)
print(response.json()["file_url"])Node
const response = await fetch("https://api.printfriendly.com/v2/convert", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({
markdown: "# Notes\n\n- First point\n- Second point",
title: "Meeting notes",
output: "docx",
}),
});
const { file_url } = await response.json();
console.log(file_url);If something goes wrong
Failures work exactly as described under Errors below: look at error.code and error.retryable, not the message. Two things are specific to this endpoint. A bad parameter is a 400 naming it in error.errors. An html body over 50 MB is refused with 413 before it is read, and one with more than 2 MB of markup is a 400; both are html_too_large.
Check a key and read its usage
GET /v2/usage answers whether a key works and where it stands this period. It is free: it does not use a document, is never counted toward your plan and is not billed. Use it to check a key when you connect an app, instead of converting a page to find out.
curl https://api.printfriendly.com/v2/usage \
-H "Authorization: Bearer YOUR_API_KEY"{
"status": "success",
"usage": {
"plan": "free",
"used_this_period": 42,
"included": 100,
"remaining": 58
}
}planisfreeorpaid.used_this_periodis what the key has used so far, not counting this request. A free key's period is the calendar month in UTC; a paid key's is its billing period.includedis what the plan includes, andremainingis what is left of it, never below 0.- It answers
200even when the key's allowance is used up: a free key that has used its 100 documents, or a paid key at its quota or its overage cap, getsremaining: 0, where/v2/convertanswers402. A paid key with overage on can go on pastincluded, soremainingreads 0 while it still works. - A missing, unknown or inactive key gets the same
401errors as every other endpoint. The per-minute request limit (10 on the free plan, 120 on paid plans) applies.
Errors
A failed request returns a JSON body. Every failure carries error.code, a stable snake_case name, and error.retryable, which is true when sending the same request again can succeed. They sit alongside the fields older integrations already read, which have not changed. Branch on the code and the flag, not on the English text, which can be reworded.
Fields
| Field | Present on | Meaning |
|---|---|---|
| error.code | every failure | Stable name for what went wrong. See the table below. |
| error.message | every failure | Plain English description. Read this one for display; it exists on every failure. |
| error.retryable | every failure | true if the same request can succeed later. |
| error.type, error.errors | 400 and 401 | type is always "parameters-validation". errors maps each field name to a list of problems. |
| status | every failure | Always "failed". |
| message | 402, 422 and 429 | The same text as error.message, at the top level. |
| error.upgrade_url | 402 | Where to upgrade. |
| error.retry_after_seconds | 429 | Seconds to wait. Also sent as the Retry-After header. |
HTTP status codes
| Status | Meaning |
|---|---|
| 200 | The document was created. Read file_url (or the file itself). |
| 202 | An async /v2/convert job was accepted. Read it later at status_url. |
| 400 | A parameter is missing or invalid. |
| 401 | The key is missing, unknown, inactive or at a limit set on it. |
| 402 | The free plan's monthly documents are used up. |
| 404 | No job with that id for this key (GET /v2/jobs/{id}). |
| 422 | The request was valid but we could not produce the document. |
| 429 | Too many requests this minute. See Retry-After. |
Error codes
| error.code | HTTP | Retry? | Meaning |
|---|---|---|---|
| invalid_parameters | 400 | no | A parameter is missing or invalid. error.errors names each field and what is wrong with it. |
| api_key_required | 401 | no | No API key was sent. |
| invalid_api_key | 401 | no | The key does not exist. Check for a typo, or whether you regenerated the key. |
| subscription_inactive | 401 | no | The plan on this key was canceled or has expired. |
| limit_exceeded | 401 | no | A usage limit set on this key was reached. Contact support to raise it. |
| quota_exceeded | 402 | no | The plan's documents are used up. On the free plan that is 100 a month, and the count resets on the 1st (UTC). On a paid plan it is the quota with overage off, or the monthly overage cap, until the next billing period. error.upgrade_url says where a person can upgrade or change the overage setting. |
| rate_limited | 429 | yes | Too many requests in a minute. Wait the number of seconds in the Retry-After header (also error.retry_after_seconds), then try again. |
| fetch_failed | 422 | yes | We could not load page_url: the connection timed out or was refused, or the site blocks us. retryable is false when the site is blocking us. |
| parse_failed | 422 | no | We loaded the address but found no article content, or it is not a web page (a PDF, a text file or an image). |
| homepage_url | 422 | no | page_url is a site's home page, which has no single article to extract. Link to a specific page. |
| content_blocked | 422 | no | Returned only when content checks are enabled for the key's plan. The page failed the same content checks printfriendly.com applies under the Acceptable Use Policy, so no document was made. The same page fails every time, so do not retry it. |
| render_timeout | 422 | yes | The document was not ready inside 27 seconds, or the service was too busy to start it. |
| render_failed | 422 | yes | Something unexpected went wrong while producing the document. Safe to try again. |
| html_too_large | 400 or 422 | no | 400: the html parameter is over 50 MB. 422: the page at page_url is too large for us to process. |
| job_not_found | 404 | no | GET /v2/jobs/{id} found no job for that id under this key. The id may be wrong, the job may belong to another key, or it may be older than 48 hours. |
Internal details such as fetch-error or high-load are not part of the contract. They are folded into the codes above, and the English sentence that goes with them is unchanged.
Examples
A missing parameter (400):
{
"status": "failed",
"error": {
"type": "parameters-validation",
"errors": { "base": ["You must provide a page_url or html parameter"] },
"message": "You must provide a page_url or html parameter",
"code": "invalid_parameters",
"retryable": false
}
}A page we could not load (422):
{
"status": "failed",
"message": "Print technology timed-out while trying to connect to this page",
"error": {
"code": "fetch_failed",
"message": "Print technology timed-out while trying to connect to this page",
"retryable": true
}
}The free plan used up for the month (402). A paid plan stopped by its overage setting answers the same way, with its own message and a link to the API key page:
{
"status": "failed",
"message": "Your free plan includes 100 documents a month. Upgrade for more.",
"error": {
"code": "quota_exceeded",
"message": "Your free plan includes 100 documents a month. Upgrade for more.",
"upgrade_url": "https://www.printfriendly.com/api#pricing",
"retryable": false
}
}Too many requests this minute (429):
HTTP/1.1 429 Too Many Requests
Retry-After: 23
{
"status": "failed",
"message": "Too many requests. This key allows 10 requests per minute. Retry in 23 seconds.",
"error": {
"code": "rate_limited",
"message": "Too many requests. This key allows 10 requests per minute. Retry in 23 seconds.",
"retryable": true,
"retry_after_seconds": 23
}
}Limits and quotas
Free plan
- Every account can create a free key on its API key page. No credit card is needed.
- It includes 100 documents per calendar month (UTC). The count resets on the 1st.
- It is a hard cap. Past it, requests answer
402withquota_exceededuntil the month turns over or you upgrade. - PDFs made with a free key carry the line “Made with the PrintFriendly API (printfriendly.com/api)” at the foot of every page, and HTML carries it once at the end. Markdown, JSON, text and Word files have none, and paid plans never do.
- When you subscribe to a paid plan the same key keeps working. It just stops being a free key.
- It works the other way too. You can downgrade to the free plan from your API key page at any time: the paid plan runs to the end of the period you have paid for, then the same key becomes a free key again. A paid plan that ends for any other reason lands there as well.
Paid plans
- Each plan has a monthly quota of PDF, HTML and text documents. Usage resets at the start of every billing period.
- What happens past the quota is up to you, with the overage switch on your API key page. With overage on, documents past your quota keep working and are billed at your plan's overage rate on your next invoice. See API pricing for rates. With overage off, requests stop at the quota.
- With overage on you can also set a monthly cap in dollars. Requests stop once the extra documents reach it.
- A stopped request answers
402withquota_exceededuntil the next billing period starts, or until you turn on overage, raise the cap or upgrade.error.upgrade_urllinks to your API key page. - A new subscription starts with overage off, so it never bills past its quota until you turn overage on.
- We email you at 80% and 100% of your quota, and when your cap is reached, once each per billing period.
- If your plan ends while overage is on, extra documents from its last period are billed on a closing invoice.
- You can see your usage for the current period on your API key page.
What counts
- Only successful documents count. Requests that fail (an invalid key, invalid parameters, or a page we can't reach or render) are never counted, and neither are requests refused for rate limiting.
- If a request times out, the document may still finish on our side, and it counts if it does.
Rate limits, time and size
- Requests per minute, per key: 10 on the free plan, 120 on paid plans. Over the limit you get
429with aRetry-Afterheader. - Requests are synchronous with a 27 second ceiling. A document that is not ready by then answers
422withrender_timeout. - The html parameter can be up to 50 MB.
Use of the API is governed by the Acceptable Use Policy, and keys used in breach of it are revoked.
Code samples
The same request in three languages, with the key in an Authorization: Bearer header and the error handling that the codes above make possible.
curl
curl -X POST https://api.printfriendly.com/v2/pdf/create \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"page_url": "https://en.wikipedia.org/wiki/Eclipse"}'Python (requests)
import requests
resp = requests.post(
"https://api.printfriendly.com/v2/pdf/create",
headers={"Authorization": "Bearer YOUR_API_KEY"},
json={"page_url": "https://en.wikipedia.org/wiki/Eclipse"},
timeout=35,
)
data = resp.json()
if data.get("status") == "success":
print(data["file_url"])
else:
err = data["error"]
print(resp.status_code, err["code"], err["message"], "retryable:", err["retryable"])Node.js (fetch)
const res = await fetch("https://api.printfriendly.com/v2/pdf/create", {
method: "POST",
headers: {
Authorization: "Bearer YOUR_API_KEY",
"Content-Type": "application/json",
},
body: JSON.stringify({ page_url: "https://en.wikipedia.org/wiki/Eclipse" }),
});
const data = await res.json();
if (data.status === "success") {
console.log(data.file_url);
} else {
const { code, message, retryable } = data.error;
console.error(res.status, code, message, "retryable:", retryable);
}Using the API from an AI agent or a script
- Get a free key (no card) and send it as
Authorization: Bearer. - Branch on
error.code. Retry only whenerror.retryableis true, and honourRetry-Afteron a 429. - On
quota_exceeded, stop and tell the person: the plan's documents are used up,error.messagesays what to do, anderror.upgrade_urlsays where. - Just need a clean page for a person to read or print? Link them to
https://www.printfriendly.com/print?url=PAGE_URL, no account or key needed. Or use the connector for Claude and other AI assistants.