PrintFriendly

REST API

API Documentation (v2)

Turn a web page or a piece of HTML into a clean PDF, a printable HTML document or plain text. Send one HTTPS request, get back a link to the file.

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

  1. Get a free API key. It includes 100 documents a month and needs no credit card.
  2. Send the key as Authorization: Bearer YOUR_API_KEY.
  3. POST a page_url (or html) to /v2/pdf/create. The reply is JSON with a file_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.

MethodHowNotes
Bearer (recommended)Authorization: Bearer YOUR_API_KEYReplies with JSON.
X-API-KeyX-API-Key: YOUR_API_KEYSame as Bearer, for clients that cannot set an Authorization header. Replies with JSON.
Query parameter?api_key=YOUR_API_KEYWorks, but URLs end up in logs and browser history. Replies with JSON.
HTTP Basic (legacy)Key as the username, empty passwordChanges the reply: you get the file itself, not JSON. See below.
Body fieldapi_keyIn 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.pdf

Endpoints

EndpointInputOutput
/v2/converturl, html or markdownA 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 convertThe job's state and, once it has finished, its result. GET only. See Background jobs.
/v2/usageNothingYour 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/createpage_url or htmlA PDF file (application/pdf). Also available as /v2/pdfs/create.
/v2/html/createpage_url or htmlA standalone, printable HTML document (text/html).
/v2/text/createpage_url or htmlThe 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.

ParameterDefaultWhat it does
page_urlnoneAddress 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.
htmlnoneYour 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.
titlePrint FriendlyDocument 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_sizeLetterPDF page size: A4 or Letter. Any other value gives Letter. Has no effect on html or text output.
css_urlnoneAddress of a stylesheet to apply to the PDF or HTML output. Alias: custom_css_url. Ignored by text/create.
custom_css_urlnoneAlias for css_url. If both are sent, css_url is used.
header_image_urlnoneAddress of an image to show in the document header. Only used with page_url, and only for PDF and HTML output.
header_textnoneA line of text (for example a tagline) shown in the document header. Only used with page_url, and only for PDF and HTML output.
dirnoneText direction, ltr or rtl. With page_url the direction we detect on the page wins and this is the fallback.
langnoneLanguage code for the document, for example en or ar. Sets the lang attribute on the output.
hostnamehost of page_urlHost name used when naming the output file. Only changes the file name.
output_typeJSON replyattachment, 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_keynoneYour 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/Eclipse

Responses

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.pdf

Other 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/Eclipse
curl "https://api.printfriendly.com/v2/pdf/create?api_key=YOUR_API_KEY" \
  -d page_url=https://en.wikipedia.org/wiki/Eclipse

Convert 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.

ParameterMeaning
urlThe web page to convert. We fetch it and keep the article. Also accepted as page_url.
htmlHTML 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.
markdownMarkdown to convert. Headings, lists, tables and code blocks all work. Up to 1 MB.
titleHeading to print above html or markdown. Ignored for url, which uses the page's own title.
source_urlWith html only. The address the HTML came from. It appears under the title and is used for relative links.

What you can choose

ParameterMeaning
outputpdf (the default), docx (Word), markdown, html, json or text.
page_sizeletter (the default) or a4. For PDF and Word.
css_urlAddress of a stylesheet to apply. For PDF and HTML. Also accepted as custom_css_url.
header_textA line of text shown above the title. For PDF and HTML.
header_image_urlAddress of an image shown above the title, such as a logo. For PDF and HTML.
langLanguage of the content, such as en or pt-BR. Defaults to the page's own.
dirltr or rtl. Defaults to the page's own.
include_linkstrue 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

OutputWhat you get
pdfA PDF file. The answer has file_url.
docxA 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.
markdownThe article as Markdown, in content. Tables stay tables.
htmlA standalone page with our print styling, in content.
jsonThe article in pieces, in document: title, author, date, excerpt, main image, word count, content_html and content_markdown.
textPlain 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_url links stay valid for 48 hours. Download the file before then.
  • document describes the article. For json output it also holds the content.
  • usage shows 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

ParameterMeaning
asynctrue 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.
modeAnother way to write async: "async" or "sync". If you send both, they must agree.
webhook_urlAn 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.

StateMeaning
queuedAccepted and waiting for a worker.
processingA worker is converting it.
succeededDone. result is exactly the body a normal 200 answer carries, usage included.
failedIt 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 429 with Retry-After until 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 retryable set to true.

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, with status set to succeeded or failed.
  • Each delivery has an X-PrintFriendly-Signature header: 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 2xx to 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_url for 48 hours. If a webhook does not arrive, check the job. A retry can arrive after a slow success, so use job_id to 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
  }
}
  • plan is free or paid. used_this_period is 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.
  • included is what the plan includes, and remaining is what is left of it, never below 0.
  • It answers 200 even 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, gets remaining: 0, where /v2/convert answers 402. A paid key with overage on can go on past included, so remaining reads 0 while it still works.
  • A missing, unknown or inactive key gets the same 401 errors 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

FieldPresent onMeaning
error.codeevery failureStable name for what went wrong. See the table below.
error.messageevery failurePlain English description. Read this one for display; it exists on every failure.
error.retryableevery failuretrue if the same request can succeed later.
error.type, error.errors400 and 401type is always "parameters-validation". errors maps each field name to a list of problems.
statusevery failureAlways "failed".
message402, 422 and 429The same text as error.message, at the top level.
error.upgrade_url402Where to upgrade.
error.retry_after_seconds429Seconds to wait. Also sent as the Retry-After header.

HTTP status codes

StatusMeaning
200The document was created. Read file_url (or the file itself).
202An async /v2/convert job was accepted. Read it later at status_url.
400A parameter is missing or invalid.
401The key is missing, unknown, inactive or at a limit set on it.
402The free plan's monthly documents are used up.
404No job with that id for this key (GET /v2/jobs/{id}).
422The request was valid but we could not produce the document.
429Too many requests this minute. See Retry-After.

Error codes

error.codeHTTPRetry?Meaning
invalid_parameters400noA parameter is missing or invalid. error.errors names each field and what is wrong with it.
api_key_required401noNo API key was sent.
invalid_api_key401noThe key does not exist. Check for a typo, or whether you regenerated the key.
subscription_inactive401noThe plan on this key was canceled or has expired.
limit_exceeded401noA usage limit set on this key was reached. Contact support to raise it.
quota_exceeded402noThe 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_limited429yesToo many requests in a minute. Wait the number of seconds in the Retry-After header (also error.retry_after_seconds), then try again.
fetch_failed422yesWe 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_failed422noWe loaded the address but found no article content, or it is not a web page (a PDF, a text file or an image).
homepage_url422nopage_url is a site's home page, which has no single article to extract. Link to a specific page.
content_blocked422noReturned 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_timeout422yesThe document was not ready inside 27 seconds, or the service was too busy to start it.
render_failed422yesSomething unexpected went wrong while producing the document. Safe to try again.
html_too_large400 or 422no400: the html parameter is over 50 MB. 422: the page at page_url is too large for us to process.
job_not_found404noGET /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 402 with quota_exceeded until 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 402 with quota_exceeded until the next billing period starts, or until you turn on overage, raise the cap or upgrade. error.upgrade_url links 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 429 with a Retry-After header.
  • Requests are synchronous with a 27 second ceiling. A document that is not ready by then answers 422 with render_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 when error.retryable is true, and honour Retry-After on a 429.
  • On quota_exceeded, stop and tell the person: the plan's documents are used up, error.message says what to do, and error.upgrade_url says 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.