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.

FieldTypeRequiredDescription
pdfFile (PDF binary)YesThe PDF template containing AcroForm fields. Maximum size: ~10 MB.
fieldsString (JSON)YesJSON 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 typeJSON valueExample
PDFTextFieldstring"customer_name": "Acme Co"
PDFCheckBox"true" or "false""agreed_to_terms": "true"
PDFDropdownstring (must match an option)"country": "United States"
PDFRadioGroupstring (must match an option)"plan": "Pro"
PDFOptionListstring (must match an option)"region": "us-east-1"
PDFButtonNot supported. Returns 400 unsupported_field_type.
PDFSignatureNot 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 codeStatusTrigger
invalid_multipart400Request body could not be parsed as multipart/form-data.
missing_pdf400No pdf field present or it wasn't a File.
missing_fields400No fields field present.
invalid_fields400fields JSON didn't decode to an object (null, array, or scalar).
invalid_fields_json400fields wasn't valid JSON.
invalid_pdf400The uploaded PDF couldn't be parsed (corrupt, encrypted, not a PDF).
no_form400The PDF has no AcroForm (no fillable fields).
unknown_field400A field name in the JSON doesn't exist in the PDF's AcroForm.
invalid_field_value400A field value was non-string. All values must be strings.
invalid_checkbox_value400A checkbox value was not "true" or "false".
unsupported_field_type400Field is a PDFButton or PDFSignature (not supported).
fill_failed400pdf-lib's field-set raised on the value (e.g. dropdown option not in the list).
appearance_failed500Appearance-stream regeneration failed. Retryable.
save_failed500PDF serialization failed. Retryable.
429429Per-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

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.