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_typestring❌html (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
optionsobject❌PDF options applied to every file (see below)
email_notificationboolean❌false skips the "bulk job finished" email for this job (in multipart, send the string false). Omit it to follow your account's email settings; true cannot turn the email on if job emails are switched off in Settings

✅* 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 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.

To stop a job, call POST /jobs/{job_id}/cancel. A queued job is cancelled with nothing charged; a job already converting stops before its next file, keeps the PDFs it made (charged as usual) and ends as cancelled.

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 accountBuy credits or subscribe
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_CREDITSAllowance and credits cannot cover a PDFBuy credits or upgrade your plan
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_CREDITSAllowance and credits 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, charged to your subscription allowance first, then free credits, then one-off credits. Files that fail or are skipped are not charged.

Before rendering starts, the whole job is checked against your balance: if your subscription allowance, free credits and one-off credits together 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​