API REFERENCE

API documentation

Convert any web page or raw HTML into a pixel-perfect PDF with one HTTP request. This page covers the full transformy.io API: authentication, every conversion parameter, async jobs, webhooks, and ready-to-paste recipes.

Introduction

The transformy API renders any URL or raw HTML document into a PDF with modern, browser-grade fidelity — flexbox, grid, web fonts, JavaScript-driven pages and print stylesheets all work exactly as they do in a current browser. You send one HTTP request; you get a PDF back.

Base URL
https://api.transformy.io/v1/
  • JSON everywhere. Requests and responses use JSON with snake_case keys. Physical measurements are strings with explicit units ("2cm", "0.5in", "20px"); booleans are real booleans, never strings.
  • Versioned by path. The API is versioned in the URL (/v1/). We never break within a version — changes to v1 are additive only, and breaking changes ship as /v2, announced well in advance with Deprecation and Sunset headers.
  • Sensible defaults. A minimal request — {"url": "https://example.com"} — produces a good PDF. Every default can be overridden; see Parameters.
  • Traceable. Every response carries an X-Request-Id header. Include it when contacting support and we can pull up the exact request, its parameters and timing.

An unauthenticated liveness check is available at GET /v1/health.

Quickstart

Your first PDF in under five minutes:

  1. Create an account and copy a test key (tfy_test_…) from the dashboard. Test keys are free, never consume credits, and watermark their output — perfect for experimenting. Export it as TRANSFORMY_API_KEY in your shell.
  2. Send a URL (or raw HTML) to the conversion endpoint:
curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_test_..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com" }' \
  --fail -o example.pdf
import fs from "node:fs";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://example.com" }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("example.pdf", Buffer.from(await res.arrayBuffer()));
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={"url": "https://example.com"},
)
res.raise_for_status()
with open("example.pdf", "wb") as f:
    f.write(res.content)
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode(["url" => "https://example.com"]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("example.pdf", $body);
}
payload := []byte(`{"url": "https://example.com"}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("example.pdf", pdf, 0o644)
}
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = { url: "https://example.com" }.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("example.pdf", res.body) if res.code == "200"
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"url\": \"https://example.com\"}"))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("example.pdf"), response.body());
}

On success the response body is the PDF (Content-Type: application/pdf). On failure the body is a JSON error object with a proper HTTP status — so always check the status before writing bytes to disk.

From here:

  • Swap in a live key (tfy_live_…) when you're ready for unwatermarked output.
  • Want a hosted download URL instead of raw bytes? Set "output": "json" — see Output modes.
  • Sending retries? Add an Idempotency-Key header so a retried request can never render (or charge) twice.
  • Long or bulk renders? Go asynchronous and get a webhook when the PDF is ready.

Authentication

Every request is authenticated with a secret API key. Pass it as a Bearer token in the Authorization header. For tooling that expects a key header instead, X-API-Key is also accepted — the two forms are equivalent:

Authorization: Bearer tfy_live_9xJk3...   # primary
X-API-Key: tfy_live_9xJk3...              # also accepted

Keys are prefixed and mode-encoded — tfy_live_… for live keys, tfy_test_… for test keys — so the mode is visible at a glance and leaked keys are greppable by secret scanners. Keys are secret and meant for server-to-server use: keep them out of client-side code and version control, and load them from the environment.

  • Multiple named keys. Create as many keys as you need per account (one per service, per environment, per teammate). Each shows its creation and last-used timestamps in the dashboard.
  • Instant revocation, zero-downtime rotation. Create a new key, deploy it, then revoke the old one — revocation takes effect immediately.
  • Shown once, stored hashed. The full key is displayed exactly once, at creation. We store only a hash; afterwards the dashboard shows a redacted form like tfy_live_...9xJk.

Authentication failures are deliberate and loud. A missing, invalid, or revoked key returns 401 with a WWW-Authenticate: Bearer header. A valid key attempting a forbidden action — a plan-gated option, or a test key on a live-only operation — returns 403. A malformed key never silently falls back to a free or anonymous account: the request fails, so a typo in your deploy config surfaces immediately instead of quietly degrading your output. See Errors for the response shape.

Test mode

tfy_test_ keys hit the same endpoints and run real conversions — same rendering, same parameters, same responses — with a few differences:

  • Output carries a diagonal "transformy.io TEST" watermark.
  • Test usage is free and effectively unlimited: a concurrency cap of 2 parallel renders and a modest rate limit, but no meaningful ceiling — run it in CI forever.
  • Test conversions never consume credits.
  • Hosted files produced in test mode expire after 24 hours.

You can also force test-mode behavior on a live key by setting "test": true in the request body — handy for a one-off experiment without switching keys. See Parameters.

Convert a URL or raw HTML

POST/v1/pdf/chrome

One endpoint converts to PDF, rendered with headless Chrome for full browser-grade fidelity. The input goes in the JSON body as either url or html:

Convert a URL
{ "url": "https://example.com/invoice/123" }
Convert raw HTML
{ "html": "<!doctype html><html>...</html>" }

Exactly one of the two is required. Sending both — or neither — fails with a 422 mutually_exclusive error. There is deliberately no auto-detecting source field: an HTML document that happens to begin with a URL-looking string would silently be fetched as a URL instead of rendered, and that class of bug is miserable to debug. You always say which one you mean.

A complete request — convert a small HTML snippet, name the download with filename, and save the binary response:

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice #1042</h1><p>Total due: $180.00</p>",
    "filename": "invoice-1042.pdf"
  }' \
  --fail -o invoice-1042.pdf
import fs from "node:fs";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    html: "<h1>Invoice #1042</h1><p>Total due: $180.00</p>",
    filename: "invoice-1042.pdf",
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("invoice-1042.pdf", Buffer.from(await res.arrayBuffer()));
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={
        "html": "<h1>Invoice #1042</h1><p>Total due: $180.00</p>",
        "filename": "invoice-1042.pdf",
    },
)
res.raise_for_status()
with open("invoice-1042.pdf", "wb") as f:
    f.write(res.content)
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "html" => "<h1>Invoice #1042</h1><p>Total due: $180.00</p>",
        "filename" => "invoice-1042.pdf",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("invoice-1042.pdf", $body);
}
payload := []byte(`{
  "html": "<h1>Invoice #1042</h1><p>Total due: $180.00</p>",
  "filename": "invoice-1042.pdf"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("invoice-1042.pdf", pdf, 0o644)
}
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = {
  html: "<h1>Invoice #1042</h1><p>Total due: $180.00</p>",
  filename: "invoice-1042.pdf",
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("invoice-1042.pdf", res.body) if res.code == "200"
var client = HttpClient.newHttpClient();
var payload = "{\"html\": \"<h1>Invoice #1042</h1><p>Total due: $180.00</p>\", \"filename\": \"invoice-1042.pdf\"}";
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("invoice-1042.pdf"), response.body());
}

Conversion is synchronous by default: the response body is the PDF itself, with a Content-Disposition header built from filename (RFC 6266 — we emit filename* for non-ASCII names). The render budget is controlled by timeout: 30000 ms by default, up to 120000 ms for synchronous requests. A render that exceeds it fails with a source_timeout error — we never silently turn a slow sync request into a job. If your pages legitimately need longer, switch to async.

Everything about the render is tunable — page size, margins, orientation, backgrounds, headers and footers, waiting behavior, and more. See Parameters for the full list, and Output modes if you want a hosted URL or a bucket upload instead of raw bytes.

Parameters

Every option is a field in the JSON body of POST /v1/pdf/chrome. Physical measurements are strings with explicit units ("2cm", "0.5in", "20px"); booleans are real booleans. Parameters compose freely — a full request combining several of them is shown at the end of this section.

Source

ParameterTypeDefaultDescription
urlstringTarget URL to render (http/https only). Mutually exclusive with html.
htmlstringRaw HTML document to render. Mutually exclusive with url.

Exactly one of url or html is required — sending both (or neither) is rejected with a 422. See Converting.

Page setup

ParameterTypeDefaultDescription
page_sizestring"a4"Named paper size: a0a6, letter, legal, tabloid, ledger.
page_width / page_heightunit stringCustom page size ("210mm", "8.5in"). Both must be provided together; overrides page_size.
orientationstring"portrait"portrait or landscape.
marginobject | stringEither an object — {"top": "1cm", "right": "1cm", "bottom": "1cm", "left": "1cm"} — or a CSS-style shorthand string ("2cm", "1cm 2cm"). Omitted sides use the standard print margins.
page_rangesstringall pagesPages to include, e.g. "1-5, 8, 11-13".
zoomnumber1.0Render scale, 0.12.0.
print_backgroundbooleantrueRender background colors and images. Deliberately defaults to true — raw browser printing defaults backgrounds off, which is almost never what you want from an API; set false to get the ink-saving behavior.
prefer_css_page_sizebooleanfalseHonor a size declared in the document's @page CSS rule over page_size.
single_pagebooleanfalseProduce one tall page sized to the content instead of paginating.
omit_backgroundbooleanfalseTransparent page background.
taggedbooleanfalseProduce a tagged (accessible) PDF with document structure for screen readers.
outlinebooleanfalseGenerate PDF bookmarks from the document's headings.

