Skip to content

Scheduled PDF reports: how to generate recurring reports automatically

Someone on your team rebuilds the same report by hand every Monday. Automating it takes four moving parts: a scheduler, a report URL with an explicit date range, a render call that waits for the charts, and delivery that stores what it sent. Here is how each should work, and the failures that make automated reports look homemade.

Somebody on your team is generating the same report by hand every Monday. They open a dashboard, wait for it to load, hit print to PDF, fix the page breaks, rename the file, and email it to eleven people. It takes forty minutes, it happens fifty-two times a year, and the output is slightly different every time depending on who did it.

Automating that is a small project with an unusually good return. The pieces are a schedule, a page that renders the report, a renderer that turns it into a PDF, and a delivery step. Here is how each one should work, and the specific things that break when you build it the obvious way.

A scheduled report is one of four patterns for automating PDF generation, and it is the one where the hard part is data readiness rather than volume. If you are still deciding which pattern fits your document, start there and come back.

The short version

To generate PDF reports on a schedule, build the report as a normal authenticated web page in your app, then run a scheduled job that calls a rendering engine against that page's URL with a short-lived token, and deliver the resulting PDF by email or to object storage. Use a cron expression or your framework's scheduler for timing, render with headless Chromium so your existing CSS and charts survive, and make the report page accept an explicit date range parameter so the job is idempotent and can be re-run for any past period.

Build the report as a web page first

The instinct is to build a PDF generator. Build a web page instead, at a real URL like /reports/monthly?from=2026-06-01&to=2026-06-30, and let the PDF be a rendering of that page.

This one decision solves several problems at once. You can look at the report in a browser while developing it instead of opening a PDF after every change. Your existing components, CSS and chart library work unchanged. Customers who prefer a link get one. And when the numbers look wrong, you debug a web page with devtools rather than a binary file.

Make the date range an explicit parameter rather than something the page infers from the current time. A report that means "last month" relative to when it runs cannot be re-rendered correctly after the fact, and the first time a job fails on the first of the month you will want to re-run it for that exact period. Explicit ranges make the whole pipeline idempotent: same URL, same PDF, forever.

Scheduling the job

Use whatever scheduler you already have rather than adding one. Laravel's scheduler, Rails with a cron-backed job, a Celery beat task, a Kubernetes CronJob, or a platform scheduled function all work. The scheduling is the easy part. The parts teams get wrong are these:

DecisionThe mistakeWhat to do instead
Time zoneRunning in server UTC and sending a "Monday" report on Sunday evening local timeSchedule in the recipient's time zone, or state the zone in the report header
Data readinessRunning at midnight before the overnight ETL has finishedTrigger on data completion, or offset the schedule well past the load window
Failure handlingSilent failure, discovered when someone asks where the report isAlert on job failure, and retry with backoff
RetriesA retry that emails the report a second timeMake delivery idempotent, keyed on report id and period
Long runsRendering 400 client reports in one serial job that times outFan out one queued job per report, with bounded concurrency

The data readiness one causes the most confusion, because the failure is silent. The job runs, the render succeeds, the PDF arrives, and the numbers are wrong because yesterday's data had not landed yet. If your report depends on a nightly load, have the load trigger the report rather than guessing at a safe hour. Where the report pulls from several systems at once, the readiness question multiplies, and it is worth having something that keeps those separate apps, APIs and databases in sync in one place before you start rendering numbers from them.

Rendering the page without shipping a browser

The report page is authenticated, which is the part that trips people up. The renderer needs to see the page as a logged-in user would, and you do not want to put a session cookie or a long-lived API key into a render request.

The clean pattern is a short-lived signed token. The scheduled job mints a token scoped to that one report URL, valid for a couple of minutes, and passes it as a query parameter or header. The report route accepts it, renders, and the token expires before it could be useful to anyone else. Laravel has signed URLs built in; most frameworks have an equivalent, and a signed JWT with a two-minute expiry works anywhere.

Then render. If you run headless Chromium yourself, that is a Puppeteer or Playwright call. If you would rather not operate a browser fleet for a job that runs once a week, post the URL to a rendering API:

curl https://api.sitepdf.com/v1/render \
  -H "Authorization: Bearer $SITEPDF_KEY" \
  -F url="https://app.example.com/reports/monthly?from=2026-06-01&to=2026-06-30&token=$SIGNED" \
  -F format=Letter \
  -F print_background=true \
  -F wait_for="#report-ready" \
  -F archive=true

