Skip to main content

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 /quickjob returns the PDF immediately
  • You have a single large document → POST /longjob handles up to 100 pages asynchronously
Asynchronous only

Bulk jobs always run in the background. You submit, then poll GET /jobs/{job_id} or wait for a bulk.job.* webhook.

Plan requirement

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.

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 }
}

Request Fields

FieldTypeRequiredDescription
bundle_typestringhtml (default) converts .html and .htm; markdown converts .md and .markdown
s3_keystring✅*Key returned by POST /bulkjob/upload-url
zip_base64string✅*Base64-encoded ZIP, up to 4 MB decoded
filesarray✅*{ path, content_base64 } objects, up to 4 MB decoded in total
optionsobjectPDF 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">, ![diagram](./img/diagram.png)
  • from the bundle root: <link href="/shared/theme.css">
  • remote https:// URLs and data: 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 queuedprocessingcompleted (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

The output ZIP mirrors the input paths and adds a manifest.json:

reports/january.pdf
reports/february.pdf
manifest.json

Error Responses

Request errors

StatusCodeMeaningSolution
400INVALID_BUNDLE_TYPEbundle_type is not html or markdownUse a supported type
400INVALID_BULK_INPUTZero or several inputs sentSend exactly one of s3_key, zip_base64, files
400INVALID_BULK_PATHA path is absolute, empty or contains ..Use relative paths inside the bundle
400INVALID_BULK_PATHSpaths is not a JSON array with one entry per fileMatch the array to the uploaded files
400INVALID_BASE64Content is not valid base64Re-encode the file
400BULK_ZIP_TOO_LARGEZIP is over your plan's limitSplit the batch
402UPGRADE_REQUIREDNo paid plan on the accountPurchase credits
403BULK_NOT_ENABLEDPlan does not include bulk conversionContact support
403FORBIDDENs3_key belongs to another accountUse a key from your own upload-url call
403CONVERSION_TYPE_NOT_ENABLEDPlan does not allow that bundle_typeUse an enabled type
403INSUFFICIENT_CREDITSBalance cannot cover a PDFPurchase credits
404UPLOAD_NOT_FOUNDThe ZIP was never uploaded, or expiredUpload again, then submit
409BULK_JOB_ALREADY_ACTIVETwo bulk jobs are already runningWait for one to finish
413BULK_INLINE_TOO_LARGEInline files exceed 4 MBUse the presigned upload

Job-level failures

Returned as error_code on a failed job.

CodeMeaning
BULK_ZIP_UNSAFE_ENTRYThe archive contains an absolute path, .., or a symlink
BULK_ZIP_SUSPICIOUS_ENTRYAn entry over 1 MB expands more than 100×
BULK_ZIP_DECOMPRESSED_TOO_LARGEThe archive expands beyond 300 MB
BULK_ZIP_TOO_MANY_ENTRIESMore than 1000 entries
BULK_ZIP_INVALIDThe archive could not be read
BULK_NO_CONVERTIBLE_FILESNo files of the chosen bundle_type
BULK_FILE_COUNT_EXCEEDEDMore convertible files than the page budget
INSUFFICIENT_CREDITSBalance cannot cover every file in the archive
BULK_ALL_FILES_FAILEDEvery file failed, for different reasons

Per-file results

Returned per entry from GET /jobs/{job_id}/files.

CodeStatusMeaningBilled
FILE_TOO_LARGEfailedFile over 10 MBNo
PAGE_LIMIT_EXCEEDEDfailedThe PDF exceeds the per-file page limitNo
RENDER_TIMEOUTfailedRendering did not finish in timeNo
RENDER_FAILEDfailedThe file could not be renderedNo
BULK_PAGE_BUDGET_EXCEEDEDskippedAdding this file would pass the batch page budgetNo
TIME_BUDGET_EXCEEDEDskippedThe job ran out of processing timeNo

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

LimitValue
Bundle typeshtml (.html, .htm), markdown (.md, .markdown)
ZIP upload50 MB
Inline upload (multipart or base64)4 MB of files
Uncompressed archive300 MB, up to 1000 entries
Convertible file size10 MB each
Pages per job200, and at most 200 convertible files
Pages per file100
Active bulk jobs per account2
Output retention30 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