Bulk HTML and Markdown to PDF: Convert a Whole Folder in One Job
Turn a ZIP of HTML or Markdown files into a ZIP of PDFs, with relative CSS, images and fonts still working. How it works, what it costs, and how to use it.
Converting one document to PDF is easy. Converting two hundred is where things usually fall apart: you write a loop, fire off requests one at a time, handle the failures, rename the outputs, and zip them back up yourself. And if those documents share a stylesheet or reference images by relative path, a one-file-at-a-time API can’t see any of it.
PodPDF bulk conversion handles the whole batch as a single job. Send a folder of HTML or Markdown files, get back one ZIP with a PDF per document and a manifest explaining what happened to each file.
What Bulk Conversion Does
You send a bundle of files. PodPDF:
- Opens the bundle and sorts files into documents and assets
- Renders each document to PDF with a headless browser, serving the other files in the bundle as its assets
- Packages the results into a ZIP that mirrors your folder structure, plus a
manifest.json - Charges only for PDFs that were produced — failed and skipped files cost nothing
The job runs asynchronously. You get a job_id back immediately and either poll for status or receive a webhook when it finishes.
Relative Assets Keep Working
This is the part a single-document API can’t do. Suppose your folder looks like this:
reports/
├── january.html
├── february.html
├── css/
│ └── site.css
└── img/
└── logo.png
and january.html contains:
<link rel="stylesheet" href="css/site.css">
<img src="img/logo.png" alt="Logo">
When you convert january.html on its own, those relative paths point at nothing. In a bulk job they resolve against the bundle, so the CSS and the logo load exactly as they would when opening the file from disk. Root-absolute paths like /css/site.css resolve against the top of the bundle too.
Which files become PDFs depends on bundle_type:
bundle_type | Converted to PDF | Everything else |
|---|---|---|
html (default) | .html, .htm | Served as assets |
markdown | .md, .markdown | Served as assets |
So a Markdown bundle can reference images in the same ZIP, and an HTML bundle can include Markdown files without them becoming PDFs.
Limits
| Limit | Value |
|---|---|
| Pages per job | 200, across all files |
| Files per job | 200 |
| ZIP upload | 50 MB |
| Inline upload (multipart or base64) | 4 MB |
| Pages per individual PDF | 100 |
| Bulk jobs running at once | 2 per account |
| Output kept | 30 days |
The 200-page budget is shared across the job. Files are rendered in order, and once the next file would push the total past 200 pages, it and the files after it are skipped rather than failing the job — you get every PDF that fit, and the manifest tells you which ones didn’t.
Using the API
Every request authenticates with your API key in the X-API-Key header. There are two ways to send files.
Small batches: multipart upload
Up to 4 MB of files, sent directly. Multipart drops folder names, so pass a paths array in the same order as the files to keep the structure that relative links depend on:
curl -X POST https://api.podpdf.com/bulkjob \
-H "X-API-Key: YOUR_API_KEY" \
-F bundle_type=html \
-F 'paths=["reports/january.html","reports/february.html","reports/css/site.css","reports/img/logo.png"]' \
-F files=@reports/january.html \
-F files=@reports/february.html \
-F files=@reports/css/site.css \
-F files=@reports/img/logo.png
Larger batches: upload a ZIP
For anything up to 50 MB, upload the ZIP straight to storage with a presigned URL, then submit the job:
# 1. Ask for an upload URL for a ZIP of this exact size
UPLOAD=$(curl -s -X POST https://api.podpdf.com/bulkjob/upload-url \
-H "X-API-Key: $PODPDF_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"content_length_bytes\": $(wc -c < reports.zip)}")
# 2. Upload the ZIP (no API key on this request)
curl -X PUT "$(echo "$UPLOAD" | jq -r .upload_url)" \
-H "Content-Type: application/zip" \
--data-binary @reports.zip
# 3. Submit the job
curl -X POST https://api.podpdf.com/bulkjob \
-H "X-API-Key: $PODPDF_API_KEY" \
-H "Content-Type: application/json" \
-d "{\"s3_key\": \"$(echo "$UPLOAD" | jq -r .s3_key)\", \"bundle_type\": \"html\", \"options\": {\"format\": \"A4\"}}"
Either way, the response is 202 Accepted:
{
"job_id": "3f5c2a7e-8b1d-4c9e-a2f4-6d7e8f9a0b1c",
"status": "queued",
"job_type": "bulk",
"bundle_type": "html",
"message": "Bulk job queued for processing"
}
The options object takes the same PDF settings as single conversions — format, margin, landscape, printBackground, scale, header and footer templates — and applies them to every document in the job.
Tracking the job
Poll the job until it reaches a final status:
curl https://api.podpdf.com/jobs/3f5c2a7e-8b1d-4c9e-a2f4-6d7e8f9a0b1c \
-H "X-API-Key: $PODPDF_API_KEY"
{
"job_id": "3f5c2a7e-8b1d-4c9e-a2f4-6d7e8f9a0b1c",
"status": "partial_failed",
"job_type": "bulk",
"file_count": 42,
"processed_count": 42,
"success_count": 40,
"failure_count": 2,
"pages_total": 118
}
status moves from queued to processing, then ends as one of:
completed— every document convertedpartial_failed— some converted, some failed or were skippedfailed— nothing converted
Rather than polling, you can register a webhook in the dashboard and subscribe to bulk.job.completed, bulk.job.partial and bulk.job.failed.
Downloading the results
curl https://api.podpdf.com/jobs/3f5c2a7e-8b1d-4c9e-a2f4-6d7e8f9a0b1c/download \
-H "X-API-Key: $PODPDF_API_KEY"
This returns a download_url for the output ZIP, valid for one hour. The ZIP itself is kept for 30 days, so call the endpoint again whenever you need a fresh link.
Partial Failures, Handled
In a batch of 200 files, something will go wrong eventually — a file too large, a document that renders to too many pages, the page budget running out. Bulk jobs don’t throw away 199 good PDFs because of one bad file.
Every PDF that converted is in the output ZIP. manifest.json records the outcome for every input:
{
"input": "reports/huge.html",
"output": null,
"status": "failed",
"error_code": "FILE_TOO_LARGE",
"missing_assets": 0
}
missing_assets is worth watching: it counts references the document made that weren’t in the bundle — a quick way to catch a stylesheet you forgot to include before a customer notices a PDF with no styling.
For a per-file breakdown without downloading anything, call GET /jobs/{job_id}/files.
What It Costs
Bulk conversion uses the same pricing as everything else: $0.01 per PDF produced. A job that converts 40 of 42 files costs $0.40. Skipped and failed files are free.
Before rendering starts, PodPDF checks your credit balance can cover the documents in the archive, so a job never gets halfway through and stops for lack of credits.
Using the Web App
You don’t need the API for any of this. In the PodPDF web app, open the Bulk tab and pick a folder or a ZIP. The app:
- Lists the files it found and which will become PDFs
- Ignores macOS metadata like
.DS_Storeand._files - Shows an estimate before you submit
- Lets you switch between HTML and Markdown when a folder contains both
Submit, and the job appears in your job history with live progress. Download the ZIP when it’s done.
When to Use Bulk vs Single Conversions
| You have | Use |
|---|---|
| One document, need the PDF back immediately | POST /quickjob (up to 25 pages) |
| One large document | POST /longjob (up to 100 pages) |
| Many documents, or documents that share assets | POST /bulkjob |
Bulk is the right choice as soon as your documents reference each other’s files, or you’d otherwise be writing a loop.
Common Use Cases
- Documentation exports — Turn a folder of Markdown docs into a PDF per page for offline distribution
- Statement runs — Render a month of customer statements from HTML templates that share one stylesheet
- Static site archives — Convert an exported site, with its CSS and images, into PDFs for records retention
- Report packs — Produce a set of related reports in one job and hand over a single ZIP
Getting Started
Create a PodPDF account, buy credits, and generate an API key under Settings → API Keys. Credits never expire. The bulk conversion guide and API reference cover every option and error code.