POST /bulkjob
Convert many HTML or Markdown files in one job and get back a single ZIP containing a PDF per file.
When to Use
✅ Use /bulkjob when:
- You have many documents to convert at once (a folder of reports, a docs directory, generated invoices)
- You want one download instead of one request per file
- Your files share assets — CSS, images, fonts — that must resolve while rendering
- You want a per-file record of what converted and what failed
❌ Use something else when:
- You have a single small document →
POST /quickjobreturns the PDF immediately - You have a single large document →
POST /longjobhandles up to 100 pages asynchronously
Bulk jobs always run in the background. You submit, then poll GET /jobs/{job_id} or wait for a bulk.job.* webhook.
Bulk conversion must be enabled on your plan. GET /plans/{plan_id} returns bulk_enabled, max_bulk_pages_per_job and max_bulk_zip_mb.
Quick Example
curl -X POST https://api.podpdf.com/bulkjob \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{
"bundle_type": "html",
"files": [
{ "path": "invoice-1.html", "content_base64": "PGgxPkludm9pY2UgMTwvaDE+" },
{ "path": "invoice-2.html", "content_base64": "PGgxPkludm9pY2UgMjwvaDE+" }
]
}'
{
"job_id": "3f5c2a7e-8b1d-4c9e-a2f4-6d7e8f9a0b1c",
"status": "queued",
"job_type": "bulk",
"bundle_type": "html",
"message": "Bulk job queued for processing"
}
Authentication
Send either an API key or a Cognito ID token:
X-API-Key: your_api_key_here
Authorization: Bearer <cognito_id_token>
If both are present, the API key is used.
Request
Endpoint
POST https://api.podpdf.com/bulkjob
Send exactly one input. Use the presigned upload for anything over 4 MB; the inline modes are limited by the API request size.
- ZIP upload (up to 50 MB)
- Multipart (up to 4 MB)
- Base64 files (up to 4 MB)
- Base64 ZIP (up to 4 MB)
Ask for an upload URL, PUT the ZIP to it, then submit the returned key.
Step 1 — get an upload URL
curl -X POST https://api.podpdf.com/bulkjob/upload-url \
-H "X-API-Key: your_api_key_here" \
-H "Content-Type: application/json" \
-d '{"content_length_bytes": 15728640}'
{
"upload_url": "https://podpdf-prod-pdfs.s3.eu-central-1.amazonaws.com/bulk-uploads/...?X-Amz-Signature=...",
"s3_key": "bulk-uploads/01HZXUSERID/01J8K2Q9ZP6X7Y3M4N5V6W7A8B.zip",
"expires_at": "2026-09-12T10:15:00.000Z",
"max_bytes": 52428800,
"required_headers": { "Content-Type": "application/zip" }
}
content_length_bytes must be the exact byte size of the ZIP. The URL is valid for 15 minutes.
Step 2 — upload the ZIP
curl -X PUT "$UPLOAD_URL" \
-H "Content-Type: application/zip" \
--data-binary @site.zip
Do not send an API key or Authorization header to the upload URL. Storage rejects an upload whose size or content type differs from what was signed.
Step 3 — submit the job
{
"s3_key": "bulk-uploads/01HZXUSERID/01J8K2Q9ZP6X7Y3M4N5V6W7A8B.zip",
"bundle_type": "html",
"options": { "format": "A4", "printBackground": true }
}
Send the files as multipart form data. Filenames lose their folders in multipart, so add a paths field: a JSON array of relative paths in the same order as the files.
curl -X POST https://api.podpdf.com/bulkjob \
-H "X-API-Key: your_api_key_here" \
-F bundle_type=markdown \
-F 'paths=["docs/guide.md","docs/img/diagram.png"]' \
-F files=@guide.md \
-F files=@img/diagram.png
Without paths, each file keeps only its own name and lands at the top level of the bundle.
Send each file with its path. This keeps folder structure without building a ZIP.
{
"bundle_type": "html",
"files": [
{ "path": "reports/january.html", "content_base64": "PCFET0NUWVBFIGh0bWw+..." },
{ "path": "reports/css/site.css", "content_base64": "Ym9keSB7fQ==" },
{ "path": "reports/img/logo.png", "content_base64": "iVBORw0KGgo..." }
]
}
If you already have a ZIP and it is small, send it inline.
{
"bundle_type": "markdown",
"zip_base64": "UEsDBBQAAAAIA..."
}
Request Fields
| Field | Type | Required | Description |
|---|---|---|---|
bundle_type | string | ❌ | html (default) converts .html and .htm; markdown converts .md and .markdown |
s3_key | string | ✅* | Key returned by POST /bulkjob/upload-url |
zip_base64 | string | ✅* | Base64-encoded ZIP, up to 4 MB decoded |
files | array | ✅* | { path, content_base64 } objects, up to 4 MB decoded in total |
options | object | ❌ | PDF options applied to every file (see below) |
✅* Send exactly one of s3_key, zip_base64 or files. Multipart requests use the files form field instead.
Options accept the same keys as /quickjob: format, width, height, margin, printBackground, scale, landscape, preferCSSPageSize, pageRanges, displayHeaderFooter, headerTemplate, footerTemplate. Anything else is ignored.
What gets converted
Only files matching bundle_type become PDFs and are billed. Everything else — CSS, images, fonts, JavaScript, and files of the other type — is an asset that is served to the page while rendering.
Folder structure is preserved, so references resolve the way they do on a normal site:
- relative:
<img src="img/logo.png">,<link href="../css/site.css">, - from the bundle root:
<link href="/shared/theme.css"> - remote
https://URLs anddata:URIs load normally
Requests to local files, localhost, and private or internal addresses are blocked; the page still renders and the count appears as blocked_requests. Missing files inside the bundle are counted as missing_assets. __MACOSX/, ._* and .DS_Store entries are ignored, and nested archives are not opened.
Markdown files are converted with the same renderer and styles as /quickjob, and a leading YAML front matter block is removed.
Response
Success (202 Accepted)
{
"job_id": "3f5c2a7e-8b1d-4c9e-a2f4-6d7e8f9a0b1c",
"status": "queued",
"job_type": "bulk",
"bundle_type": "html",
"message": "Bulk job queued for processing"
}
Tracking progress
Poll GET /jobs/{job_id}:
{
"job_id": "3f5c2a7e-8b1d-4c9e-a2f4-6d7e8f9a0b1c",
"status": "partial_failed",
"job_type": "bulk",
"bundle_type": "html",
"file_count": 42,
"processed_count": 42,
"success_count": 40,
"failure_count": 2,
"pages_total": 118,
"s3_url": "https://...-output.zip?X-Amz-Signature=...",
"s3_url_expires_at": "2026-09-12T11:03:12.000Z",
"error_code": null,
"completed_at": "2026-09-12T10:03:12.000Z"
}
status moves queued → processing → completed (every file converted), partial_failed (some converted) or failed (none converted). file_count is null until the archive is opened, and processed_count updates as files are rendered.
Per-file results and download
GET /jobs/{job_id}/files— what happened to each fileGET /jobs/{job_id}/download— a fresh one-hour link to the output ZIP
The output ZIP mirrors the input paths and adds a manifest.json:
reports/january.pdf
reports/february.pdf
manifest.json
Error Responses
Request errors
| Status | Code | Meaning | Solution |
|---|---|---|---|
| 400 | INVALID_BUNDLE_TYPE | bundle_type is not html or markdown | Use a supported type |
| 400 | INVALID_BULK_INPUT | Zero or several inputs sent | Send exactly one of s3_key, zip_base64, files |
| 400 | INVALID_BULK_PATH | A path is absolute, empty or contains .. | Use relative paths inside the bundle |
| 400 | INVALID_BULK_PATHS | paths is not a JSON array with one entry per file | Match the array to the uploaded files |
| 400 | INVALID_BASE64 | Content is not valid base64 | Re-encode the file |
| 400 | BULK_ZIP_TOO_LARGE | ZIP is over your plan's limit | Split the batch |
| 402 | UPGRADE_REQUIRED | No paid plan on the account | Purchase credits |
| 403 | BULK_NOT_ENABLED | Plan does not include bulk conversion | Contact support |
| 403 | FORBIDDEN | s3_key belongs to another account | Use a key from your own upload-url call |
| 403 | CONVERSION_TYPE_NOT_ENABLED | Plan does not allow that bundle_type | Use an enabled type |
| 403 | INSUFFICIENT_CREDITS | Balance cannot cover a PDF | Purchase credits |
| 404 | UPLOAD_NOT_FOUND | The ZIP was never uploaded, or expired | Upload again, then submit |
| 409 | BULK_JOB_ALREADY_ACTIVE | Two bulk jobs are already running | Wait for one to finish |
| 413 | BULK_INLINE_TOO_LARGE | Inline files exceed 4 MB | Use the presigned upload |
Job-level failures
Returned as error_code on a failed job.
| Code | Meaning |
|---|---|
BULK_ZIP_UNSAFE_ENTRY | The archive contains an absolute path, .., or a symlink |
BULK_ZIP_SUSPICIOUS_ENTRY | An entry over 1 MB expands more than 100× |
BULK_ZIP_DECOMPRESSED_TOO_LARGE | The archive expands beyond 300 MB |
BULK_ZIP_TOO_MANY_ENTRIES | More than 1000 entries |
BULK_ZIP_INVALID | The archive could not be read |
BULK_NO_CONVERTIBLE_FILES | No files of the chosen bundle_type |
BULK_FILE_COUNT_EXCEEDED | More convertible files than the page budget |
INSUFFICIENT_CREDITS | Balance cannot cover every file in the archive |
BULK_ALL_FILES_FAILED | Every file failed, for different reasons |
Per-file results
Returned per entry from GET /jobs/{job_id}/files.
| Code | Status | Meaning | Billed |
|---|---|---|---|
FILE_TOO_LARGE | failed | File over 10 MB | No |
PAGE_LIMIT_EXCEEDED | failed | The PDF exceeds the per-file page limit | No |
RENDER_TIMEOUT | failed | Rendering did not finish in time | No |
RENDER_FAILED | failed | The file could not be rendered | No |
BULK_PAGE_BUDGET_EXCEEDED | skipped | Adding this file would pass the batch page budget | No |
TIME_BUDGET_EXCEEDED | skipped | The job ran out of processing time | No |
Complete Examples
cURL — upload, submit, wait, download
API_KEY="your_api_key_here"
SIZE=$(wc -c < site.zip)
# 1. Upload URL
UPLOAD=$(curl -s -X POST https://api.podpdf.com/bulkjob/upload-url \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-d "{\"content_length_bytes\": $SIZE}")
URL=$(echo "$UPLOAD" | jq -r .upload_url)
KEY=$(echo "$UPLOAD" | jq -r .s3_key)
# 2. Upload
curl -s -X PUT "$URL" -H "Content-Type: application/zip" --data-binary @site.zip
# 3. Submit
JOB=$(curl -s -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\"}" | jq -r .job_id)
# 4. Poll until it finishes
while true; do
STATUS=$(curl -s "https://api.podpdf.com/jobs/$JOB" -H "X-API-Key: $API_KEY" | jq -r .status)
echo "status: $STATUS"
case "$STATUS" in completed|partial_failed|failed) break ;; esac
sleep 5
done
# 5. Download the output ZIP
curl -s "https://api.podpdf.com/jobs/$JOB/download" -H "X-API-Key: $API_KEY" \
| jq -r .download_url | xargs curl -o output.zip
JavaScript (Node 18+)
const API_KEY = process.env.PODPDF_API_KEY;
const BASE = 'https://api.podpdf.com';
async function api(path, options = {}) {
const response = await fetch(BASE + path, {
...options,
headers: { 'X-API-Key': API_KEY, 'Content-Type': 'application/json', ...options.headers },
});
return response.json();
}
// Files under 4 MB can go inline, no upload step
const job = await api('/bulkjob', {
method: 'POST',
body: JSON.stringify({
bundle_type: 'html',
files: [
{ path: 'a.html', content_base64: Buffer.from('<h1>A</h1>').toString('base64') },
{ path: 'b.html', content_base64: Buffer.from('<h1>B</h1>').toString('base64') },
],
}),
});
let status = 'queued';
while (!['completed', 'partial_failed', 'failed'].includes(status)) {
await new Promise((resolve) => setTimeout(resolve, 5000));
({ status } = await api(`/jobs/${job.job_id}`));
}
const { files } = await api(`/jobs/${job.job_id}/files`);
console.log(files.map((file) => `${file.input}: ${file.status}`).join('\n'));
const { download_url: downloadUrl } = await api(`/jobs/${job.job_id}/download`);
console.log('Download:', downloadUrl);
Python
import base64, time, requests
API_KEY = "your_api_key_here"
BASE = "https://api.podpdf.com"
headers = {"X-API-Key": API_KEY}
# Upload a ZIP
zip_bytes = open("docs.zip", "rb").read()
upload = requests.post(f"{BASE}/bulkjob/upload-url", headers=headers,
json={"content_length_bytes": len(zip_bytes)}).json()
requests.put(upload["upload_url"], data=zip_bytes,
headers={"Content-Type": "application/zip"})
job = requests.post(f"{BASE}/bulkjob", headers=headers,
json={"s3_key": upload["s3_key"], "bundle_type": "markdown"}).json()
while True:
job_status = requests.get(f"{BASE}/jobs/{job['job_id']}", headers=headers).json()
if job_status["status"] in ("completed", "partial_failed", "failed"):
break
time.sleep(5)
print(job_status["success_count"], "of", job_status["file_count"], "converted")
download = requests.get(f"{BASE}/jobs/{job['job_id']}/download", headers=headers).json()
open("output.zip", "wb").write(requests.get(download["download_url"]).content)
Billing
Each successfully converted PDF costs your plan's per-PDF price; free credits are used first. Files that fail or are skipped are not charged.
Before rendering starts, the whole job is checked against your balance: if free credits plus balance cannot cover every convertible file, the job fails with INSUFFICIENT_CREDITS and nothing is charged.
Limits
| Limit | Value |
|---|---|
| Bundle types | html (.html, .htm), markdown (.md, .markdown) |
| ZIP upload | 50 MB |
| Inline upload (multipart or base64) | 4 MB of files |
| Uncompressed archive | 300 MB, up to 1000 entries |
| Convertible file size | 10 MB each |
| Pages per job | 200, and at most 200 convertible files |
| Pages per file | 100 |
| Active bulk jobs per account | 2 |
| Output retention | 30 days; download links last 1 hour and can be reissued |
When a file would take the job past the page budget, that file and every file after it are marked skipped and are not billed. See Limits for every limit across the API.
Next Steps
- Bulk conversion guide — a full walkthrough with assets, failures and the manifest
- Jobs API — status, per-file results and downloads
- Webhooks — get notified instead of polling