Batch PDF Generation in Production: Zip PDFs and Upload Straight to S3 with Api2Pdf
Summary. Month-end invoices, statements for every customer, a compliance export, a "download all" button: sooner or later you need hundreds of PDFs at once, delivered as one file to a place you control. This guide builds that pipeline on Api2Pdf with three calls: render each PDF with Headless Chrome, bundle them with the /zip endpoint, and have Api2Pdf PUT the archive straight into your own S3 bucket with useCustomStorage and a presigned URL. You get a complete Python script (tested end to end against a local S3 emulator), a Node.js version, retries, cleanup, Azure Blob and Google Cloud Storage variants, and the gotchas that break presigned uploads.
The pattern
- Render. Call
POST /chrome/pdf/htmlonce per document, in parallel. Each response has aFileUrl(hosted by Api2Pdf for 24 hours) and aResponseId. - Zip. Pass the list of
FileUrls toPOST /zip, with afileNameper entry. Folder paths in the file name (acme/INV-0001.pdf) become folders inside the archive. - Deliver. In the same
/zipcall, setuseCustomStorage: trueand astorageobject with a presignedPUTURL. Api2Pdf uploads the archive directly to your bucket. Your server never downloads or re-uploads it, and you never hand Api2Pdf any cloud credentials. - Clean up. Check the object exists in your bucket, then call
DELETE /file/{responseId}for each intermediate PDF so nothing waits out the 24-hour retention on Api2Pdf's side.
Your app server only moves small JSON payloads. The heavy bytes go from Api2Pdf to your bucket.
Step 1: render each PDF
This is the standard Headless Chrome call. Set inline to false and give every file a predictable name:
curl -X POST "https://v2.api2pdf.com/chrome/pdf/html" \
-H "Authorization: YOUR-API-KEY" \
-H "Content-Type: application/json" \
-d '{
"html": "<h1>Invoice INV-2026-10-0001</h1>",
"fileName": "INV-2026-10-0001.pdf",
"inline": false,
"options": { "printBackground": true }
}'
Response:
{
"Success": true,
"FileUrl": "https://link-to-file-available-for-24-hours",
"MbOut": 0.0213,
"Seconds": 1.2,
"Cost": 0.00025,
"ResponseId": "6e46637a-650d-46d5-af0b-3d7831baccbb",
"Error": null
}
Keep FileUrl (the zip step reads from it) and ResponseId (for cleanup). The URL expires after 24 hours, so run the zip step right after rendering, not in tomorrow's job. Every Headless Chrome option works here: headers and footers, landscape, marginTop, preferCSSPageSize, puppeteerWaitForMethod for pages that load data with JavaScript. See the Headless Chrome options.
Step 2: zip and upload to your bucket in one call
POST /zip takes a files array of { "url", "fileName" } objects, fetches each one, and builds the archive. Two details matter:
/zipreturns the archive as binary by default. Add?outputBinary=falseto get the usual JSON response. You want JSON here, because the bytes are going to your bucket, not back to you.storageis ignored unlessuseCustomStorageistrue.storage.methoddefaults toPUT.storage.extraHTTPHeadersis sent with the upload request, which is how you satisfy headers the presigned URL was signed with.
curl -X POST "https://v2.api2pdf.com/zip?outputBinary=false" \
-H "Authorization: YOUR-API-KEY" \
-H "Content-Type: application/json" \
-d '{
"fileName": "invoices-2026-10.zip",
"files": [
{ "url": "https://FILE-URL-FROM-STEP-1", "fileName": "acme/INV-2026-10-0001.pdf" },
{ "url": "https://ANOTHER-FILE-URL", "fileName": "globex/INV-2026-10-0002.pdf" }
],
"useCustomStorage": true,
"storage": {
"method": "PUT",
"url": "https://your-bucket.s3.amazonaws.com/exports/2026-10/invoices-2026-10.zip?X-Amz-...",
"extraHTTPHeaders": { "Content-Type": "application/zip" }
}
}'
useCustomStorage and storage are not specific to /zip. The same two fields work on the render, LibreOffice, merge and other conversion endpoints, so you can skip the zip entirely and land each PDF in your bucket (see below).
Step 3: the presigned URL
A presigned URL lets Api2Pdf write exactly one object key, with one method, for a few minutes. Nothing else in your account is exposed. With boto3:
import boto3
from botocore.config import Config
s3 = boto3.client("s3", region_name="us-east-1", config=Config(signature_version="s3v4"))
put_url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": "my-exports", "Key": "exports/2026-10/invoices-2026-10.zip",
"ContentType": "application/zip"},
ExpiresIn=900,
)
Three things to get right:
- Sign the Content-Type, then send it. When you pass
ContentTypewith SigV4,content-typebecomes a signed header. The upload must send the exact same value, which is why the/zipcall sets"extraHTTPHeaders": { "Content-Type": "application/zip" }. A mismatch gets a403 SignatureDoesNotMatchfrom S3. Signing it is still worth it, because the object is then stored asapplication/zipinstead ofbinary/octet-stream. - Use SigV4 and the bucket's region. Depending on configuration, boto3 can still produce legacy SigV2 presigned URLs (
AWSAccessKeyId=...&Signature=...).Config(signature_version="s3v4")with the bucket'sregion_namegives you a SigV4 URL that works in every region. - Make the expiry cover the whole job. The URL has to be valid when Api2Pdf finishes building the archive, not just when you send the request. Create it right before the
/zipcall and allow a few minutes.
The complete Python script
This script renders a batch of invoices in parallel, zips them into per-customer folders, uploads the archive to S3, verifies it, and deletes the intermediate PDFs. It calls the REST API with requests, so the payloads are exactly the JSON shown above. Install the two dependencies with pip install boto3 requests and set API2PDF_API_KEY, EXPORT_BUCKET and AWS_REGION.
import os
import time
from concurrent.futures import ThreadPoolExecutor
import boto3
import requests
from botocore.config import Config
API2PDF = os.environ.get("API2PDF_BASE_URL", "https://v2.api2pdf.com")
BUCKET = os.environ["EXPORT_BUCKET"]
session = requests.Session()
session.headers.update({"Authorization": os.environ["API2PDF_API_KEY"]})
s3 = boto3.client("s3", region_name=os.environ["AWS_REGION"], config=Config(signature_version="s3v4"))
class Api2PdfError(Exception):
pass
def api2pdf_post(path, payload, attempts=4):
"""POST to Api2Pdf. Retry network errors, 429 and 5xx with backoff; fail fast on anything else."""
for attempt in range(attempts):
last = attempt == attempts - 1
try:
resp = session.post(f"{API2PDF}{path}", json=payload, timeout=120)
except requests.RequestException:
if last:
raise
else:
body = resp.json() if resp.content else {}
if resp.ok and body.get("Success"):
return body
if last or (resp.status_code != 429 and resp.status_code < 500):
raise Api2PdfError(f"{path} {resp.status_code}: {body.get('Error')}")
time.sleep(2 ** attempt)
def render_invoice(invoice):
result = api2pdf_post("/chrome/pdf/html", {
"html": INVOICE_TEMPLATE.format(**invoice),
"fileName": f"{invoice['number']}.pdf",
"inline": False,
"options": {"printBackground": True, "marginTop": "0.5in", "marginBottom": "0.5in"},
})
return {
"url": result["FileUrl"],
"fileName": f"{invoice['customer_slug']}/{invoice['number']}.pdf",
"responseId": result["ResponseId"],
}
def presigned_put(key, content_type, expires=900):
return s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": key, "ContentType": content_type},
ExpiresIn=expires,
)
def export_batch(invoices, zip_key, workers=8):
# 1. Render every PDF in parallel with a bounded pool.
with ThreadPoolExecutor(max_workers=workers) as pool:
rendered = list(pool.map(render_invoice, invoices))
# 2. Zip them and have Api2Pdf PUT the archive straight into your bucket.
api2pdf_post("/zip?outputBinary=false", {
"fileName": os.path.basename(zip_key),
"inline": False,
"files": [{"url": r["url"], "fileName": r["fileName"]} for r in rendered],
"useCustomStorage": True,
"storage": {
"method": "PUT",
"url": presigned_put(zip_key, "application/zip"),
"extraHTTPHeaders": {"Content-Type": "application/zip"},
},
})
# 3. Confirm the object landed, then delete the intermediate PDFs from Api2Pdf.
head = s3.head_object(Bucket=BUCKET, Key=zip_key)
for r in rendered:
session.delete(f"{API2PDF}/file/{r['responseId']}", timeout=30)
return head["ContentLength"]
INVOICE_TEMPLATE = """<!doctype html>
<html><head><meta charset="utf-8"><style>
body {{ font-family: system-ui, sans-serif; margin: 0; }}
.head {{ display: flex; justify-content: space-between; }}
table {{ width: 100%; border-collapse: collapse; margin-top: 24px; }}
td, th {{ border-bottom: 1px solid #ddd; padding: 8px; text-align: left; }}
</style></head><body>
<div class="head"><h1>Invoice {number}</h1><p>{customer}<br>{date}</p></div>
<table><tr><th>Description</th><th>Amount</th></tr>
<tr><td>{description}</td><td>{amount}</td></tr></table>
</body></html>"""
if __name__ == "__main__":
invoices = [
{"number": f"INV-2026-10-{i:04d}", "customer": f"Customer {i}",
"customer_slug": f"customer-{i}", "date": "2026-10-31",
"description": "October usage", "amount": "$120.00"}
for i in range(1, 51)
]
size = export_batch(invoices, "exports/2026-10/invoices-2026-10.zip")
print(f"uploaded {size} bytes")
What the script does in production terms:
- Bounded concurrency.
ThreadPoolExecutor(max_workers=8)keeps throughput high without opening hundreds of connections at once. Api2Pdf has no rate limits, so tune it to what your own process and network handle well. - Retries only where they help. Network errors,
429and5xxare retried with exponential backoff (1, 2, 4 seconds). Any other failure, such as a malformed payload, fails immediately with the API'sErrormessage instead of being retried four times. - Trust your bucket, not the response. After the upload,
head_objectconfirms the archive exists and returns its size. Only then are the intermediate PDFs deleted, so a failed upload can be retried from the sameFileUrls without re-rendering. - Cleanup.
DELETE /file/{responseId}removes each intermediate PDF from Api2Pdf storage right away instead of after 24 hours. That matters if the documents contain personal data. See Api2Pdf and GDPR.
The same pipeline in Node.js
The official api2pdf npm package (2.1.0) supports useCustomStorage, storage and outputBinary on every call, plus zipGenerate and utilityDelete. Install it with npm install api2pdf @aws-sdk/client-s3 @aws-sdk/s3-request-presigner:
import Api2Pdf from "api2pdf"
import { S3Client, PutObjectCommand, HeadObjectCommand } from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
const a2p = new Api2Pdf(process.env.API2PDF_API_KEY)
// WHEN_REQUIRED stops SDK v3 from signing a checksum of an empty body into the URL
const s3 = new S3Client({ requestChecksumCalculation: "WHEN_REQUIRED" })
const BUCKET = process.env.EXPORT_BUCKET
export async function exportBatch(docs, zipKey) {
// Render every document (cap concurrency with p-limit or similar for big batches)
const rendered = await Promise.all(docs.map(async doc => {
const r = await a2p.chromeHtmlToPdf(doc.html, {
inline: false,
fileName: `${doc.id}.pdf`,
options: { printBackground: true }
})
return { url: r.FileUrl, fileName: `${doc.folder}/${doc.id}.pdf`, responseId: r.ResponseId }
}))
const putUrl = await getSignedUrl(s3,
new PutObjectCommand({ Bucket: BUCKET, Key: zipKey, ContentType: "application/zip" }),
{ expiresIn: 900 })
await a2p.zipGenerate(rendered.map(({ url, fileName }) => ({ url, fileName })), {
fileName: zipKey.split("/").pop(),
inline: false,
outputBinary: false,
useCustomStorage: true,
storage: { method: "PUT", url: putUrl, extraHTTPHeaders: { "Content-Type": "application/zip" } }
})
await s3.send(new HeadObjectCommand({ Bucket: BUCKET, Key: zipKey }))
await Promise.all(rendered.map(r => a2p.utilityDelete(r.responseId)))
}
The S3Client option is the gotcha here. Recent AWS SDK for JavaScript v3 releases add flexible checksums by default, and getSignedUrl for PutObjectCommand then bakes x-amz-checksum-crc32 and x-amz-sdk-checksum-algorithm into the URL, computed for an empty body. The real upload doesn't match that checksum and S3 rejects it. requestChecksumCalculation: "WHEN_REQUIRED" keeps the checksum out of the URL. The client rejects its promise when the API returns Success: false, so wrap the calls in your own retry helper as in the Python version.
Skip the zip: land every PDF in your bucket
If users download documents one at a time, or a downstream system reads them individually, there is no need for an archive. Put useCustomStorage on the render call itself, with one presigned URL per object key:
def render_to_s3(invoice):
key = f"invoices/{invoice['customer_slug']}/{invoice['number']}.pdf"
api2pdf_post("/chrome/pdf/html", {
"html": INVOICE_TEMPLATE.format(**invoice),
"fileName": f"{invoice['number']}.pdf",
"inline": False,
"options": {"printBackground": True},
"useCustomStorage": True,
"storage": {
"method": "PUT",
"url": presigned_put(key, "application/pdf"),
"extraHTTPHeaders": {"Content-Type": "application/pdf"},
},
})
return key
Each PDF goes straight from the renderer to your bucket. Use this when you don't want documents on Api2Pdf storage at all, for example under the policies described in our HIPAA notes. One trade-off: a file uploaded to your storage is not on Api2Pdf storage, so you can't pass it to /zip or /pdfsharp/merge afterwards unless you give Api2Pdf a URL it can read, such as a presigned GET.
Azure Blob Storage and Google Cloud Storage
Anything that accepts an authenticated HTTP PUT to a URL works as a target.
Azure Blob Storage: create a SAS URL for the blob with create/write permission. Azure requires the x-ms-blob-type header on a Put Blob request, so send it with extraHTTPHeaders:
"storage": {
"method": "PUT",
"url": "https://ACCOUNT.blob.core.windows.net/exports/invoices-2026-10.zip?sv=...&sig=...",
"extraHTTPHeaders": {
"x-ms-blob-type": "BlockBlob",
"Content-Type": "application/zip"
}
}
Google Cloud Storage: a V4 signed URL for PUT. If you sign a content type, send the same one:
from datetime import timedelta
from google.cloud import storage
blob = storage.Client().bucket("exports").blob("2026-10/invoices-2026-10.zip")
put_url = blob.generate_signed_url(
version="v4",
expiration=timedelta(minutes=15),
method="PUT",
content_type="application/zip",
)
# then: "storage": {"method": "PUT", "url": put_url,
# "extraHTTPHeaders": {"Content-Type": "application/zip"}}
Sizing large batches
- Split big jobs into several archives. The
/zipendpoint builds the archive in memory, and the API reference warns to be mindful of memory limits. For thousands of documents, write one archive per customer, per day or per few hundred files. Each archive becomes its own S3 object, and a failure only costs you one chunk. - Use V2 XL for the heavy cases. Very large archives or slow, image-heavy renders can go to v2-xl.api2pdf.com: 4 CPU, 8 GB of memory and a 5-minute timeout per request, at 10x the standard price. It's the same API, so in the script you only change
API2PDF_BASE_URL. In Node, pass the base URL as the second constructor argument (new Api2Pdf(key, "https://v2-xl.api2pdf.com")). - Know what you pay for. Every call, including
/zip, is billed on pay-as-you-go pricing: data processed plus compute seconds, with no per-document fee and no rate limits. Each response'sCostfield tells you exactly what that call cost, so log it per batch. - Make re-runs safe. Derive object keys from the batch (
exports/2026-10/invoices-2026-10.zip) instead of timestamps, so a retried job overwrites the same object instead of producing duplicates.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
S3 returns SignatureDoesNotMatch | Content-Type sent on upload differs from the signed one, or the URL is SigV2/wrong region | Send the exact signed Content-Type in storage.extraHTTPHeaders; presign with s3v4 and the bucket's region |
| Upload rejected with a checksum error (Node) | AWS SDK v3 signed a CRC32 of an empty body into the URL | new S3Client({ requestChecksumCalculation: "WHEN_REQUIRED" }) |
AccessDenied / Request has expired | Presigned URL expired before the archive finished | Generate it immediately before the /zip call; raise ExpiresIn |
You got binary bytes instead of JSON from /zip | outputBinary defaults to true on /zip | Call /zip?outputBinary=false |
| The file still went to Api2Pdf storage | storage sent without useCustomStorage | Set "useCustomStorage": true |
| Zip fails to fetch a file | A FileUrl older than 24 hours, or deleted too early | Zip right after rendering; delete intermediates only after the upload is verified |
Azure returns 400 on upload | Missing x-ms-blob-type | Add "x-ms-blob-type": "BlockBlob" to extraHTTPHeaders |
Wrap-up
Batch exports usually turn into a pile of glue code: a render queue, temporary files on disk, an S3 upload, cleanup jobs. With Api2Pdf it comes down to three endpoints. /chrome/pdf/html renders, /zip bundles, and useCustomStorage delivers the result to a bucket you own through a URL that can write one object for a few minutes. Grab an API key at portal.api2pdf.com, point the script at a test bucket, and run it on last month's invoices. The full endpoint reference is in the interactive API docs.



