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). |
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". |
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. |
save_failed | 500 | PDF serialization failed. Retryable. |
429 | 429 | Per-IP rate limit (100/month) 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
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.
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.
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?
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 — file an issue via the waitlist form's message field with the template attached.
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 budget. For higher quotas, join the waitlist.
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.