Endpoint reference
POST /api/fill-form
Fill AcroForm fields in a PDF you upload. Returns the filled PDF as application/pdf.
Request
Method: POST. URL: https://pdfops.dev/api/fill-form. Body: multipart/form-data.
| Field | Type | Required | Description |
|---|---|---|---|
pdf | File (PDF binary) | Yes | The PDF template containing AcroForm fields. Maximum size: ~10 MB. |
fields | String (JSON) | Yes | JSON object mapping field name → value. Field names must match the AcroForm field names in the PDF exactly. Values are strings (see field-type mapping below). |
flatten | "true" | "false" | No (default "false") | When "true", bakes the filled values into the page content and removes the AcroForm, so fields are no longer interactive or enumerable. Flattening prevents casual re-editing in viewers; it is not tamper evidence, a digital signature, access control, or regulatory compliance. |
Field-type mapping
The endpoint dispatches by AcroForm field type. Each type accepts a specific shape of value in the fields JSON:
| AcroForm type | JSON value | Example |
|---|---|---|
| PDFTextField | string | "customer_name": "Acme Co" |
| PDFCheckBox | "true" or "false" | "agreed_to_terms": "true" |
| PDFDropdown | string (must match an option) | "country": "United States" |
| PDFRadioGroup | string (must match an option) | "plan": "Pro" |
| PDFOptionList | string (must match an option) | "region": "us-east-1" |
| PDFButton | Not supported. Returns 400 unsupported_field_type. | |
| PDFSignature | Not supported. Returns 400 unsupported_field_type. | |
Response
On success: 200 OK, Content-Type: application/pdf, body is the filled PDF binary.
On any failure: 4xx or 500, Content-Type: application/json, body is { "error": "<code>", "details": "<explanation>" }.
Error codes
| Error code | Status | Trigger |
|---|---|---|
invalid_multipart | 400 | Request body could not be parsed as multipart/form-data. |
missing_pdf | 400 | No pdf field present or it wasn't a File. |
missing_fields | 400 | No fields field present. |
invalid_fields | 400 | fields JSON didn't decode to an object (null, array, or scalar). |
invalid_fields_json | 400 | fields wasn't valid JSON. |
invalid_pdf | 400 | The uploaded PDF couldn't be parsed (corrupt, encrypted, not a PDF). |
no_form | 400 | The PDF has no AcroForm (no fillable fields). |
unknown_field | 400 | A field name in the JSON doesn't exist in the PDF's AcroForm. |
invalid_field_value | 400 | A field value was non-string. All values must be strings. |
invalid_checkbox_value | 400 | A checkbox value was not "true" or "false". |
invalid_flatten_value | 400 | flatten was sent but was not "true" or "false". |
unsupported_field_type | 400 | Field is a PDFButton or PDFSignature (not supported). |
fill_failed | 400 | pdf-lib's field-set raised on the value (e.g. dropdown option not in the list). |
appearance_failed | 500 | Appearance-stream regeneration failed. Retryable. |
flatten_failed | 500 | Flattening raised after a successful fill (unusual field/widget structure). Retry without flatten, or send the template to contact. |
save_failed | 500 | PDF serialization failed. Retryable. |
invalid_api_key | 401 | An X-API-Key header was sent but the key is unknown or revoked. No fallback to the anonymous IP cap — get a fresh key at /pricing. |
rate_limited (anonymous) | 429 | No X-API-Key header and the per-IP limit (100/month) is exhausted. Retry-After header gives seconds until next calendar month. |
rate_limited (keyed) | 429 | Your key's tier quota (Free 250 / Indie 4,000 / Pro 25,000 per month) is exhausted. Retry-After header gives seconds until next calendar month. |
Example
Don't have a fillable PDF? Grab the sample invoice-template.pdf with customer_name + total fields.
curl -X POST https://pdfops.dev/api/fill-form \
-F "pdf=@invoice-template.pdf" \
-F 'fields={"customer_name":"Acme Co","total":"$1,250.00"}' \
-o filled.pdf \
-H "X-API-Key: pdfops_live_..." # optional — raises your quota to your tier
Walkthrough examples in the blog: invoice PDFs from Stripe webhooks (Cloudflare Workers), auto-filling a W-9 from a contractor-onboarding webhook (Vercel Edge). Broader patterns: invoice PDFs at scale and contract PDFs from onboarding flows. Filling is often the middle of a chain — /api/inspect lists the template's fields first, and /api/merge combines the filled PDF with other documents after.
Code examples
The canonical curl above translated to common stacks. All use stdlib + one widely-adopted HTTP client (httpx for Python); no PDFops-specific SDK needed because the API is plain multipart over HTTP.
Have an API key? Add an X-API-Key header to any of these requests — optional, and it raises your quota from 100/IP/month to your tier's.
Want the longer version — including the honest comparison with native libraries and when to use them instead? See the language guides: fill a PDF in Python, in Node.js, and in JavaScript (browser + server).
Node.js (22+) — built-in fetch + FormData
import { readFile, writeFile } from 'node:fs/promises';
const pdf = await readFile('invoice-template.pdf');
const fd = new FormData();
fd.append('pdf', new Blob([pdf], { type: 'application/pdf' }), 'invoice-template.pdf');
fd.append('fields', JSON.stringify({
customer_name: 'Acme Co',
total: '$1,250.00',
}));
const res = await fetch('https://pdfops.dev/api/fill-form', {
method: 'POST',
body: fd,
});
if (!res.ok) {
const err = await res.json();
throw new Error(`PDFops fill-form failed: ${err.error} — ${err.details}`);
}
await writeFile('filled.pdf', Buffer.from(await res.arrayBuffer()));
Python (3.10+) — httpx
import json
import httpx
with open('invoice-template.pdf', 'rb') as f:
pdf_bytes = f.read()
files = {'pdf': ('invoice-template.pdf', pdf_bytes, 'application/pdf')}
data = {'fields': json.dumps({
'customer_name': 'Acme Co',
'total': '$1,250.00',
})}
r = httpx.post('https://pdfops.dev/api/fill-form', files=files, data=data, timeout=30)
if r.status_code != 200:
err = r.json()
raise RuntimeError(f"PDFops fill-form failed: {err['error']} — {err['details']}")
with open('filled.pdf', 'wb') as f:
f.write(r.content)
Go (1.21+) — net/http + mime/multipart
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
)
func fillForm() error {
pdfBytes, err := os.ReadFile("invoice-template.pdf")
if err != nil {
return err
}
var body bytes.Buffer
w := multipart.NewWriter(&body)
part, err := w.CreateFormFile("pdf", "invoice-template.pdf")
if err != nil {
return err
}
if _, err := part.Write(pdfBytes); err != nil {
return err
}
fields, _ := json.Marshal(map[string]string{
"customer_name": "Acme Co",
"total": "$1,250.00",
})
if err := w.WriteField("fields", string(fields)); err != nil {
return err
}
w.Close()
req, err := http.NewRequest("POST", "https://pdfops.dev/api/fill-form", &body)
if err != nil {
return err
}
req.Header.Set("Content-Type", w.FormDataContentType())
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
var apiErr struct {
Error string `json:"error"`
Details string `json:"details"`
}
json.NewDecoder(resp.Body).Decode(&apiErr)
return fmt.Errorf("fill-form failed: %s — %s", apiErr.Error, apiErr.Details)
}
out, err := os.Create("filled.pdf")
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
Migration recipes
If you're moving from a hosted HTML→PDF API, the shape of the change is similar across all three incumbents: take your HTML template, re-author it as a PDF with AcroForm fields, then swap the SDK call for the fill-form curl. The template work is one-time per template; the per-request shape simplifies.
From DocRaptor
DocRaptor's API accepts document_content (HTML/XML) or document_url and renders the result via PrinceXML. PDFops accepts an existing AcroForm PDF and a JSON of field values. Migration: (1) Take the HTML template you were sending to DocRaptor. (2) Open it in any PDF editor (Acrobat, Mac Preview, LibreOffice Draw, or run it through weasyprint as a one-shot conversion), then save as PDF. (3) Add AcroForm fields at the positions where DocRaptor was substituting your data; name them after the keys you'll send. (4) Replace the DocRaptor SDK call with a multipart POST to /api/fill-form. The HTML template work is one-time; the request shape is now simpler (no per-request Prince render). Side-by-side at /vs/docraptor — includes the dead-changelog signal worth weighing.
From PDFShift
PDFShift renders HTML to PDF via Chromium with credit-system pricing. PDFops takes an existing AcroForm PDF + data. Migration mirrors DocRaptor: take your HTML template, save as PDF, add AcroForm fields, swap the API call. PDFShift's strengths (custom fonts, full CSS, modern HTML, image embeds) all transfer to the template-authoring step — you bake them in once at the PDF level instead of paying for them on every render. Once the template is set, you don't need Chromium per request anymore. Side-by-side at /vs/pdfshift.
From PDFMonkey
PDFMonkey uses HTML + Liquid templating with a dashboard editor. PDFops doesn't run a templating engine. Migration: (1) Rebuild each template as a PDF (Acrobat / Mac Preview / LibreOffice Draw) with AcroForm fields where Liquid variables were. (2) Move Liquid-side computed values — loops over line items, conditionals, date formatters, the barcode filter, Chart.js graphs — into your calling code's pre-processing. (3) Replace the PDFMonkey API call with the fill-form curl. If your templates lean heavily on Liquid features, PDFMonkey may still fit better — the honest framing on /vs/pdfmonkey spells out when to stay vs migrate.
Behavioral notes
- Appearance streams regenerate. Every filled field gets a freshly-generated
/AP/Nstream using an embedded Helvetica, so the PDF renders correctly across all major viewers (Acrobat, Preview, Chrome). For PDFs that already shipped baked appearances, the regeneration is visually equivalent. - Fields in the PDF that you don't send are left unchanged. Only the field names you include in the
fieldsJSON are touched. - All values must be strings. Numbers, booleans, arrays, objects in the JSON return
400 invalid_field_value. Format on your side:String(amount.toFixed(2)),String(isAgreed), etc. - Encrypted PDFs are not supported. Decrypt server-side before upload.
FAQ
How do I find the field names in my PDF?
Fastest: drop the PDF into the free form-field inspector — it lists every field name, type, and the ready-to-fill JSON. Otherwise, open the PDF in Acrobat (Tools → Prepare Form), Mac Preview, or LibreOffice Draw; field names appear when you hover or click each field. From the command line, pdftk dump_data_fields lists all AcroForm field names. PDF authoring tools name fields automatically (e.g. Text1, CheckBox2); rename them to something predictable in code (customer_name, total) before deploying.
Why is my filled PDF rendering as empty fields in some viewers?
Older versions of /api/fill-form returned PDFs with field values set in the data layer but no /AP (appearance stream), which some viewers render as empty boxes. As of 2026-05-18 the endpoint regenerates appearance streams using an embedded Helvetica, so any AcroForm whose fields reference /Helvetica in their DA renders correctly. If you still see empty fields, the PDF's DA may reference a font we don't have embedded — reply to your key email or write hello@pdfops.dev with the template attached.
Does flatten=true make the PDF tamper-proof or compliant?
No. Flattening bakes the filled values into the page content and removes the AcroForm, so the fields can no longer be clicked, edited, or enumerated in a viewer — the document behaves like a printed page. That prevents casual re-editing and is what most workflows mean by "locking" a filled form. It does not provide tamper evidence (a determined editor can still alter the page content), digital signatures, access control, retention policy, or regulatory compliance. If you need provable integrity, apply a digital signature to the flattened output with a signing tool. Flattened output keeps the byte-determinism contract: the same template and values produce the same bytes.
What field types are supported?
Five AcroForm field types: PDFTextField, PDFCheckBox, PDFDropdown, PDFRadioGroup, PDFOptionList. PDFButton and PDFSignature are intentionally unsupported (see error unsupported_field_type).
How do I send a checkbox value?
As a string: "true" to check, "false" to uncheck. Boolean JSON values aren't accepted.
Can I create new fields?
No — /api/fill-form fills existing AcroForm fields only. Author your PDF template with the fields you need first.
What happens if I exceed the rate limit?
The middleware returns 429 Too Many Requests with a Retry-After header. 429s don't consume quota. If you're hitting the anonymous 100/IP/month cap, get a free API key at /pricing — 250 requests/month, with paid Indie (4,000) and Pro (25,000) tiers above it.
Common error: unknown_field
You sent a fields key that doesn't exist as an AcroForm field name in the uploaded PDF. Field names are case-sensitive and must match exactly. To audit what's actually in your PDF: open it in Acrobat (Tools → Prepare Form), in Mac Preview (the fields panel on the right), or run pdftk yourfile.pdf dump_data_fields | grep ^FieldName from the command line. Typical causes: typos (customer_Name vs customer_name), emoji in PDF authoring-tool defaults ("First Name 🚀"), or sending fields from a different template version. The endpoint refuses to silently skip unknown fields because that hides real wiring bugs — safer to fail loudly than fill the wrong PDF.
Common error: appearance_failed
pdf-lib couldn't generate the appearance stream for one of your filled fields, usually because the field's DA (default appearance) references a font that isn't embedded in the AcroForm's DR (default resources). PDFops auto-embeds Helvetica before regenerating appearances, so this should be rare; when it fires, it usually means the template was authored in a tool that wrote a non-standard font reference (e.g., a custom corporate font with no fallback). Workaround: re-author the template in Acrobat or LibreOffice Draw, which will set the DA to /Helvetica by default. Retryable as 500 if the cause is transient.
Common error: no_form
The uploaded PDF doesn't have an AcroForm dictionary — it's a PDF you can view + print but not fill. To add fillable fields: open in Acrobat (Tools → Prepare Form → Add fields manually), in LibreOffice Draw (Form Controls toolbar), or use pdftk to add a form layer. If you want a working sample to start from, grab the sample invoice-template.pdf — it has customer_name + total fields you can fill via the curl example above. If you're getting no_form on a PDF you authored with form fields, double-check the save format: "Save as PDF" in some tools drops the form layer; use "Save as PDF/A-2" or explicit "Preserve AcroForm" options.
Common error: invalid_field_value
One of your fields values isn't a string — the API requires every value to be a JSON string regardless of underlying type. Common offenders: numbers ("total": 1250.0 → should be "$1,250.00" after formatting), booleans ("agreed": true → should be "true" for checkboxes), and arrays/objects. Format server-side before serializing: String(amount.toFixed(2)) for currency, String(date.toISOString().slice(0,10)) for ISO dates, String(boolean) for booleans. The string-only constraint is intentional — AcroForm field values are themselves strings in the PDF spec, and accepting native JSON types would force the endpoint into stringify-with-locale-rules territory.