Rendering environment

ParameterTypeDefaultDescription
mediastring"print"CSS media type to emulate: print or screen. Use screen when the page's print stylesheet is missing or unwanted.
javascriptbooleantrueExecute the page's JavaScript.
viewportobjectLayout viewport: {"width": 1280, "height": 1024, "device_scale_factor": 1}.
timezonestringIANA timezone id the page sees, e.g. "Europe/Brussels".
localestringLocale tag, e.g. "fr-BE" — affects Intl formatting and navigator.language.
inject_cssstringExtra CSS appended after the page loads.
inject_jsstringJavaScript executed after load, before capture.
blockobjectDeclarative blocking: {"ads": true, "cookie_banners": true, "resource_types": ["image", "font"], "url_patterns": ["*tracker*"]}. Any subset of keys.
fail_on_console_errorsbooleanfalseFail the conversion if the page throws an uncaught JavaScript error — useful in CI-quality pipelines.

Readiness controls (wait_until, wait_for_selector, wait_for_expression, delay, wait_for_fonts) are covered in Waiting; page headers and footers in Headers & footers.

Fetching pages behind auth

These apply in url mode only, and let you render pages that sit behind a login or an API gateway.

ParameterTypeDefaultDescription
http_headersobjectExtra headers sent with the main document request, e.g. {"Authorization": "Bearer eyJ…"}. Hop-by-hop headers and Host are stripped.
cookiesarrayCookies injected before navigation — the standard way to render pages behind a session login. Object shape below.
http_authobject{"username": "u", "password": "p"} — answers HTTP Basic/Digest challenges.
user_agentstringCustom User-Agent for all requests the page makes.
fail_on_statusbooleantrueFail the conversion if the main document returns a non-2xx status (the code is surfaced in the error) instead of rendering the error page.

Each entry in cookies takes the full cookie shape:

"cookies": [
  {
    "name": "sessionid",
    "value": "abc123",
    "domain": ".example.com",
    "path": "/",
    "secure": true,
    "http_only": true,
    "same_site": "Lax"
  }
]

Credentials sent in these fields are used transiently for the render, never persisted, and redacted from job objects, logs and the dashboard. Even so, prefer short-lived, least-privilege tokens over long-lived session credentials. See Security.

Output & delivery

ParameterTypeDefaultDescription
outputstring"file"file (binary response), json (hosted URL), base64 (inline), or storage (upload to your bucket) — see Output modes.
filenamestring"document.pdf"Filename used in Content-Disposition and hosted URLs (non-ASCII handled via filename*).
expires_ininteger (s)86400Hosted-file TTL for output: "json", max 30 days — see Output modes.
storageobjectPresigned upload destination for output: "storage" — see Output modes.
asyncbooleanfalseReturn 202 and a job immediately; implied true when webhook_url is set — see Async.
webhook_urlstringHTTPS URL notified when the job completes — see Webhooks.
timeoutinteger (ms)30000Overall render budget. Max 120000 for sync requests; higher caps apply to async.

Document metadata & protection

ParameterTypeDefaultDescription
titlestringPDF document title metadata.
metadataobject{"author": "…", "subject": "…", "keywords": "…", "creator": "…"}.
protectionobjectPDF encryption and permissions — see below.
testbooleanfalseForce test-mode behavior (watermark, no credit charged) even on a live key — handy for one-off experiments. See Test mode.

protection encrypts the PDF and sets its permission flags:

  • user_password — required to open the document.
  • owner_password — required to change permissions.
  • allow_printing, allow_copying, allow_modifying, allow_annotating, allow_filling_forms — booleans controlling what a reader may do.
"protection": {
  "user_password": "open-me",
  "owner_password": "admin",
  "allow_printing": true,
  "allow_copying": false,
  "allow_modifying": false,
  "allow_annotating": false,
  "allow_filling_forms": true
}

Putting it together

Parameters compose in a single flat body. A US-letter landscape render with custom margins, backgrounds on, delivered as a hosted URL:

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/report",
    "page_size": "letter",
    "orientation": "landscape",
    "margin": {"top": "2cm", "right": "1.5cm", "bottom": "2cm", "left": "1.5cm"},
    "print_background": true,
    "output": "json"
  }'
const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/report",
    page_size: "letter",
    orientation: "landscape",
    margin: { top: "2cm", right: "1.5cm", bottom: "2cm", left: "1.5cm" },
    print_background: true,
    output: "json",
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
const { file } = await res.json();
console.log(file.url);
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={
        "url": "https://example.com/report",
        "page_size": "letter",
        "orientation": "landscape",
        "margin": {"top": "2cm", "right": "1.5cm", "bottom": "2cm", "left": "1.5cm"},
        "print_background": True,
        "output": "json",
    },
)
res.raise_for_status()
data = res.json()
print(data["file"]["url"])
<?php
$payload = [
    "url" => "https://example.com/report",
    "page_size" => "letter",
    "orientation" => "landscape",
    "margin" => ["top" => "2cm", "right" => "1.5cm", "bottom" => "2cm", "left" => "1.5cm"],
    "print_background" => true,
    "output" => "json",
];
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    $data = json_decode($body, true);
    echo $data["file"]["url"];
}
payload := []byte(`{
  "url": "https://example.com/report",
  "page_size": "letter",
  "orientation": "landscape",
  "margin": {"top": "2cm", "right": "1.5cm", "bottom": "2cm", "left": "1.5cm"},
  "print_background": true,
  "output": "json"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = {
  url: "https://example.com/report",
  page_size: "letter",
  orientation: "landscape",
  margin: { top: "2cm", right: "1.5cm", bottom: "2cm", left: "1.5cm" },
  print_background: true,
  output: "json",
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
puts data.dig("file", "url") if res.code == "200"
var payload = "{\"url\": \"https://example.com/report\", \"page_size\": \"letter\", "
    + "\"orientation\": \"landscape\", "
    + "\"margin\": {\"top\": \"2cm\", \"right\": \"1.5cm\", \"bottom\": \"2cm\", \"left\": \"1.5cm\"}, "
    + "\"print_background\": true, \"output\": \"json\"}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(response.body());

Because output is "json", the response is the JSON envelope with a hosted file.url rather than raw bytes — see Output modes.

Headers & footers

Repeat a running header or footer — page numbers, a date, a logo — on every page by passing a header and/or footer object with an html key. Setting either one turns it on; there is no separate display flag to remember.

"header": {"html": "<div style='font-size:10px; width:100%; text-align:center;'>{{title}}</div>"},
"footer": {"html": "<div style='font-size:10px;'>Page {{page}} of {{pages}}</div>"}

Template variables

Inside header and footer HTML, these placeholders are substituted per page:

VariableRenders as
{{page}}Current page number.
{{pages}}Total number of pages.
{{date}}Date the document was rendered.
{{title}}Document title.
{{url}}URL of the document.

The header is its own tiny document

Header and footer HTML is rendered in isolation, not inside your page — which causes the classic surprises:

  • Your page's CSS does not cascade into it. Style the fragment with inline styles, or include a <style> tag inside the fragment itself.
  • Set an explicit font-size. The default is effectively zero — an unstyled footer renders invisibly and looks like it was silently dropped.
  • External resources don't load. Inline images as data: URIs; no external stylesheets, fonts, or scripts.
  • The page margin must leave room. Headers and footers draw inside the margin area set by the margin parameter — not by the header itself. If the margin is too small, the header or footer is clipped or invisible.

A complete, working example

A footer with page numbers, following every rule above: explicit font size, inline styles only, and a margin that leaves room for it.

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/report",
    "footer": {"html": "<div style=\"font-size:10px; width:100%; text-align:center; color:#666;\">Page {{page}} of {{pages}}</div>"},
    "margin": "20mm"
  }' \
  --fail -o report.pdf
import fs from "node:fs";

const footer = {
  html: "<div style='font-size:10px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>",
};

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://example.com/report", footer, margin: "20mm" }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("report.pdf", Buffer.from(await res.arrayBuffer()));
import os, requests

footer = {
    "html": "<div style='font-size:10px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>"
}

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={"url": "https://example.com/report", "footer": footer, "margin": "20mm"},
)
res.raise_for_status()
with open("report.pdf", "wb") as f:
    f.write(res.content)
<?php
$footer = [
    "html" => "<div style='font-size:10px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>",
];
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://example.com/report",
        "footer" => $footer,
        "margin" => "20mm",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("report.pdf", $body);
}
payload := []byte(`{
  "url": "https://example.com/report",
  "footer": {"html": "<div style='font-size:10px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>"},
  "margin": "20mm"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("report.pdf", pdf, 0o644)
}
require "net/http"
require "json"

footer = {
  html: "<div style='font-size:10px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>",
}

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = { url: "https://example.com/report", footer: footer, margin: "20mm" }.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("report.pdf", res.body) if res.code == "200"
var payload = "{\"url\": \"https://example.com/report\", "
    + "\"footer\": {\"html\": \"<div style='font-size:10px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>\"}, "
    + "\"margin\": \"20mm\"}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("report.pdf"), response.body());
}

