DEV Community

clause-netizen
clause-netizen

Posted on

Turn any URL into a print-ready PDF with one GET request

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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)