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.
https://api.transformy.io/v1/
- JSON everywhere. Requests and responses use JSON with
snake_casekeys. 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 withDeprecationandSunsetheaders. - 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-Idheader. 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:
- 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 asTRANSFORMY_API_KEYin your shell. - 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-Keyheader 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
/v1/pdf/chromeOne 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:
{ "url": "https://example.com/invoice/123" }
{ "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
| Parameter | Type | Default | Description |
|---|---|---|---|
url | string | — | Target URL to render (http/https only). Mutually exclusive with html. |
html | string | — | Raw 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
| Parameter | Type | Default | Description |
|---|---|---|---|
page_size | string | "a4" | Named paper size: a0–a6, letter, legal, tabloid, ledger. |
page_width / page_height | unit string | — | Custom page size ("210mm", "8.5in"). Both must be provided together; overrides page_size. |
orientation | string | "portrait" | portrait or landscape. |
margin | object | string | — | Either 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_ranges | string | all pages | Pages to include, e.g. "1-5, 8, 11-13". |
zoom | number | 1.0 | Render scale, 0.1–2.0. |
print_background | boolean | true | Render 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_size | boolean | false | Honor a size declared in the document's @page CSS rule over page_size. |
single_page | boolean | false | Produce one tall page sized to the content instead of paginating. |
omit_background | boolean | false | Transparent page background. |
tagged | boolean | false | Produce a tagged (accessible) PDF with document structure for screen readers. |
outline | boolean | false | Generate PDF bookmarks from the document's headings. |
Rendering environment
| Parameter | Type | Default | Description |
|---|---|---|---|
media | string | "print" | CSS media type to emulate: print or screen. Use screen when the page's print stylesheet is missing or unwanted. |
javascript | boolean | true | Execute the page's JavaScript. |
viewport | object | — | Layout viewport: {"width": 1280, "height": 1024, "device_scale_factor": 1}. |
timezone | string | — | IANA timezone id the page sees, e.g. "Europe/Brussels". |
locale | string | — | Locale tag, e.g. "fr-BE" — affects Intl formatting and navigator.language. |
inject_css | string | — | Extra CSS appended after the page loads. |
inject_js | string | — | JavaScript executed after load, before capture. |
block | object | — | Declarative blocking: {"ads": true, "cookie_banners": true, "resource_types": ["image", "font"], "url_patterns": ["*tracker*"]}. Any subset of keys. |
fail_on_console_errors | boolean | false | Fail 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.
| Parameter | Type | Default | Description |
|---|---|---|---|
http_headers | object | — | Extra headers sent with the main document request, e.g. {"Authorization": "Bearer eyJ…"}. Hop-by-hop headers and Host are stripped. |
cookies | array | — | Cookies injected before navigation — the standard way to render pages behind a session login. Object shape below. |
http_auth | object | — | {"username": "u", "password": "p"} — answers HTTP Basic/Digest challenges. |
user_agent | string | — | Custom User-Agent for all requests the page makes. |
fail_on_status | boolean | true | Fail 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
| Parameter | Type | Default | Description |
|---|---|---|---|
output | string | "file" | file (binary response), json (hosted URL), base64 (inline), or storage (upload to your bucket) — see Output modes. |
filename | string | "document.pdf" | Filename used in Content-Disposition and hosted URLs (non-ASCII handled via filename*). |
expires_in | integer (s) | 86400 | Hosted-file TTL for output: "json", max 30 days — see Output modes. |
storage | object | — | Presigned upload destination for output: "storage" — see Output modes. |
async | boolean | false | Return 202 and a job immediately; implied true when webhook_url is set — see Async. |
webhook_url | string | — | HTTPS URL notified when the job completes — see Webhooks. |
timeout | integer (ms) | 30000 | Overall render budget. Max 120000 for sync requests; higher caps apply to async. |
Document metadata & protection
| Parameter | Type | Default | Description |
|---|---|---|---|
title | string | — | PDF document title metadata. |
metadata | object | — | {"author": "…", "subject": "…", "keywords": "…", "creator": "…"}. |
protection | object | — | PDF encryption and permissions — see below. |
test | boolean | false | Force 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.
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:
| Parameter | Type | Default | Description |
|---|---|---|---|
wait_until | string | "load" | Page lifecycle event to wait for before the other conditions are evaluated. Values below. |
wait_for_selector | string | object | — | Wait until a CSS selector matches. Object form for finer control: {"selector": "#chart", "state": "visible", "timeout": 15000}. |
wait_for_expression | string | — | JavaScript expression polled in the page until it evaluates truthy, e.g. "window.renderComplete === true". |
delay | integer (ms) | 0 | Fixed wait before capture. Capped at 10000. |
wait_for_fonts | boolean | true | Wait for all web fonts to finish loading (document.fonts.ready) before capture. |
wait_until accepts:
load— the page'sloadevent 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:
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());
}
{
"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.
{
"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:
{
"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:
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: queued → processing → succeeded | failed | canceled.
queued/processing— in progress; keep polling (or wait for the webhook).succeeded— terminal.fileis populated with the same file object returned byoutput: "json"(see Output modes).failed— terminal.errorcarries 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
| Endpoint | Description |
|---|---|
GET /v1/jobs/{job_id} | Poll status and fetch result metadata. |
GET /v1/jobs | List 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 impliesasync: 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:
{
"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-timestampis more than 5 minutes from your clock (replay protection). - Compare signatures with a constant-time comparison, never
==. webhook-signaturemay contain several space-separatedv1,…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:
/v1/files/{file_id}Returns the file's metadata — size, page count, expiry, and the download URL:
{
"id": "file_8snm2k",
"url": "https://files.transformy.io/8snm2k/invoice.pdf?sig=…",
"size_bytes": 182034,
"pages": 4,
"expires_at": "2026-07-09T14:00:00Z"
}
/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
/v1/accountReturns your plan and credit balance — useful for pre-flight checks before a large batch, or for wiring low-credit alerts into your own monitoring:
{
"object": "account",
"plan": "starter",
"credits": {
"included": 5000,
"used": 688,
"remaining": 4312,
"resets_at": "2026-08-01T00:00:00Z"
},
"concurrency_limit": 5
}
/v1/usageReturns 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:
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:
| Limit | When exceeded | Error code |
|---|---|---|
| Requests per minute | 429, with a Retry-After header | rate_limit_exceeded |
| Concurrent renders | 429 | concurrency_exceeded |
| Monthly credits | 402 — the body includes your reset date and an upgrade URL | credits_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:
| Situation | Error code |
|---|---|
| Same key, different request payload | idempotency_payload_mismatch |
| Same key while the original request is still running | idempotency_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.
{
"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:
| Type | Meaning |
|---|---|
invalid_request_error | The request itself is malformed or a parameter is invalid. |
authentication_error | The API key is missing, invalid, or revoked. |
permission_error | The key is valid but not allowed to do this — a plan-gated option, or a test key on a live-only operation. |
insufficient_credits | Your monthly credits are exhausted or the subscription has lapsed. |
rate_limit_error | You hit the request-rate or concurrency limit — see Rate limits. |
conversion_error | The render itself failed — see Conversion errors below. |
api_error | Our 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.
| Status | When | Example codes |
|---|---|---|
400 | Malformed JSON, unknown parameter | invalid_json, unknown_parameter |
401 | Missing/invalid/revoked key | invalid_api_key |
402 | Credits exhausted / subscription lapsed — body includes your reset date and an upgrade URL | credits_exhausted |
403 | Plan-gated feature; test key on a live-only operation | feature_not_in_plan |
404 | Unknown job or file id | not_found |
409 | Idempotency conflicts; canceling a job that already finished | idempotency_key_in_use, idempotency_payload_mismatch |
413 | HTML payload too large | payload_too_large |
422 | Semantically invalid — a bad option combination, both url and html supplied, a blocked URL | invalid_parameter, url_blocked, mutually_exclusive |
429 | Rate or concurrency limited — with Retry-After | rate_limit_exceeded, concurrency_exceeded |
500 | Our bug | internal_error |
503 | Overloaded or maintenance — with Retry-After | service_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:
| Code | What happened |
|---|---|
source_unreachable | We couldn't reach the target URL at all — DNS failure, connection refused, or TLS handshake failure. |
source_timeout | The render exceeded its timeout budget. For long-running pages, raise the timeout or switch to an async request. |
source_http_error | The 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_found | Your wait_for_selector never matched within its timeout — see Waiting. |
render_crashed | The page crashed the renderer. Retrying usually works; if it recurs, send us the request_id. |
storage_upload_failed | The 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_blocked | The 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/16andmetadata.google.internal. - IPv6 equivalents:
::1,fd00::/8,fe80::/10. - Non-HTTP(S) schemes:
file:,gopher:,dict:, anddata: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: trueto have your@pagesize win over the request'spage_size. mediadefaults to"print", so your@media printrules already apply — no parameter needed.orphansandwidowsstop 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.
My header or footer doesn't show up
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.