A richer footer to copy

Left/right layout with inline flex and an inlined logo — everything a real invoice footer needs, ready to drop into footer.html:

footer.html
<div style="font-size:9px; color:#666; width:100%; margin:0 10mm;
            display:flex; align-items:center; justify-content:space-between;">
  <!-- external images never load here: inline your logo as a data URI -->
  <img src="data:image/png;base64,iVBORw0KGg..." style="height:10px;" alt="logo" />
  <span>Acme Inc. — {{date}}</span>
  <span>Page {{page}} of {{pages}}</span>
</div>

Remember the space rule: the footer draws inside the bottom margin, so pair fragments like this with a margin whose bottom side is generous enough (see Page setup).

Waiting

Pages that build their content with JavaScript — dashboards, charts, SPAs — need a readiness signal before capture. Without one, the PDF is taken the moment the page loads, and you get a blank frame or a loading spinner instead of your data. These parameters tell the renderer what "ready" means:

ParameterTypeDefaultDescription
wait_untilstring"load"Page lifecycle event to wait for before the other conditions are evaluated. Values below.
wait_for_selectorstring | objectWait until a CSS selector matches. Object form for finer control: {"selector": "#chart", "state": "visible", "timeout": 15000}.
wait_for_expressionstringJavaScript expression polled in the page until it evaluates truthy, e.g. "window.renderComplete === true".
delayinteger (ms)0Fixed wait before capture. Capped at 10000.
wait_for_fontsbooleantrueWait for all web fonts to finish loading (document.fonts.ready) before capture.

wait_until accepts:

  • load — the page's load event has fired (default).
  • domcontentloaded — the DOM is parsed; images, stylesheets and scripts may still be loading.
  • networkidle — the network has been mostly quiet (at most two in-flight requests) for 500 ms. The right choice for most JS-heavy pages.
  • networkidle_strict — the network has been completely quiet (zero in-flight requests) for 500 ms. Beware: pages with long-polling or analytics beacons may never reach this state.

Prefer an explicit signal over a timer. wait_for_selector and wait_for_expression fire the instant the page is actually ready — fast when it's fast, patient when it's slow. delay is the flakiest option: too short and you capture a half-rendered page, too long and every conversion pays the full wait. If you control the page, the most robust pattern is an element (or a global flag) that only appears once the content is genuinely ready, and a wait_for_selector pointed at it.

For example, capturing a chart dashboard that inserts #chart-ready when its last chart has drawn:

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/dashboards/42",
    "wait_until": "networkidle",
    "wait_for_selector": "#chart-ready"
  }' \
  --fail -o dashboard.pdf
import fs from "node:fs";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://app.example.com/dashboards/42",
    wait_until: "networkidle",
    wait_for_selector: "#chart-ready",
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("dashboard.pdf", Buffer.from(await res.arrayBuffer()));
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={
        "url": "https://app.example.com/dashboards/42",
        "wait_until": "networkidle",
        "wait_for_selector": "#chart-ready",
    },
)
res.raise_for_status()
with open("dashboard.pdf", "wb") as f:
    f.write(res.content)
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://app.example.com/dashboards/42",
        "wait_until" => "networkidle",
        "wait_for_selector" => "#chart-ready",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("dashboard.pdf", $body);
}
payload := []byte(`{
  "url": "https://app.example.com/dashboards/42",
  "wait_until": "networkidle",
  "wait_for_selector": "#chart-ready"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("dashboard.pdf", pdf, 0o644)
}
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = {
  url: "https://app.example.com/dashboards/42",
  wait_until: "networkidle",
  wait_for_selector: "#chart-ready",
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("dashboard.pdf", res.body) if res.code == "200"
var payload = "{\"url\": \"https://app.example.com/dashboards/42\", "
    + "\"wait_until\": \"networkidle\", \"wait_for_selector\": \"#chart-ready\"}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("dashboard.pdf"), response.body());
}

All waits run inside the overall render budget set by timeout (see Output & delivery) — a condition that never becomes true can't hang a request. A selector that never appears fails the conversion with selector_not_found, and a budget overrun with source_timeout, rather than silently capturing a broken page — see Errors.

Output modes

The response shape is chosen by you, never inferred: the output parameter selects one of four modes, and the response for a given mode is always the same shape regardless of which other parameters you set. "file" (the default) streams the PDF back as binary; "json" stores it and returns a hosted URL; "base64" inlines the bytes in a JSON envelope; "storage" uploads straight to your own bucket.

file — binary response (default)

The response body is the PDF itself. The headers carry everything you'd otherwise need a JSON envelope for — the request id, page count, credit usage, and (in url mode) the HTTP status the target returned:

Response · 200
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Length: 182034
Content-Disposition: attachment; filename="invoice.pdf"
X-Request-Id: req_9f3k2j
X-PDF-Pages: 4
X-Credits-Used: 1
X-Credits-Remaining: 4312
X-Source-Status: 200          # HTTP status of the target URL (url mode)

Check the status before writing bytes

Errors on this endpoint are always JSON with a proper HTTP status code — never a 200 with an error body, and never error text inside a PDF. If you write the response to disk without checking the status, a failed request leaves you with a .pdf file containing a JSON error. Check for 200 first; on anything else, parse the body as a JSON error object.

json — hosted URL

With "output": "json" we store the PDF and return a JSON envelope with a hosted download URL instead of raw bytes:

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/invoice/123",
    "output": "json"
  }'
const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://example.com/invoice/123", output: "json" }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
const data = await res.json();
console.log(data.file.url);
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={"url": "https://example.com/invoice/123", "output": "json"},
)
res.raise_for_status()
data = res.json()
print(data["file"]["url"])
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://example.com/invoice/123",
        "output" => "json",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    $data = json_decode($body, true);
    echo $data["file"]["url"];
}
payload := []byte(`{"url": "https://example.com/invoice/123", "output": "json"}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    var data struct {
        File struct {
            URL string `json:"url"`
        } `json:"file"`
    }
    json.NewDecoder(res.Body).Decode(&data)
    fmt.Println(data.File.URL)
}
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = { url: "https://example.com/invoice/123", output: "json" }.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
puts data["file"]["url"] if res.code == "200"
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString("{\"url\": \"https://example.com/invoice/123\", \"output\": \"json\"}"))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
    // Extract file.url from the envelope with your JSON library of choice.
    System.out.println(response.body());
}
Response · 200
{
  "object": "conversion",
  "request_id": "req_9f3k2j",
  "file": {
    "id": "file_8snm2k",
    "url": "https://files.transformy.io/8snm2k/invoice.pdf?sig=...",
    "size_bytes": 182034,
    "pages": 4,
    "expires_at": "2026-07-09T14:00:00Z"
  },
  "duration_ms": 2140,
  "credits_used": 1
}

The hosted file lives for expires_in seconds — 86400 (24 hours) by default, up to a maximum of 30 days. The download URL is a signed, expiring CDN URL, not an API-authenticated route: it needs no API key, so it's safe to hand directly to browsers, emails, or your own users. See Files for fetching metadata or deleting a hosted file before its TTL.

base64 — inline bytes

"output": "base64" returns the same JSON envelope with a "content" field holding the base64-encoded PDF instead of file.url. Nothing is stored. Use it in environments that can't receive a binary response and don't want us hosting the file — compliance-sensitive pipelines, serverless platforms that mangle binary bodies, and the like. Remember that base64 inflates the payload by roughly a third.

storage — upload to your bucket

"output": "storage" uploads the finished PDF directly to your own bucket via a presigned upload URL. The pattern is cloud-agnostic — S3, GCS, Azure Blob, R2, MinIO, anything that accepts a presigned PUT — and we never hold your cloud credentials: you mint a short-lived upload URL, we use it once.

Request
{
  "url": "https://example.com/invoice/123",
  "output": "storage",
  "storage": {
    "url": "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=...",
    "method": "PUT",
    "headers": {"x-amz-storage-class": "STANDARD_IA"}
  }
}

storage.url is required and must be HTTPS. method defaults to PUT (POST is also allowed, for GCS resumable-style flows). headers are sent verbatim with the upload — needed when your signature covers headers like Content-Type or a storage class. The response is the usual conversion envelope with a storage result in place of a hosted file:

Response · 200
{
  "object": "conversion",
  "request_id": "req_9f3k2j",
  "storage": {"delivered": true, "status": 200, "size_bytes": 182034, "pages": 4},
  "duration_ms": 2140,
  "credits_used": 1
}

A render that succeeds but fails to upload is a failed conversion: you get a 422 with code storage_upload_failed, the destination's HTTP status and a response excerpt in the message, and no credits are charged. There is no half-success state where the PDF exists but you can't reach it.

