Invoice and report PDFs
Turn an authenticated HTML page into a paginated PDF, without a PDF library.
Generating an invoice with a PDF library means laying out a document in code: absolute coordinates, manual page breaks, a font subsystem. Rendering the invoice as an HTML page and capturing it means you keep CSS, and the same template can serve the web view and the download.
The basic render
curl -G https://ssnap.cc/api/v1/screenshot \
-H "Authorization: Bearer $SSNAP_API_KEY" \
--data-urlencode "url=https://your-app.com/invoices/1042/print?signature=..." \
--data-urlencode "format=pdf" \
--data-urlencode "network_idle=true" \
--output invoice-1042.pdfformat=pdf returns application/pdf bytes. The document paginates itself, so
full_page does not apply and selector is ignored — a PDF is always the whole
document.
Authentication is the first problem
Ssnap fetches the url as an anonymous public visitor. It carries none of your cookies, so a page behind session auth renders as your login screen, and a private url is rejected outright: the target must be publicly routable. See URL restrictions.
The workable pattern is a signed, time-limited, public route that renders one document:
URL::temporarySignedRoute('invoices.print', now()->addMinutes(5), ['invoice' => $invoice]);Same idea anywhere else: a token in the url, short expiry, single document, no session. Keep the window tight — the url is a bearer credential for that invoice while it lives.
Print CSS does the layout
The page is rendered by Chromium, so @media print rules apply. That is where pagination
actually gets controlled:
@media print {
nav, .cookie-banner, .download-button { display: none; }
.invoice-line-items { break-inside: auto; }
.invoice-total,
.signature-block { break-inside: avoid; }
thead { display: table-header-group; }
tfoot { display: table-footer-group; }
}display: table-header-group is the one worth remembering: it repeats the table header
on every page of a long line-item list, which is otherwise the most common complaint
about an HTML-rendered invoice.
Image styling does not apply
background, border_width, border_color and every watermark_* parameter are image
post-processing. They are skipped entirely for PDFs, as is quality — PDF is not a lossy
raster format.
A "PAID" or "DRAFT" watermark therefore belongs in the page's own CSS:
@media print {
.watermark {
position: fixed;
inset: 0;
display: grid;
place-content: center;
font-size: 8rem;
color: rgb(0 0 0 / 8%);
transform: rotate(-30deg);
pointer-events: none;
}
}position: fixed repeats it on every printed page, which is usually what you want.
Viewport still matters
width/height and device_id apply in PDF mode, because they set the rendering
viewport, which drives your responsive breakpoints. A template that collapses to a
single-column mobile layout at 390px will produce a single-column PDF. Pin a desktop
viewport for documents:
--data-urlencode "width=1240" \
--data-urlencode "height=1754"theme applies too — theme=dark emulates prefers-color-scheme: dark, which is almost
never what you want for something that will be printed on paper. Leave it at light.
Long reports: go async
A multi-hundred-page report will not finish inside the 30-second synchronous render
timeout. Pass a callback_url: the request returns 202 at once, and the finished PDF
is POSTed to your endpoint, with a 120-second job budget.
curl -X POST https://ssnap.cc/api/v1/screenshot \
-H "Authorization: Bearer $SSNAP_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/reports/2026-q1?signature=...",
"format": "pdf",
"network_idle": true,
"delay": 1500,
"callback_url": "https://your-app.com/hooks/ssnap"
}'Two cautions on the async path. The signed url on your side must still be valid when the job actually runs, so give it more headroom than you would for a synchronous call. And the webhook is public — verify its signature before writing the file anywhere. See Async callbacks and Verify webhook signatures.
Charts and fonts
Anything drawn client-side needs to have finished drawing. network_idle=true covers the
data fetch; delay covers the render after it. For a report full of charts, 1000–2000 ms
is a realistic starting point — then measure, because the delay counts against the render
timeout.
Caching and storage
cache_ttl works for PDFs exactly as for images, and format is part of the cache key,
so a PDF and a PNG of the same page cache separately. For an invoice this matters less
than you would think: the document is usually generated once and then belongs in your own
storage. Do not rely on Ssnap as the system of record — captures are pruned per
Storage and retention, and a signed url expires after 24 hours.
Checklist
- Public, signed, short-lived route — not a session-authenticated page.
format=pdf; expectselector,full_page,qualityand all image styling to be ignored.- Watermark and page breaks in
@media print, not in API parameters. - Desktop
width/heightso the template does not render its mobile layout. network_idle=true, plus adelayif anything is drawn client-side.- Async with
callback_urlfor anything long, with signature verification on the receiver. - Store the bytes yourself.