You need a PDF of a page your app already renders: an invoice, a monthly report, a receipt. The usual path is bundling headless Chromium into your build image, which adds a few hundred megabytes of system libraries, a --no-sandbox flag someone will question in code review, and a Dockerfile that breaks the next time the base image moves. The older alternative, wkhtmltopdf, froze on an ancient WebKit, so anything using grid or modern flexbox comes out looking wrong.
SnapPDF is one GET request that returns the PDF bytes. No API key on the direct base URL, no job queue, no polling.
The request
curl -o page.pdf "https://snappdf.dedyn.io/v1/pdf?url=https://example.com"
If your target URL has its own query string, let curl encode it instead of hand-escaping ampersands:
curl -fsS -o report.pdf --get \
--data-urlencode "url=https://example.com/reports/q3?theme=print" \
--data-urlencode "format=Letter" \
--data-urlencode "landscape=true" \
--data-urlencode "wait_for_selector=#totals" \
https://snappdf.dedyn.io/v1/pdf
The optional params:
| Param | Values | Notes |
|---|---|---|
format |
A4 (default), A3, A5, Letter, Legal, Tabloid
|
Page size |
landscape |
true \ |
false |
background |
true \ |
false |
scale |
number, 0.1–2 | Chromium's own print scale range |
wait_for_selector |
CSS selector | Waits for that element before printing |
wait_for_selector is the one that saves you. The renderer waits for the network to go idle, but a client-rendered dashboard often settles its requests a beat before it paints the numbers. Point the selector at something that only exists after your data lands and you stop shipping PDFs of a loading spinner.
The response
The body is the raw PDF, served as Content-Type: application/pdf. It is not JSON, and there is no base64 wrapper or data key to unwrap. Read it as bytes and write it to disk.
Errors are the exception: a bad or unsupported request comes back as 400 and a failed render as 502, both JSON with error and detail keys. So branch on the status code before you touch the body.
import { writeFile } from 'node:fs/promises';
const params = new URLSearchParams({
url: 'https://example.com/invoices/1042',
format: 'A4',
background: 'true',
wait_for_selector: '#invoice-total',
});
const res = await fetch(`https://snappdf.dedyn.io/v1/pdf?${params}`);
if (!res.ok) {
const { error, detail } = await res.json();
throw new Error(`${error}: ${detail}`);
}
const pdf = Buffer.from(await res.arrayBuffer());
await writeFile('invoice-1042.pdf', pdf);
That Buffer.from(await res.arrayBuffer()) matters. Calling res.text() on a PDF corrupts it, since the bytes are not valid UTF-8 and the decoder replaces what it cannot map.
Wiring it into CI
The version I actually use runs on a docs repo. Every merge to main, the job re-renders the published pages and uploads them as a build artifact, so support can hand a customer the exact doc set that shipped with a release:
- name: Render docs to PDF
run: |
mkdir -p pdfs
while read -r slug; do
curl -fsS --get --retry 3 \
--data-urlencode "url=https://docs.example.com/$slug" \
--data-urlencode "wait_for_selector=article" \
-o "pdfs/$slug.pdf" \
https://snappdf.dedyn.io/v1/pdf
done < docs-pages.txt
- uses: actions/upload-artifact@v4
with:
name: docs-pdf
path: pdfs/
Keep the -f on curl. Without it curl exits 0 on a 502 and happily writes the JSON error body into a file named .pdf, and you find out weeks later when someone opens it. With -f the step fails where the problem happened.
The same loop works for invoices keyed off order IDs, or for a nightly snapshot of an internal dashboard mailed to whoever asked for it.
Working curl, Node, and Python examples live in the repo: github.com/clause-netizen/snappdf-api
Top comments (0)