Presigned URLs that expire mid-render

The classic pitfall: a presigned URL minted with a 60-second validity expires before a slow render finishes, and the upload fails with a 403 from your bucket. Sign upload URLs with at least 15 minutes of validity.

Two security notes. The storage url is treated as a credential — it is redacted from job objects, logs, and the dashboard. And uploads are restricted to public HTTPS endpoints: a presigned URL pointing at a private-range or internal host is rejected with url_blocked, per the same rules as URL fetching.

storage works with async too: the job's file field is replaced by the same storage result, and the webhook fires only after the upload has succeeded — so a job.succeeded event always means the PDF is already in your bucket.

Asynchronous conversions

Set "async": true on any conversion request to queue it as a background job instead of waiting for the PDF in the same connection. Providing a webhook_url implies async: true automatically — see Webhooks. The API responds immediately with 202 Accepted, a Location header pointing at the job, and the job object as the body:

Response · 202 Accepted
HTTP/1.1 202 Accepted
Location: /v1/jobs/job_7d2mfa
{
  "object": "job",
  "id": "job_7d2mfa",
  "status": "queued",
  "created_at": "2026-07-08T14:00:00Z",
  "input": {"url": "https://example.com/invoice/123"},
  "file": null,
  "error": null
}

Job lifecycle

A job moves through a small, terminal-explicit set of states: queuedprocessingsucceeded | failed | canceled.

  • queued / processing — in progress; keep polling (or wait for the webhook).
  • succeeded — terminal. file is populated with the same file object returned by output: "json" (see Output modes).
  • failed — terminal. error carries the standard error object with a stable machine code.
  • canceled — terminal. You canceled the job before it finished.

Polling

Poll GET /v1/jobs/{job_id} to fetch the current job object. While the job is queued or processing, the response carries a Retry-After: 2 header — honor it rather than hammering the endpoint. Once the status is terminal, read file (on success) or error (on failure).

Job endpoints

EndpointDescription
GET /v1/jobs/{job_id}Poll status and fetch result metadata.
GET /v1/jobsList jobs, newest first. Cursor pagination via limit and starting_after; filter with status=.
DELETE /v1/jobs/{job_id}Cancel a job — only while it is queued or processing. Canceling a job in a terminal state returns 409.

Submit and poll

Submit an async job, poll until it reaches a terminal state, then print the hosted file URL:

# submit the job
JOB_ID=$(curl -s -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://example.com/invoice/123", "async": true }' \
  | jq -r '.id')

# poll until terminal
while :; do
  JOB=$(curl -s https://api.transformy.io/v1/jobs/$JOB_ID \
    -H "Authorization: Bearer tfy_live_...")
  STATUS=$(echo "$JOB" | jq -r '.status')
  case "$STATUS" in queued|processing) sleep 2 ;; *) break ;; esac
done

echo "$JOB" | jq -r '.file.url'
const submit = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ url: "https://example.com/invoice/123", async: true }),
});
let job = await submit.json();

