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

  1. Render. Call POST /chrome/pdf/html once per document, in parallel. Each response has a FileUrl (hosted by Api2Pdf for 24 hours) and a ResponseId.
  2. Zip. Pass the list of FileUrls to POST /zip, with a fileName per entry. Folder paths in the file name (acme/INV-0001.pdf) become folders inside the archive.
  3. Deliver. In the same /zip call, set useCustomStorage: true and a storage object with a presigned PUT URL. Api2Pdf uploads the archive directly to your bucket. Your server never downloads or re-uploads it, and you never hand Api2Pdf any cloud credentials.
  4. 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:

  • /zip returns the archive as binary by default. Add ?outputBinary=false to get the usual JSON response. You want JSON here, because the bytes are going to your bucket, not back to you.
  • storage is ignored unless useCustomStorage is true. storage.method defaults to PUT. storage.extraHTTPHeaders is 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 ContentType with SigV4, content-type becomes a signed header. The upload must send the exact same value, which is why the /zip call sets "extraHTTPHeaders": { "Content-Type": "application/zip" }. A mismatch gets a 403 SignatureDoesNotMatch from S3. Signing it is still worth it, because the object is then stored as application/zip instead of binary/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's region_name gives 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 /zip call 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, 429 and 5xx are retried with exponential backoff (1, 2, 4 seconds). Any other failure, such as a malformed payload, fails immediately with the API's Error message instead of being retried four times.
  • Trust your bucket, not the response. After the upload, head_object confirms the archive exists and returns its size. Only then are the intermediate PDFs deleted, so a failed upload can be retried from the same FileUrls 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 /zip endpoint 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's Cost field 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

SymptomLikely causeFix
S3 returns SignatureDoesNotMatchContent-Type sent on upload differs from the signed one, or the URL is SigV2/wrong regionSend 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 URLnew S3Client({ requestChecksumCalculation: "WHEN_REQUIRED" })
AccessDenied / Request has expiredPresigned URL expired before the archive finishedGenerate it immediately before the /zip call; raise ExpiresIn
You got binary bytes instead of JSON from /zipoutputBinary defaults to true on /zipCall /zip?outputBinary=false
The file still went to Api2Pdf storagestorage sent without useCustomStorageSet "useCustomStorage": true
Zip fails to fetch a fileA FileUrl older than 24 hours, or deleted too earlyZip right after rendering; delete intermediates only after the upload is verified
Azure returns 400 on uploadMissing x-ms-blob-typeAdd "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.