The wait_for parameter matters more than anything else in that call. A report page usually finishes loading before its charts finish drawing, so a naive render captures empty chart containers. Set an explicit marker element that your page adds once every chart has rendered, and wait on that rather than on a fixed delay. Our guide on waiting for content before rendering covers the patterns in detail, and the page breaks guide covers keeping tables from splitting across pages, which is the other thing that makes an automated report look homemade.

Reports that print well

A dashboard is designed for a screen that scrolls. A report is a fixed-size page, and the difference shows up immediately. A few adjustments carry most of the quality:

  • Set an explicit page size and orientation. Wide tables want landscape, and US recipients want Letter rather than A4. The page size guide covers how to set this reliably.
  • Turn on background printing, or your branded header prints white. Backgrounds are stripped by default in print rendering.
  • Repeat table headers across pages with thead, and prevent rows splitting with break-inside: avoid.
  • Hide navigation, filters and buttons with a print stylesheet. Nobody wants a Refresh button in a PDF.
  • Put the period, the generation timestamp and a report identifier in the footer. When someone forwards page four out of context, that footer is what makes it interpretable.

Delivery, and keeping a copy

Once the PDF exists, deliver it and store it. Email is the common route and works fine, with the caveat that attachment size limits are real and a heavy report with embedded images can bounce. Above a few megabytes, email a link to object storage instead of the file itself.

Storing a copy matters more than teams expect. Recurring reports get referenced later, often in an argument: what did the June numbers say before the restatement, what did we send the client, which version did they sign off on. If your report reflects live data, regenerating it next quarter will produce a different document than the one you sent, because the underlying data moved. Store the rendered artifact, not just the ability to re-render.

That is what archive=true does in the call above: alongside the PDF, Sitepdf keeps a timestamped, retrievable snapshot of exactly what was rendered, so the June report stays the June report. The website archiving page explains how the record works. If you would rather store it yourself, save the PDF to your own bucket with a deterministic key like reports/{client}/{period}.pdf and never overwrite it.

Frequently asked questions

How do I generate a PDF report automatically every month?

Build the report as an authenticated web page that takes an explicit date range, then schedule a job that mints a short-lived signed token, calls a rendering engine against that URL, and emails or stores the PDF. Use your framework's existing scheduler for timing, and trigger on data-load completion rather than a fixed hour if the report depends on an overnight pipeline.

How do I render a login-protected page to PDF?

Do not share session cookies with the renderer. Generate a signed URL or short-lived token scoped to that single report route, valid for a minute or two, and pass it in the render request. The route validates the token and renders normally. The token expires long before it could be reused, and no long-lived credential leaves your application.

Why are my charts blank in the scheduled PDF?

The render fired before the charting library finished drawing. Page load completing is not the same as content being ready, especially with JavaScript charts that draw after data arrives. Add a marker element to the DOM once all charts have rendered, then have the renderer wait for that selector instead of using a fixed delay.

Should I generate report PDFs on demand or on a schedule?

Generate on a schedule when the report goes to people who expect it in their inbox, and on demand when users choose their own date ranges. Many teams do both from the same report page, which is the main argument for building the report as a web page first: one implementation serves the scheduled job, the download button and the shared link.

How do I generate hundreds of client reports at once?

Fan out rather than looping. Queue one job per client so a single failure does not take down the batch, cap concurrency so you do not overwhelm your database or your renderer, and make each job idempotent so retries are safe. The bulk HTML to PDF page covers running high-volume batches without a browser pool of your own.

Putting it together

The whole pipeline is four moving parts: a scheduler, a report URL with an explicit period, a render call that waits for the content to be ready, and a delivery step that stores what it sent. Built that way it runs for years without attention, and it produces the same document every time regardless of who is on shift.

If the rendering half is the part you would rather not own, the PDF generator API page lists every parameter including wait_for and archiving, and what these APIs actually cost works through the pricing models before you commit. Where a scheduled report is one of several document types you produce from templates, the document generation API page covers that wider job. You can try the converter at the top of this page on any URL, including a report page, right now.

Written by the team building Sitepdf, an HTML to PDF API that archives every page it renders. The in-browser converter is free to try; early access locks the launch pricing.

§ Early access

Get on the early-access list

The API opens to the list first, in order. Early access locks the planned launch rates for 12 months. No card required, launching soon.

Render + archive, one API