Skip to main content

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_typeConvertedEverything else
html (default).html, .htmTreated as assets
markdown.md, .markdownTreated 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:

![diagram](./img/diagram.png)

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"
StatusMeaning
queued / processingStill working; processed_count climbs as files render
completedEvery file converted
partial_failedSome files converted; the rest failed or were skipped
failedNothing 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
}
CodeWhat to do
FILE_TOO_LARGEThe file is over 10 MB; split it or inline fewer assets
PAGE_LIMIT_EXCEEDEDThat document renders to more than 100 pages
RENDER_TIMEOUTThe page never finished loading in 20 seconds — usually a slow remote asset
RENDER_FAILEDThe page could not be rendered at all
BULK_PAGE_BUDGET_EXCEEDEDThe job hit its 200-page budget; resubmit the rest
TIME_BUDGET_EXCEEDEDThe 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_assets and blocked_requests if 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_url after 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