DEV Community

clause-netizen
clause-netizen

Posted on

Turn Any Web Page Into a Print-Ready PDF With One curl Command

You have a page that renders fine in a browser and now someone wants it as a file: an invoice to attach, a report to archive, a pricing page to snapshot before the next deploy. Wiring up headless Chrome for that means a Puppeteer install, a container with the right shared libraries, and a cold-start problem in CI. For one GET request's worth of work, that stack is overkill.

SnapPDF does the browser part server-side. You send it a URL, it sends back a PDF.

The one-liner

No API key, no headers, no JSON envelope. GET /v1/pdf with a url param returns raw PDF bytes:

curl -G "https://snappdf.dedyn.io/v1/pdf" \
  --data-urlencode "url=https://example.com" \
  -o page.pdf
Enter fullscreen mode Exit fullscreen mode

Use -G with --data-urlencode instead of pasting the target URL into the query string yourself. If the target has its own query params (?id=42&tab=summary), a hand-built URL splits at the first & and you'll render the wrong page. --data-urlencode escapes it for you.

Optional query params, all of which map to what you'd set in a print dialog or a Puppeteer config:

  • format — paper size, A4 by default
  • landscapetrue or false
  • backgroundtrue or false, controls whether CSS backgrounds print
  • scale — a number, for shrinking wide layouts onto the page
  • wait_for_selector — a CSS selector to wait for before rendering

That last one matters most in practice. If the page draws a chart or loads data client-side, render timing decides whether your PDF shows content or a spinner. Point wait_for_selector at an element that only exists once the page is ready:

curl -G "https://snappdf.dedyn.io/v1/pdf" \
  --data-urlencode "url=https://example.com/report?id=42" \
  --data-urlencode "wait_for_selector=#chart-loaded" \
  --data-urlencode "background=true" \
  -o report.pdf
Enter fullscreen mode Exit fullscreen mode

The same thing from Node

Global fetch handles it without any dependencies. Build the URL with URL so encoding stays correct, then write the bytes to disk:

import fs from "node:fs/promises";

const api = new URL("https://snappdf.dedyn.io/v1/pdf");
api.searchParams.set("url", "https://example.com/invoice/42");
api.searchParams.set("wait_for_selector", "#invoice-total");

const res = await fetch(api);
if (!res.ok) throw new Error(`SnapPDF returned ${res.status}`);

const pdf = Buffer.from(await res.arrayBuffer());
await fs.writeFile("invoice.pdf", pdf);
Enter fullscreen mode Exit fullscreen mode

What comes back

The response body is the PDF itself, Content-Type: application/pdf. There is no JSON wrapper, no base64 field to decode, no second request to fetch the result. Pipe it to a file, stream it to S3, or set it as an email attachment as-is. If you want a sanity check in a script, the first bytes of any valid response are %PDF.

That shape is what makes it useful in CI. A GitHub Actions step that archives your live pricing page on every release needs nothing but curl:

- name: Snapshot pricing page
  run: |
    curl -G "https://snappdf.dedyn.io/v1/pdf" \
      --data-urlencode "url=https://yoursite.com/pricing" \
      --fail -o pricing-$(date +%F).pdf

- uses: actions/upload-artifact@v4
  with:
    name: pricing-snapshot
    path: pricing-*.pdf
Enter fullscreen mode Exit fullscreen mode

--fail makes curl exit nonzero on an error status, so a bad render fails the job instead of uploading an HTML error page with a .pdf extension. I run a variant of this that snapshots terms-of-service and pricing pages on a schedule, which turns "what did the page say in March" from an argument into a folder of dated PDFs.

Try it

Examples for other languages and CI setups live in the repo: github.com/clause-netizen/snappdf-api.

Top comments (0)