while (job.status === "queued" || job.status === "processing") {
  await new Promise((r) => setTimeout(r, 2000));
  const res = await fetch(`https://api.transformy.io/v1/jobs/${job.id}`, {
    headers: { Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}` },
  });
  job = await res.json();
}

if (job.status === "succeeded") console.log(job.file.url);
else console.error(job.error);
import os, time, requests

headers = {"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"}
job = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers=headers,
    json={"url": "https://example.com/invoice/123", "async": True},
).json()

while job["status"] in ("queued", "processing"):
    time.sleep(2)
    job = requests.get(
        f"https://api.transformy.io/v1/jobs/{job['id']}", headers=headers
    ).json()

if job["status"] == "succeeded":
    print(job["file"]["url"])
else:
    print(job["error"])
<?php
function tfy(string $method, string $url, ?array $payload = null): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER => [
            "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS => $payload ? json_encode($payload) : null,
    ]);
    return json_decode(curl_exec($ch), true);
}

$job = tfy("POST", "https://api.transformy.io/v1/pdf/chrome",
    ["url" => "https://example.com/invoice/123", "async" => true]);

while (in_array($job["status"], ["queued", "processing"])) {
    sleep(2);
    $job = tfy("GET", "https://api.transformy.io/v1/jobs/" . $job["id"]);
}

echo $job["status"] === "succeeded" ? $job["file"]["url"] : json_encode($job["error"]);
type job struct {
	ID     string `json:"id"`
	Status string `json:"status"`
	File   *struct {
		URL string `json:"url"`
	} `json:"file"`
}

do := func(method, url string, body []byte) job {
	req, _ := http.NewRequest(method, url, bytes.NewBuffer(body))
	req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	res, _ := http.DefaultClient.Do(req)
	defer res.Body.Close()
	var j job
	json.NewDecoder(res.Body).Decode(&j)
	return j
}

j := do("POST", "https://api.transformy.io/v1/pdf/chrome",
	[]byte(`{"url": "https://example.com/invoice/123", "async": true}`))
for j.Status == "queued" || j.Status == "processing" {
	time.Sleep(2 * time.Second)
	j = do("GET", "https://api.transformy.io/v1/jobs/"+j.ID, nil)
}
if j.Status == "succeeded" {
	fmt.Println(j.File.URL)
}
require "net/http"
require "json"

def tfy(req, uri)
  req["Authorization"] = "Bearer #{ENV['TRANSFORMY_API_KEY']}"
  res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
  JSON.parse(res.body)
end

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req.body = { url: "https://example.com/invoice/123", async: true }.to_json
job = tfy(req, uri)

while %w[queued processing].include?(job["status"])
  sleep 2
  uri = URI("https://api.transformy.io/v1/jobs/#{job['id']}")
  job = tfy(Net::HTTP::Get.new(uri), uri)
end

puts job["status"] == "succeeded" ? job["file"]["url"] : job["error"]
var client = HttpClient.newHttpClient();
var apiKey = System.getenv("TRANSFORMY_API_KEY");

var submit = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + apiKey)
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(
        "{\"url\": \"https://example.com/invoice/123\", \"async\": true}"))
    .build();
// parse with your JSON library of choice; org.json shown here
var job = new org.json.JSONObject(
    client.send(submit, HttpResponse.BodyHandlers.ofString()).body());

while (job.getString("status").equals("queued") || job.getString("status").equals("processing")) {
    Thread.sleep(2000);
    var poll = HttpRequest.newBuilder()
        .uri(URI.create("https://api.transformy.io/v1/jobs/" + job.getString("id")))
        .header("Authorization", "Bearer " + apiKey)
        .build();
    job = new org.json.JSONObject(client.send(poll, HttpResponse.BodyHandlers.ofString()).body());
}

if (job.getString("status").equals("succeeded")) {
    System.out.println(job.getJSONObject("file").getString("url"));
}

Sync requests are never silently upgraded

A synchronous request that exceeds its timeout is not quietly turned into a 202 — it fails with the source_timeout error code. The fix is to send it async. Use async for anything that runs near the timeout and for bulk work; in production, webhooks beat polling.

Webhooks

Webhooks push the result of an async job to your server the moment it finishes — no polling loop. There are two ways to receive them:

  • Per-request webhook_url (most common) — pass an HTTPS URL with the conversion request; setting it implies async: true. The event for that job is delivered to that URL.
  • Account-level endpoints — managed in the dashboard or via GET/POST/DELETE /v1/webhook_endpoints. Every job event on the account is delivered to each enabled endpoint.

Deliveries are POST requests with a JSON event envelope. data.object is the full job resource, including the file object on success:

Event payload
{
  "id": "evt_3ks92m",
  "type": "job.succeeded",
  "created_at": "2026-07-08T14:00:07Z",
  "data": {
    "object": {
      "object": "job",
      "id": "job_7d2mfa",
      "status": "succeeded",
      "created_at": "2026-07-08T14:00:00Z",
      "input": {"url": "https://example.com/invoice/123"},
      "file": {
        "id": "file_8snm2k",
        "url": "https://files.transformy.io/8snm2k/invoice.pdf?sig=…",
        "size_bytes": 182034,
        "pages": 4,
        "expires_at": "2026-07-09T14:00:00Z"
      },
      "error": null
    }
  }
}

Event types: job.succeeded and job.failed.

Verifying signatures

Every delivery is signed following the Standard Webhooks spec, so you can prove it came from transformy and wasn't tampered with. Three headers accompany each request:

webhook-id: msg_2KWPBgLlAfxdpx2AI54pPJ85f4W      # dedupe key, stable across retries
webhook-timestamp: 1751980807                     # reject if skew > 5 min
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

The signature is an HMAC-SHA256 over the string {id}.{timestamp}.{raw_body} — the webhook-id header, the webhook-timestamp header, and the raw request body, joined with periods. The HMAC key is your endpoint's whsec_… signing secret (specifically, the base64-decoded portion after the whsec_ prefix). The secret is shown once at creation and can be rotated with overlapping validity, so rotation never drops deliveries.

To verify correctly:

  • Compute the expected signature over the raw request body, before any JSON parsing or re-serialization — a single re-encoded byte breaks the HMAC.
  • Reject deliveries whose webhook-timestamp is more than 5 minutes from your clock (replay protection).
  • Compare signatures with a constant-time comparison, never ==.
  • webhook-signature may contain several space-separated v1,… values during secret rotation — the delivery is valid if any of them matches.

Official standard-webhooks verification libraries exist for every language below and are the easy path. The panels show the manual version, so you can see exactly what they do:

# Verification happens on your server; with a captured delivery saved to
# payload.json you can recompute the expected signature with openssl.
# signed content = "{webhook-id}.{webhook-timestamp}.{raw body}"
SECRET="whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"

printf '%s.%s.%s' \
  "msg_2KWPBgLlAfxdpx2AI54pPJ85f4W" \
  "1751980807" \
  "$(cat payload.json)" \
  | openssl dgst -sha256 -mac HMAC \
      -macopt hexkey:"$(printf '%s' "${SECRET#whsec_}" | base64 -d | xxd -p -c 256)" \
      -binary \
  | base64
# compare with the value after "v1," in the webhook-signature header
import crypto from "node:crypto";

function verifyWebhook(secret, headers, rawBody) {
  const signed = `${headers["webhook-id"]}.${headers["webhook-timestamp"]}.${rawBody}`;
  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = crypto.createHmac("sha256", key).update(signed).digest();
  return headers["webhook-signature"].split(" ").some((part) => {
    const sig = Buffer.from(part.split(",")[1] ?? "", "base64");
    return sig.length === expected.length && crypto.timingSafeEqual(sig, expected);
  });
}
import base64, hashlib, hmac

def verify_webhook(secret, headers, raw_body):
    signed = f"{headers['webhook-id']}.{headers['webhook-timestamp']}.{raw_body}"
    key = base64.b64decode(secret.removeprefix("whsec_"))
    digest = hmac.new(key, signed.encode(), hashlib.sha256).digest()
    expected = base64.b64encode(digest).decode()
    return any(
        hmac.compare_digest(expected, part.split(",", 1)[1])
        for part in headers["webhook-signature"].split()
    )
<?php
function verifyWebhook(string $secret, array $headers, string $rawBody): bool {
    $signed = $headers["webhook-id"] . "." . $headers["webhook-timestamp"] . "." . $rawBody;
    $key = base64_decode(substr($secret, strlen("whsec_")));
    $expected = base64_encode(hash_hmac("sha256", $signed, $key, true));
    foreach (explode(" ", $headers["webhook-signature"]) as $part) {
        [, $sig] = explode(",", $part, 2);
        if (hash_equals($expected, $sig)) {
            return true;
        }
    }
    return false;
}
func verifyWebhook(secret string, headers map[string]string, rawBody []byte) bool {
	signed := headers["webhook-id"] + "." + headers["webhook-timestamp"] + "." + string(rawBody)
	key, _ := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(signed))
	expected := base64.StdEncoding.EncodeToString(mac.Sum(nil))
	for _, part := range strings.Split(headers["webhook-signature"], " ") {
		if _, sig, ok := strings.Cut(part, ","); ok && hmac.Equal([]byte(sig), []byte(expected)) {
			return true
		}
	}
	return false
}
require "base64"
require "openssl"

def verify_webhook(secret, headers, raw_body)
  signed = "#{headers['webhook-id']}.#{headers['webhook-timestamp']}.#{raw_body}"
  key = Base64.decode64(secret.delete_prefix("whsec_"))
  expected = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", key, signed))
  headers["webhook-signature"].split(" ").any? do |part|
    OpenSSL.secure_compare(expected, part.split(",", 2)[1].to_s)
  end
end
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.security.MessageDigest;
import java.util.Base64;
import java.util.Map;

static boolean verifyWebhook(String secret, Map<String, String> headers, String rawBody)
        throws Exception {
    var signed = headers.get("webhook-id") + "." + headers.get("webhook-timestamp") + "." + rawBody;
    var key = Base64.getDecoder().decode(secret.substring("whsec_".length()));
    var mac = Mac.getInstance("HmacSHA256");
    mac.init(new SecretKeySpec(key, "HmacSHA256"));
    var expected = Base64.getEncoder().encodeToString(mac.doFinal(signed.getBytes("UTF-8")));
    for (var part : headers.get("webhook-signature").split(" ")) {
        var sig = part.substring(part.indexOf(',') + 1);
        if (MessageDigest.isEqual(expected.getBytes(), sig.getBytes())) {
            return true;
        }
    }
    return false;
}

Delivery semantics

  • At-least-once. The same event can be delivered more than once — deduplicate by webhook-id, which is stable across retries.
  • Retries with exponential backoff over roughly 24 hours after a failed delivery.
  • Any 2xx response within 15 seconds counts as delivered; anything else (including a timeout) schedules a retry.
  • Auto-disable on sustained failure. An endpoint that keeps failing is disabled and you're notified by email.
  • Delivery log and manual redelivery are available in the dashboard.
  • HTTPS only — plain-HTTP webhook URLs are rejected.

Respond first, process later

Return a 2xx immediately and hand the event to a queue or background worker. If you download the PDF, write to your database, or send email inline, you'll blow through the 15-second window and trigger needless retries.

Files

When a conversion stores its PDF — output: "json" (see Output modes) or any async job — the response contains a file object. Two endpoints let you work with it afterwards:

GET/v1/files/{file_id}

Returns the file's metadata — size, page count, expiry, and the download URL:

Response · 200
{
  "id": "file_8snm2k",
  "url": "https://files.transformy.io/8snm2k/invoice.pdf?sig=…",
  "size_bytes": 182034,
  "pages": 4,
  "expires_at": "2026-07-09T14:00:00Z"
}
DELETE/v1/files/{file_id}

Deletes the stored PDF before its TTL runs out — the download URL stops working immediately. Use it when the document is sensitive and your side has finished downloading it.

Download URLs and expiry

Download URLs are signed, expiring CDN URLs on files.transformy.io — they are not API-authenticated, so you can hand them straight to browsers, emails, or end users without exposing your API key.

How long a file lives is set per request with expires_in (seconds): the default is 24 hours, the maximum is 30 days. In test mode, files always expire after 24 hours regardless of expires_in. After expiry (or a DELETE), the file id returns 404 — see Errors.

Account

GET/v1/account

Returns your plan and credit balance — useful for pre-flight checks before a large batch, or for wiring low-credit alerts into your own monitoring:

Response · 200
{
  "object": "account",
  "plan": "starter",
  "credits": {
    "included": 5000,
    "used": 688,
    "remaining": 4312,
    "resets_at": "2026-08-01T00:00:00Z"
  },
  "concurrency_limit": 5
}
GET/v1/usage

Returns your usage over time — conversion counts and credits consumed per day — for dashboards and alerting.

You rarely need to poll /v1/account: every conversion response already carries your credit state in the X-Credits-Used, X-Credits-Remaining, and X-Credits-Reset headers (see Rate limits & credits). Reach for the endpoint only when you need the balance outside a conversion. The dashboard also emails you automatically at 80% credit usage.

Rate limits and credits

Every response — success or error — tells you exactly where you stand. Read these headers instead of counting requests yourself:

Response headers
X-RateLimit-Limit: 120                # requests/min for this key
X-RateLimit-Remaining: 118
X-RateLimit-Reset: 1751980860         # epoch seconds
X-Credits-Used: 1                     # cost of THIS request
X-Credits-Remaining: 4312
X-Credits-Reset: 2026-08-01T00:00:00Z

Three independent limits apply to every key:

LimitWhen exceededError code
Requests per minute429, with a Retry-After headerrate_limit_exceeded
Concurrent renders429concurrency_exceeded
Monthly credits402 — the body includes your reset date and an upgrade URLcredits_exhausted

Both 429 and 402 responses use the standard error envelope.

The credit model is deliberately simple: one conversion costs 1 credit, up to 5 MB of output or 50 pages. Documents beyond that cost multiple credits, and the X-Credits-Used header always tells you what a given request actually cost. Test keys never consume credits — they run real conversions with watermarked output, capped at 2 concurrent renders.

Check your balance programmatically with GET /v1/account, or just watch X-Credits-Remaining on responses you're already making. The dashboard emails you when you've used 80% of your monthly credits.

Retrying after a 429

Honor the Retry-After header when present, and back off exponentially with jitter between attempts. A tight retry loop against a rate limit only keeps you rate-limited longer.

Idempotency

Network calls fail in ambiguous ways. If a conversion POST times out on your side and you retry it, the first request may in fact have completed — and now you've paid for two renders and generated two files. Idempotency keys make retries safe.

Send an Idempotency-Key: <any unique string, 16–255 chars> header (a UUID v4 is ideal) with any conversion POST. The first request executes normally and its full response — status, headers, and body or file reference — is cached against the combination of your API key and the idempotency key for 24 hours. Any retry with the same key returns the stored response, byte for byte, with an Idempotent-Replayed: true header so you can tell a replay from a fresh render. No second render, no second credit.

Two conflict cases return a 409 with the standard error envelope:

SituationError code
Same key, different request payloadidempotency_payload_mismatch
Same key while the original request is still runningidempotency_key_in_use

A conversion request with an idempotency key looks like this — each language generates a UUID with its standard library:

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8e03978e-40d5-43e8-bc93-6894a57f9324" \
  -d '{ "url": "https://example.com/invoice/123" }' \
  --fail -o invoice.pdf
import fs from "node:fs";
import { randomUUID } from "node:crypto";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": randomUUID(),
  },
  body: JSON.stringify({ url: "https://example.com/invoice/123" }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("invoice.pdf", Buffer.from(await res.arrayBuffer()));
import os, uuid, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={
        "Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}",
        "Idempotency-Key": str(uuid.uuid4()),
    },
    json={"url": "https://example.com/invoice/123"},
)
res.raise_for_status()
with open("invoice.pdf", "wb") as f:
    f.write(res.content)
<?php
$idempotencyKey = bin2hex(random_bytes(16));

$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
        "Idempotency-Key: " . $idempotencyKey,
    ],
    CURLOPT_POSTFIELDS => json_encode(["url" => "https://example.com/invoice/123"]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("invoice.pdf", $body);
}
key := make([]byte, 16)
rand.Read(key) // crypto/rand

payload := []byte(`{"url": "https://example.com/invoice/123"}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", hex.EncodeToString(key))

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("invoice.pdf", pdf, 0o644)
}
require "net/http"
require "json"
require "securerandom"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
  "Idempotency-Key" => SecureRandom.uuid,
})
req.body = { url: "https://example.com/invoice/123" }.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("invoice.pdf", res.body) if res.code == "200"
var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", UUID.randomUUID().toString())
    .POST(HttpRequest.BodyPublishers.ofString("{\"url\": \"https://example.com/invoice/123\"}"))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("invoice.pdf"), response.body());
}

