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.