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
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,A4by default -
landscape—trueorfalse -
background—trueorfalse, 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
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);
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
--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 (1)
"I'm curious, does this method handle pages with dynamic content, like those that load via JavaScript, or is it more suited for static web pages? Been looking for a simple way to do this for client projects, following you for more content on web automation"