Guide

Filling a PDF from a Python Lambda with no dependencies to package

A Python Lambda that fills a PDF usually reaches for one of two things: a native library like pypdf, or the requests package wrapping an HTTP call. Both work, and both come with something to package: pypdf means owning appearance streams and checkbox on-state names yourself, and requests means installing a wheel into the deployment zip or a Lambda layer before the first invocation ever runs. Here's a fill that skips both, using only urllib.request and json from the standard library, plus a multipart body built by hand. Nothing to pip install, nothing to vendor, nothing to test against Amazon Linux's binary compatibility before it works in Lambda.

The shape of the job

Say a small insurance brokerage wants certificates of insurance generated on demand. A client hits an API Gateway endpoint with a policy number, the holder's name, and a coverage date; a Lambda function pulls a fixed COI template from S3, fills three AcroForm fields, and writes the result back to S3 for the client to download. The template stays the same from request to request. Only the values change.

Why the standard library is enough here

The friction with PDF work on Lambda tends to be the same friction twice, once for the PDF library and once for the HTTP client. A local fill library that flattens forms typically shells out to pdftk or poppler, and neither one ships in the Lambda runtime, so flattening turns into a container image or a custom layer just to run one binary. An HTTP client like requests isn't in the runtime either; it needs a pip install into the deployment package, and its compiled dependencies carry version quirks against Amazon Linux worth avoiding when a bare urllib.request call does the same job.

A single multipart POST to a hosted fill endpoint moves the PDF work off the Lambda entirely: the appearance streams, the checkbox states, and the flattening all run server-side, and the only local code is one HTTP request assembled from modules already sitting in every Python 3 Lambda runtime.

Building the multipart body by hand

multipart/form-data is a plain wire format: a boundary string repeated between parts, a Content-Disposition header naming each field, and the raw file bytes with no transfer encoding applied. Reproducing it without a library is short.

import uuid


def build_multipart(fields, file_bytes, filename):
    boundary = uuid.uuid4().hex
    parts = []
    for name, value in fields.items():
        parts.append(
            f"--{boundary}\r\n"
            f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
            f"{value}\r\n"
        )
    parts.append(
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="pdf"; filename="{filename}"\r\n'
        f"Content-Type: application/pdf\r\n\r\n"
    )
    header = "".join(parts).encode("utf-8")
    footer = f"\r\n--{boundary}--\r\n".encode("utf-8")
    return header + file_bytes + footer, boundary

Three things worth pointing at. The field carrying the template is named pdf, matching the API's multipart schema exactly, a detail that's easy to get wrong copying from an example that used a different name. fields is a JSON string, not a nested multipart structure, so the whole dict gets json.dumps'd into one text part. And flatten travels as the literal string "true", since the endpoint reads it off a multipart field where there's no such thing as a JSON boolean.

The Lambda handler

The handler pulls the template from S3 once per invocation, fills it, and writes the result back under a fresh key.

import json
import os
import urllib.error
import urllib.request
import uuid

import boto3

BUCKET = os.environ["COI_BUCKET"]
TEMPLATE_KEY = "templates/coi-template.pdf"
API_KEY = os.environ["PDFOPS_API_KEY"]

s3 = boto3.client("s3")


def fill_certificate(holder_name, policy_number, effective_date):
    template = s3.get_object(Bucket=BUCKET, Key=TEMPLATE_KEY)["Body"].read()
    fields = {
        "fields": json.dumps({
            "holder_name": holder_name,
            "policy_number": policy_number,
            "effective_date": effective_date,
        }),
        "flatten": "true",
    }
    body, boundary = build_multipart(fields, template, "coi-template.pdf")

    req = urllib.request.Request(
        "https://pdfops.dev/api/fill-form",
        data=body,
        method="POST",
        headers={
            "X-API-Key": API_KEY,
            "Content-Type": f"multipart/form-data; boundary={boundary}",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=25) as res:
            return res.read()
    except urllib.error.HTTPError as e:
        err = json.loads(e.read())
        raise RuntimeError(
            f"PDFops fill-form failed: {err['error']} ({err['details']})"
        ) from e


def handler(event, context):
    payload = json.loads(event["body"])
    pdf_bytes = fill_certificate(
        payload["holder_name"],
        payload["policy_number"],
        payload["effective_date"],
    )

    key = f"certificates/{uuid.uuid4().hex}.pdf"
    s3.put_object(Bucket=BUCKET, Key=key, Body=pdf_bytes, ContentType="application/pdf")
    url = s3.generate_presigned_url(
        "get_object", Params={"Bucket": BUCKET, "Key": key}, ExpiresIn=3600
    )
    return {"statusCode": 200, "body": json.dumps({"url": url})}

The error handling matters as much as the happy path. A non-2xx response raises urllib.error.HTTPError, and PDFops returns { "error": "<code>", "details": "<explanation>" } on failure, so parsing e.read() into JSON gets you the same error codes the docs list: invalid_api_key if the key in the environment variable got rotated by a later signup, rate_limited with a Retry-After header if the month's quota ran out, or unsupported_field_type if the template's field types changed under you. Surfacing err['error'] in the raised exception means CloudWatch shows which of those happened instead of a bare stack trace from inside urlopen.

Quota, and the shared-IP problem

PDFops allows 100 anonymous requests per IP per month before it needs a key. Lambda functions attached to a VPC often route outbound traffic through a shared NAT gateway, so that IP quota belongs to every function behind the same gateway, not just this one. A key sidesteps the sharing, since usage is tracked per key rather than per IP. POST /api/signup with an email address gets one; the key arrives by email rather than in the response body, so a debug print of the signup response can't leak it into a log. Store it as a Lambda environment variable and pass it as X-API-Key on every request; the free tier covers 250 requests a month, plenty for a brokerage that isn't yet issuing certificates by the hundred.

Try it

The pattern here works for any fixed-template, variable-value PDF: a permit, a receipt, a boarding pass. Sign up for a free key at /docs/signup, check what a template's AcroForm fields are named at /docs/inspect before assuming pdf matches your keys, and watch a key's monthly usage at /docs/usage or the dashboard.