Random UUIDs make a single retry loop safe, but a deterministic key derived from the business object — invoice-2026-0142-v1, say — goes further: any part of your application that tries to render that invoice within 24 hours gets the same PDF for the same credit, no coordination required. Bump the suffix when the underlying document actually changes.

Errors

Errors are always JSON with a proper HTTP status code, and the envelope is identical across every endpoint — including the binary conversion endpoint. You will never get a 200 with an error body, and never an error body that isn't JSON. Check the status code before writing response bytes to disk.

Response · 422 Unprocessable Entity
{
  "error": {
    "type": "invalid_request_error",
    "code": "invalid_parameter",
    "message": "page_width and page_height must be provided together.",
    "param": "page_height",
    "doc_url": "https://transformy.io/docs/#error-invalid_parameter",
    "request_id": "req_9f3k2j"
  }
}

type is the broad category — switch on it for coarse handling, and on code for specifics:

TypeMeaning
invalid_request_errorThe request itself is malformed or a parameter is invalid.
authentication_errorThe API key is missing, invalid, or revoked.
permission_errorThe key is valid but not allowed to do this — a plan-gated option, or a test key on a live-only operation.
insufficient_creditsYour monthly credits are exhausted or the subscription has lapsed.
rate_limit_errorYou hit the request-rate or concurrency limit — see Rate limits.
conversion_errorThe render itself failed — see Conversion errors below.
api_errorOur bug, not yours. Safe to retry; tell us the request_id if it persists.

When several parameters fail validation at once, the envelope additionally carries an errors array of {param, code, message} objects so you can surface every problem in one round trip.

StatusWhenExample codes
400Malformed JSON, unknown parameterinvalid_json, unknown_parameter
401Missing/invalid/revoked keyinvalid_api_key
402Credits exhausted / subscription lapsed — body includes your reset date and an upgrade URLcredits_exhausted
403Plan-gated feature; test key on a live-only operationfeature_not_in_plan
404Unknown job or file idnot_found
409Idempotency conflicts; canceling a job that already finishedidempotency_key_in_use, idempotency_payload_mismatch
413HTML payload too largepayload_too_large
422Semantically invalid — a bad option combination, both url and html supplied, a blocked URLinvalid_parameter, url_blocked, mutually_exclusive
429Rate or concurrency limited — with Retry-Afterrate_limit_exceeded, concurrency_exceeded
500Our buginternal_error
503Overloaded or maintenance — with Retry-Afterservice_unavailable

Conversion errors

Render failures are domain errors, never a confusing gateway 5xx from us. A synchronous request that can't be rendered fails with a 422 and a conversion_error envelope; an asynchronous job moves to status: "failed" with the same error object attached. Either way, the code tells you what happened:

CodeWhat happened
source_unreachableWe couldn't reach the target URL at all — DNS failure, connection refused, or TLS handshake failure.
source_timeoutThe render exceeded its timeout budget. For long-running pages, raise the timeout or switch to an async request.
source_http_errorThe target URL returned a non-2xx response; the message includes the upstream status code. Set fail_on_status: false if you'd rather render the error page.
selector_not_foundYour wait_for_selector never matched within its timeout — see Waiting.
render_crashedThe page crashed the renderer. Retrying usually works; if it recurs, send us the request_id.
storage_upload_failedThe render succeeded but the upload to your storage destination failed; the message includes the destination's HTTP status and a response excerpt. No credits are charged.
url_blockedThe URL (or one of its subresources or redirects) is on the security blocklist — private networks, cloud metadata, non-HTTP(S) schemes.

Every error carries a request_id (also present on every response as the X-Request-Id header). Echo it when you contact support and we can pull up the exact request, its parameters, and its timing.

Security

A service that fetches URLs on your behalf is the classic SSRF surface: the textbook exploit is asking it to render http://169.254.169.254/… and reading cloud credentials out of the PDF. The API is designed so you don't have to think about this — even when the URLs come from your own users — and this page spells out exactly what we block.

Blocked at all times, for the main URL and every subresource and redirect hop:

  • Private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8.
  • Link-local, including cloud metadata: 169.254.0.0/16 and metadata.google.internal.
  • IPv6 equivalents: ::1, fd00::/8, fe80::/10.
  • Non-HTTP(S) schemes: file:, gopher:, dict:, and data: as the document itself.

The checks are DNS-rebinding safe: we resolve each hostname once, pin the resolved address for the fetch, and re-check on every redirect — a DNS record that flips to an internal address mid-render buys an attacker nothing. A blocked fetch fails the conversion with the url_blocked error code. The same restriction applies to storage upload destinations: a presigned URL pointing at a private host is rejected.

Renderers run sandboxed with no network route to our internal infrastructure, file downloads are disabled, and every render is subject to resource limits: a maximum page weight, a maximum subresource count, and a hard render timeout.

Rendering internal pages

Sometimes the page you want to render should be private — an internal dashboard, an admin report. We publish static egress IP addresses per region: allowlist them in your firewall and our renderers can reach your internal pages without you exposing them to the public internet. Combined with the target-URL authentication parameters, this covers most "render something behind our VPN" setups.

Credential handling

The credentials you pass for a render — cookies, http_headers, http_auth, and presigned storage URLs — are used transiently for that render and never persisted. They are redacted from job objects, from our logs, and from the dashboard's request log. Even so, treat them like the secrets they are: prefer short-lived, least-privilege tokens scoped to exactly the page being rendered over long-lived session cookies or admin credentials.

Cookbook

Complete, runnable recipes for the most common jobs. Each one is a full request you can copy, run, and adapt.

Invoice with page numbers

Render raw HTML with a centered Page X of Y footer. Two details matter: the footer needs an explicit inline font-size, and the bottom margin must leave room for it — see Headers & footers.

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "html": "<h1>Invoice #1042</h1><table><tr><td>Consulting</td><td>$1,200.00</td></tr></table>",
    "footer": {"html": "<div style=\"font-size:9px; width:100%; text-align:center; color:#666;\">Page {{page}} of {{pages}}</div>"},
    "margin": {"top": "1cm", "right": "1cm", "bottom": "2cm", "left": "1cm"},
    "filename": "invoice-1042.pdf",
    "page_size": "a4"
  }' \
  --fail -o invoice-1042.pdf
