Bulk Conversion Guide
Convert a folder of HTML or Markdown files in one job and download a single ZIP of PDFs.
This guide walks through a complete job. For the request and response reference, see POST /bulkjob.
How it works
1. POST /bulkjob/upload-url → a one-time upload URL (ZIPs over 4 MB)
2. PUT the ZIP to that URL
3. POST /bulkjob → 202 with a job_id
4. GET /jobs/{job_id} → poll, or wait for a webhook
5. GET /jobs/{job_id}/files → what happened to each file
6. GET /jobs/{job_id}/download → a fresh link to the output ZIP
Small batches skip steps 1 and 2: send the files inline as base64 or multipart, up to 4 MB.
Preparing the files
A bundle is just your folder structure, zipped:
site.zip
├── reports/
│ ├── january.html
│ ├── february.html
│ ├── css/site.css
│ └── img/logo.png
└── shared/theme.css
Choose what gets converted with bundle_type:
bundle_type | Converted | Everything else |
|---|---|---|
html (default) | .html, .htm | Treated as assets |
markdown | .md, .markdown | Treated as assets |
So the bundle above produces reports/january.pdf and reports/february.pdf, using the CSS and image while rendering. Output paths mirror input paths.
How assets resolve
Each file is rendered from its own location in the bundle, so references work the way they do on a website:
<!-- in reports/january.html -->
<link rel="stylesheet" href="css/site.css"> <!-- reports/css/site.css -->
<link rel="stylesheet" href="/shared/theme.css"> <!-- shared/theme.css, from the bundle root -->
<img src="img/logo.png"> <!-- reports/img/logo.png -->
<img src="https://cdn.example.com/banner.png"> <!-- fetched normally -->
Markdown works the same way:

Fonts, stylesheets and images hosted on the public internet load normally. Requests to local files, localhost, and private or internal addresses are blocked — the page still renders, and the count shows up as blocked_requests for that file. A reference to something that is not in the bundle returns a 404 and is counted as missing_assets. Both are worth checking when a PDF looks unstyled.
A leading YAML front matter block is removed from Markdown files, so this renders as a heading rather than a table:
---
title: Guide
---
# Guide
__MACOSX/, ._* and .DS_Store entries (which macOS adds when you zip from Finder) are ignored, and nested archives are not opened.
Running a job
API_KEY="your_api_key_here"
curl -X POST https://api.podpdf.com/bulkjob \
-H "X-API-Key: $API_KEY" \
-H "Content-Type: application/json" \
-d '{"s3_key": "'"$KEY"'", "bundle_type": "html", "options": {"format": "A4", "printBackground": true}}'
options are applied to every file in the job.
Then poll until the status is terminal:
curl "https://api.podpdf.com/jobs/$JOB_ID" -H "X-API-Key: $API_KEY"
| Status | Meaning |
|---|---|
queued / processing | Still working; processed_count climbs as files render |
completed | Every file converted |
partial_failed | Some files converted; the rest failed or were skipped |
failed | Nothing converted, or the job was rejected before rendering |
Polling every 3–5 seconds is plenty. To avoid polling entirely, subscribe to the bulk.job.completed, bulk.job.partial and bulk.job.failed webhooks.
The page budget
A job covers 200 pages. Files are converted in path order until the next PDF would push the job past the budget; that file and everything after it are marked skipped with BULK_PAGE_BUDGET_EXCEEDED and are not billed.
Because every PDF is at least one page, an archive with more than 200 convertible files is rejected up front with BULK_FILE_COUNT_EXCEEDED — nothing is rendered and nothing is charged.
If your batch is bigger, split it into several jobs. Two bulk jobs can run at once per account.
Reading the results
curl "https://api.podpdf.com/jobs/$JOB_ID/files" -H "X-API-Key: $API_KEY"
Every file comes back with its own status:
{
"index": 7,
"input": "reports/huge.html",
"output": null,
"status": "failed",
"pages": 0,
"error_code": "FILE_TOO_LARGE",
"error_message": "File exceeds the 10 MB limit",
"blocked_requests": 0,
"missing_assets": 0,
"render_timeout": false
}
| Code | What to do |
|---|---|
FILE_TOO_LARGE | The file is over 10 MB; split it or inline fewer assets |
PAGE_LIMIT_EXCEEDED | That document renders to more than 100 pages |
RENDER_TIMEOUT | The page never finished loading in 20 seconds — usually a slow remote asset |
RENDER_FAILED | The page could not be rendered at all |
BULK_PAGE_BUDGET_EXCEEDED | The job hit its 200-page budget; resubmit the rest |
TIME_BUDGET_EXCEEDED | The job ran out of processing time; use smaller batches |
The same list is included in the output ZIP as manifest.json, alongside the PDFs:
reports/january.pdf
reports/february.pdf
manifest.json
Downloading
curl "https://api.podpdf.com/jobs/$JOB_ID/download" -H "X-API-Key: $API_KEY"
The returned download_url is valid for one hour. Files are kept for 30 days, so you can request a fresh link whenever you need one. A job that produced nothing has no output ZIP.
Billing
You are charged per PDF produced. Files that fail or are skipped cost nothing.
Before any rendering starts, the job is checked against your balance: if free credits plus your balance cannot cover every convertible file in the archive, the job fails with INSUFFICIENT_CREDITS and nothing is charged. Top up and resubmit.
Tips
✅ DO
- Zip the folder, not its parent, so paths stay short and predictable
- Keep shared CSS and images in the bundle so every file renders identically
- Use the presigned upload for anything over 4 MB
- Check
missing_assetsandblocked_requestsif a PDF looks unstyled - Split large batches into jobs of ~200 pages
❌ DON'T
- Don't send an API key to the upload URL — it is already signed, and the extra header breaks it
- Don't reference files outside the bundle with
../— those entries are rejected - Don't rely on a
download_urlafter an hour; request a new one - Don't submit a third bulk job while two are running; wait or you will get
BULK_JOB_ALREADY_ACTIVE
Next Steps
- POST /bulkjob — full request and response reference
- Jobs API — status, per-file results, downloads
- Limits — every limit in one place