import fs from "node:fs";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    html: "<h1>Invoice #1042</h1><table><tr><td>Consulting</td><td>$1,200.00</td></tr></table>",
    footer: {
      html: "<div style='font-size:9px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>",
    },
    margin: { top: "1cm", right: "1cm", bottom: "2cm", left: "1cm" },
    filename: "invoice-1042.pdf",
    page_size: "a4",
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("invoice-1042.pdf", Buffer.from(await res.arrayBuffer()));
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={
        "html": "<h1>Invoice #1042</h1><table><tr><td>Consulting</td><td>$1,200.00</td></tr></table>",
        "footer": {
            "html": "<div style='font-size:9px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>"
        },
        "margin": {"top": "1cm", "right": "1cm", "bottom": "2cm", "left": "1cm"},
        "filename": "invoice-1042.pdf",
        "page_size": "a4",
    },
)
res.raise_for_status()
with open("invoice-1042.pdf", "wb") as f:
    f.write(res.content)
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "html" => "<h1>Invoice #1042</h1><table><tr><td>Consulting</td><td>\$1,200.00</td></tr></table>",
        "footer" => ["html" => "<div style='font-size:9px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>"],
        "margin" => ["top" => "1cm", "right" => "1cm", "bottom" => "2cm", "left" => "1cm"],
        "filename" => "invoice-1042.pdf",
        "page_size" => "a4",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("invoice-1042.pdf", $body);
}
payload := []byte(`{
  "html": "<h1>Invoice #1042</h1><table><tr><td>Consulting</td><td>$1,200.00</td></tr></table>",
  "footer": {"html": "<div style='font-size:9px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>"},
  "margin": {"top": "1cm", "right": "1cm", "bottom": "2cm", "left": "1cm"},
  "filename": "invoice-1042.pdf",
  "page_size": "a4"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("invoice-1042.pdf", pdf, 0o644)
}
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = {
  html: "<h1>Invoice #1042</h1><table><tr><td>Consulting</td><td>$1,200.00</td></tr></table>",
  footer: { html: "<div style='font-size:9px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>" },
  margin: { top: "1cm", right: "1cm", bottom: "2cm", left: "1cm" },
  filename: "invoice-1042.pdf",
  page_size: "a4",
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("invoice-1042.pdf", res.body) if res.code == "200"
var payload = "{"
    + "\"html\": \"<h1>Invoice #1042</h1><table><tr><td>Consulting</td><td>$1,200.00</td></tr></table>\","
    + "\"footer\": {\"html\": \"<div style='font-size:9px; width:100%; text-align:center; color:#666;'>Page {{page}} of {{pages}}</div>\"},"
    + "\"margin\": {\"top\": \"1cm\", \"right\": \"1cm\", \"bottom\": \"2cm\", \"left\": \"1cm\"},"
    + "\"filename\": \"invoice-1042.pdf\","
    + "\"page_size\": \"a4\""
    + "}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("invoice-1042.pdf"), response.body());
}

A page behind a login

Inject a session cookie, then wait for an element that only renders once the page's data has loaded. Because fail_on_status defaults to true, an expired session that redirects to a login page or returns a 403 fails the conversion with the upstream status instead of silently rendering the wrong page.

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://app.example.com/reports/42",
    "cookies": [{"name": "sessionid", "value": "abc123", "domain": "app.example.com"}],
    "wait_for_selector": "#report-loaded"
  }' \
  --fail -o report-42.pdf
import fs from "node:fs";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://app.example.com/reports/42",
    cookies: [{ name: "sessionid", value: "abc123", domain: "app.example.com" }],
    wait_for_selector: "#report-loaded",
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("report-42.pdf", Buffer.from(await res.arrayBuffer()));
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={
        "url": "https://app.example.com/reports/42",
        "cookies": [{"name": "sessionid", "value": "abc123", "domain": "app.example.com"}],
        "wait_for_selector": "#report-loaded",
    },
)
res.raise_for_status()
with open("report-42.pdf", "wb") as f:
    f.write(res.content)
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://app.example.com/reports/42",
        "cookies" => [["name" => "sessionid", "value" => "abc123", "domain" => "app.example.com"]],
        "wait_for_selector" => "#report-loaded",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("report-42.pdf", $body);
}
payload := []byte(`{
  "url": "https://app.example.com/reports/42",
  "cookies": [{"name": "sessionid", "value": "abc123", "domain": "app.example.com"}],
  "wait_for_selector": "#report-loaded"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("report-42.pdf", pdf, 0o644)
}
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = {
  url: "https://app.example.com/reports/42",
  cookies: [{ name: "sessionid", value: "abc123", domain: "app.example.com" }],
  wait_for_selector: "#report-loaded",
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("report-42.pdf", res.body) if res.code == "200"
var payload = "{"
    + "\"url\": \"https://app.example.com/reports/42\","
    + "\"cookies\": [{\"name\": \"sessionid\", \"value\": \"abc123\", \"domain\": \"app.example.com\"}],"
    + "\"wait_for_selector\": \"#report-loaded\""
    + "}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("report-42.pdf"), response.body());
}

Cookie values and other target-URL credentials are used transiently for the render and redacted from job objects, logs, and the dashboard — see credential handling.

JavaScript-heavy pages and charts

Single-page apps and charting libraries finish painting well after load fires. Wait for the network to go quiet, then poll a JavaScript expression your page sets when its charts are done:

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://dashboards.example.com/q2-report",
    "javascript": true,
    "wait_until": "networkidle",
    "wait_for_expression": "window.chartsReady === true"
  }' \
  --fail -o q2-report.pdf
import fs from "node:fs";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://dashboards.example.com/q2-report",
    javascript: true,
    wait_until: "networkidle",
    wait_for_expression: "window.chartsReady === true",
  }),
});
if (!res.ok) throw new Error(JSON.stringify(await res.json()));
fs.writeFileSync("q2-report.pdf", Buffer.from(await res.arrayBuffer()));
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={
        "url": "https://dashboards.example.com/q2-report",
        "javascript": True,
        "wait_until": "networkidle",
        "wait_for_expression": "window.chartsReady === true",
    },
)
res.raise_for_status()
with open("q2-report.pdf", "wb") as f:
    f.write(res.content)
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://dashboards.example.com/q2-report",
        "javascript" => true,
        "wait_until" => "networkidle",
        "wait_for_expression" => "window.chartsReady === true",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    file_put_contents("q2-report.pdf", $body);
}
payload := []byte(`{
  "url": "https://dashboards.example.com/q2-report",
  "javascript": true,
  "wait_until": "networkidle",
  "wait_for_expression": "window.chartsReady === true"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode == 200 {
    pdf, _ := io.ReadAll(res.Body)
    os.WriteFile("q2-report.pdf", pdf, 0o644)
}
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = {
  url: "https://dashboards.example.com/q2-report",
  javascript: true,
  wait_until: "networkidle",
  wait_for_expression: "window.chartsReady === true",
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
File.binwrite("q2-report.pdf", res.body) if res.code == "200"
var payload = "{"
    + "\"url\": \"https://dashboards.example.com/q2-report\","
    + "\"javascript\": true,"
    + "\"wait_until\": \"networkidle\","
    + "\"wait_for_expression\": \"window.chartsReady === true\""
    + "}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofByteArray());
if (response.statusCode() == 200) {
    Files.write(Path.of("q2-report.pdf"), response.body());
}

If your app can flip a DOM state instead — say, render a #charts-done element when drawing finishes — wait_for_selector works just as well. All the wait strategies are compared in Waiting.

Controlling page breaks

Page breaks are controlled by your CSS, not by request parameters. Ship print rules with your document:

@media print {
  .chapter { break-before: page; }        /* start each chapter on a new page */
  table, figure { break-inside: avoid; }  /* never split these across pages */
  p { orphans: 3; widows: 3; }            /* no lonely lines at page edges */
}

@page {
  size: A4;
  margin: 2cm;
}
  • Set prefer_css_page_size: true to have your @page size win over the request's page_size.
  • media defaults to "print", so your @media print rules already apply — no parameter needed.
  • orphans and widows stop single lines from being stranded at the top or bottom of a page.
curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/report",
    "prefer_css_page_size": true
  }' \
  --fail -o report.pdf

Upload straight to your bucket

With output: "storage" the PDF never passes through your server: presign an upload URL with your cloud SDK, hand it to the API, and the file lands in your bucket. The response tells you the upload succeeded via storage.delivered.

# presign an upload URL with your cloud SDK first
curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://example.com/invoice/123",
    "output": "storage",
    "storage": {
      "url": "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=...",
      "method": "PUT"
    }
  }'
# → {"object": "conversion", "storage": {"delivered": true, "status": 200, ...}, ...}
// presign an upload URL with your cloud SDK
const uploadUrl = "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=...";

const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://example.com/invoice/123",
    output: "storage",
    storage: { url: uploadUrl, method: "PUT" },
  }),
});
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
console.log(data.storage.delivered); // true
import os, requests

# presign an upload URL with your cloud SDK
upload_url = "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=..."

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={"Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}"},
    json={
        "url": "https://example.com/invoice/123",
        "output": "storage",
        "storage": {"url": upload_url, "method": "PUT"},
    },
)
res.raise_for_status()
data = res.json()
print(data["storage"]["delivered"])  # True
<?php
// presign an upload URL with your cloud SDK
$uploadUrl = "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=...";

$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "url" => "https://example.com/invoice/123",
        "output" => "storage",
        "storage" => ["url" => $uploadUrl, "method" => "PUT"],
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 200) {
    $data = json_decode($body, true);
    var_dump($data["storage"]["delivered"]); // bool(true)
}
// presign an upload URL with your cloud SDK
payload := []byte(`{
  "url": "https://example.com/invoice/123",
  "output": "storage",
  "storage": {
    "url": "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=...",
    "method": "PUT"
  }
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()

var result struct {
    Storage struct {
        Delivered bool `json:"delivered"`
    } `json:"storage"`
}
json.NewDecoder(res.Body).Decode(&result)
fmt.Println(result.Storage.Delivered) // true
require "net/http"
require "json"

# presign an upload URL with your cloud SDK
upload_url = "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=..."

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
})
req.body = {
  url: "https://example.com/invoice/123",
  output: "storage",
  storage: { url: upload_url, method: "PUT" },
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
data = JSON.parse(res.body)
puts data["storage"]["delivered"] # true
// presign an upload URL with your cloud SDK
var uploadUrl = "https://my-bucket.s3.eu-west-1.amazonaws.com/invoices/123.pdf?X-Amz-Signature=...";
var payload = "{"
    + "\"url\": \"https://example.com/invoice/123\","
    + "\"output\": \"storage\","
    + "\"storage\": {\"url\": \"" + uploadUrl + "\", \"method\": \"PUT\"}"
    + "}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 200) {
    System.out.println(response.body()); // "storage": {"delivered": true, ...}
}

Presign generously

Sign the upload URL for at least 15 minutes of validity. A presigned URL that expires before a slow render finishes fails the whole conversion with storage_upload_failed. See storage output.

Async at scale with webhooks

Generating thousands of documents? Submit each one with async: true, a webhook_url, and a per-document Idempotency-Key — then forget about it until the webhook arrives. The key makes retries safe: resubmitting the same document can never render or charge twice. Here's one document from the batch:

curl -X POST https://api.transformy.io/v1/pdf/chrome \
  -H "Authorization: Bearer tfy_live_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: statement-2026-06-customer-841" \
  -d '{
    "html": "<h1>Statement 2026-06 for customer 841</h1>",
    "async": true,
    "webhook_url": "https://yourapp.com/hooks/transformy",
    "output": "json"
  }'
# → 202 {"object": "job", "id": "job_7d2mfa", "status": "queued", ...}
const res = await fetch("https://api.transformy.io/v1/pdf/chrome", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSFORMY_API_KEY}`,
    "Content-Type": "application/json",
    "Idempotency-Key": "statement-2026-06-customer-841",
  },
  body: JSON.stringify({
    html: "<h1>Statement 2026-06 for customer 841</h1>",
    async: true,
    webhook_url: "https://yourapp.com/hooks/transformy",
    output: "json",
  }),
});
if (res.status !== 202) throw new Error(await res.text());
const job = await res.json();
console.log(job.id); // job_7d2mfa — the webhook fires when it finishes
import os, requests

res = requests.post(
    "https://api.transformy.io/v1/pdf/chrome",
    headers={
        "Authorization": f"Bearer {os.environ['TRANSFORMY_API_KEY']}",
        "Idempotency-Key": "statement-2026-06-customer-841",
    },
    json={
        "html": "<h1>Statement 2026-06 for customer 841</h1>",
        "async": True,
        "webhook_url": "https://yourapp.com/hooks/transformy",
        "output": "json",
    },
)
res.raise_for_status()
job = res.json()
print(job["id"])  # job_7d2mfa — the webhook fires when it finishes
<?php
$ch = curl_init("https://api.transformy.io/v1/pdf/chrome");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("TRANSFORMY_API_KEY"),
        "Content-Type: application/json",
        "Idempotency-Key: statement-2026-06-customer-841",
    ],
    CURLOPT_POSTFIELDS => json_encode([
        "html" => "<h1>Statement 2026-06 for customer 841</h1>",
        "async" => true,
        "webhook_url" => "https://yourapp.com/hooks/transformy",
        "output" => "json",
    ]),
]);
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
if ($status === 202) {
    $job = json_decode($body, true);
    echo $job["id"]; // job_7d2mfa — the webhook fires when it finishes
}
payload := []byte(`{
  "html": "<h1>Statement 2026-06 for customer 841</h1>",
  "async": true,
  "webhook_url": "https://yourapp.com/hooks/transformy",
  "output": "json"
}`)
req, _ := http.NewRequest("POST", "https://api.transformy.io/v1/pdf/chrome", bytes.NewBuffer(payload))
req.Header.Set("Authorization", "Bearer "+os.Getenv("TRANSFORMY_API_KEY"))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "statement-2026-06-customer-841")

res, err := http.DefaultClient.Do(req)
if err != nil {
    log.Fatal(err)
}
defer res.Body.Close()

var job struct {
    ID string `json:"id"`
}
json.NewDecoder(res.Body).Decode(&job)
fmt.Println(job.ID) // job_7d2mfa — the webhook fires when it finishes
require "net/http"
require "json"

uri = URI("https://api.transformy.io/v1/pdf/chrome")
req = Net::HTTP::Post.new(uri, {
  "Authorization" => "Bearer #{ENV['TRANSFORMY_API_KEY']}",
  "Content-Type" => "application/json",
  "Idempotency-Key" => "statement-2026-06-customer-841",
})
req.body = {
  html: "<h1>Statement 2026-06 for customer 841</h1>",
  async: true,
  webhook_url: "https://yourapp.com/hooks/transformy",
  output: "json",
}.to_json

res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
job = JSON.parse(res.body)
puts job["id"] # job_7d2mfa — the webhook fires when it finishes
var payload = "{"
    + "\"html\": \"<h1>Statement 2026-06 for customer 841</h1>\","
    + "\"async\": true,"
    + "\"webhook_url\": \"https://yourapp.com/hooks/transformy\","
    + "\"output\": \"json\""
    + "}";

var client = HttpClient.newHttpClient();
var request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.transformy.io/v1/pdf/chrome"))
    .header("Authorization", "Bearer " + System.getenv("TRANSFORMY_API_KEY"))
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "statement-2026-06-customer-841")
    .POST(HttpRequest.BodyPublishers.ofString(payload))
    .build();

var response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() == 202) {
    System.out.println(response.body()); // {"object": "job", "id": "job_7d2mfa", ...}
}

On the receiving end, your webhook handler verifies the signature, dedupes on the webhook-id header (delivery is at-least-once, so the same event can arrive more than once), and downloads file.url before expires_at. Polling GET /v1/jobs/{job_id} works too — see Async.

Troubleshooting

The most common problems and their fixes.

My PDF has no background colors or images

print_background is on by default, so if backgrounds are missing you've probably set it to false — remove the override. The other usual suspect is your own CSS: rules under @media print often hide or strip backgrounds, and print styles apply because media defaults to "print". Set "media": "screen" to render the page as a browser tab shows it — see Parameters.

The page renders before my data loads

Don't reach for delay — a fixed sleep is either too short (broken PDF) or too long (wasted seconds on every render). Wait for a real signal instead: wait_for_selector for an element that appears when data is in, wait_for_expression for a flag your page sets, or wait_until: "networkidle" for fetch-heavy pages. See Waiting.

Headers and footers render inside the page margins, so the margin must leave room — a footer with a 0 bottom margin has nowhere to draw. They also need an explicit inline font-size (the default is effectively invisible), and your page's CSS doesn't cascade into them: every style must be inline in the header/footer HTML itself. See Headers & footers.

Fonts look wrong

wait_for_fonts is on by default, so timing is rarely the cause. More likely the font never loaded: self-host your fonts or make sure the font files are reachable from the rendered page — subresources must be publicly fetchable, and requests to private or internal hosts are blocked. See Security.

I saved the response to disk but the file is corrupt

You almost certainly wrote a JSON error body into a .pdf file. Errors are always JSON with a proper HTTP status, even on the binary endpoint — so check the status is 200 before writing bytes to disk (with curl, use --fail). See Output modes and Errors.

My conversion times out

Synchronous requests are capped at a timeout of 120000 ms; a render that exceeds it fails with source_timeout. For slow pages or bulk work, submit with async: true — async jobs get a higher budget, and a webhook tells you when the PDF is ready. See Async.

My target URL works in my browser but fails here

Your browser is inside your network; our fetcher is not — and it deliberately blocks private and internal IP ranges, so a URL that resolves to 10.x, 192.168.x, or localhost is rejected with url_blocked. To render internal dashboards, allowlist our published static egress IPs in your firewall instead of exposing the page publicly. See static egress IPs.

I got charged for a retry

The original request finished (and was billed) even though your client gave up waiting, so the retry was a second, separate conversion. Send an Idempotency-Key header on every conversion request and retries return the stored response instead of rendering — and charging — again. See Idempotency.

Still stuck? Email support with the X-Request-Id of the failing request — every response carries one, and it lets us pull up the exact request, its parameters, and timing.

transformy
© 2014-2026 transformy.io — made in 🇧🇪 with ❤️ and 🍟