Filling a PDF form from JSON and returning a completed file

Extract the field names first, then fill them by name. Why flatten matters, and the two-call loop that survives a form template changing.

Filling a PDF form on Infrai is two calls: POST /v1/pdf/form/extract tells you what fields the document actually has, and POST /v1/pdf/form/fill writes values into them by name. Both take the PDF as a URL, base64 data or a stored pdf_id, so you can point at a template in your own bucket and never move bytes through your process.

Extract first, always. Guessing field names against someone else’s form is how you ship a filled document with three empty boxes.

See the fields

curl -sS -X POST "https://api.infrai.cc/v1/pdf/form/extract" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"pdf": "https://files.example.com/templates/onboarding-w9.pdf"}'
{
  "ok": true,
  "data": {
    "fields": [
      {"name": "applicant_name", "type": "text", "value": ""},
      {"name": "company", "type": "text", "value": ""},
      {"name": "start_date", "type": "text", "value": ""},
      {"name": "accepts_terms", "type": "checkbox", "value": "Off"}
    ]
  }
}

Those names come from the document’s AcroForm definition.

They are whatever the person who made the template typed, which in practice means applicant_name in a form somebody designed carefully and Name1[0] in one exported from a word processor a decade ago — there is no convention to rely on, no normalisation you can apply, and no way to know without asking the document, which is exactly why extract exists and why guessing is the wrong instinct even when the names look obvious.

Cache the field list per template version, not per request. It changes when the template does and not otherwise.

Fill them

curl -sS -X POST "https://api.infrai.cc/v1/pdf/form/fill" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "pdf": "https://files.example.com/templates/onboarding-w9.pdf",
    "fields": {
      "applicant_name": "Ada Lovelace",
      "company": "Northwind Ltd",
      "start_date": "2026-10-01",
      "accepts_terms": "Yes"
    },
    "flatten": true,
    "store": true
  }'
{
  "ok": true,
  "data": {
    "pdf_id": "pdf_2fVc8nRqLmT4xBzY",
    "url": "https://files.infrai.cc/pdf/2fVc8nRqLmT4xBzY.pdf",
    "size_bytes": 184320,
    "page_count": 2,
    "sha256": "9f2c41bd7a9e8c0b5d3e1f7a2b8d4c6e0a9f3b1d5c7e2a4f6b8d0c2e4a6f8b0d",
    "created_at": "2026-09-21T03:45:00Z",
    "retention_days": 7,
    "source": "form.fill"
  }
}

flatten: true is the field that decides whether you’ve produced a document or an editable form. Flattened means the values are drawn into the page and can’t be changed; unflattened means the recipient can edit them, which for a signed agreement is not what you want.

sha256 is worth storing alongside your own record. It’s what lets you prove later that the file you’re holding is the file you generated.

The loop, with validation against the real fields

import os

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})


def form_fields(pdf: str) -> dict[str, str]:
    resp = SESSION.post(f"{API}/v1/pdf/form/extract", json={"pdf": pdf}, timeout=60)
    body = resp.json()
    if not body.get("ok"):
        raise RuntimeError(body["error"]["code"])
    return {f["name"]: f.get("type", "text") for f in body["data"].get("fields", [])}


def fill(pdf: str, values: dict, *, flatten: bool = True) -> dict:
    """Validate against the template's real field names before spending a call.
    A silent no-op on a misspelled field is the failure mode here: the document
    comes back looking fine, with one box empty, and nobody notices until a
    customer does."""
    available = form_fields(pdf)
    unknown = [name for name in values if name not in available]
    if unknown:
        raise ValueError(f"fields not in this template: {unknown}; available: {sorted(available)}")

    missing = [name for name in available if name not in values]
    resp = SESSION.post(
        f"{API}/v1/pdf/form/fill",
        json={"pdf": pdf, "fields": values, "flatten": flatten, "store": True},
        timeout=120,
    )
    resp.raise_for_status()
    data = resp.json()["data"]
    return {"pdf_id": data["pdf_id"], "url": data["url"], "sha256": data["sha256"],
            "pages": data.get("page_count"), "left_blank": missing}


if __name__ == "__main__":
    print(fill("https://files.example.com/templates/onboarding-w9.pdf",
               {"applicant_name": "Ada Lovelace", "company": "Northwind Ltd",
                "start_date": "2026-10-01", "accepts_terms": "Yes"}))

Returning left_blank rather than failing on it is deliberate: leaving optional fields empty is legitimate, and knowing which ones were skipped is useful for the audit line.

Checkboxes are the fiddly part

Text fields take strings. Checkboxes take whatever export value the template defined — often Yes/Off, sometimes On/Off, occasionally something the template author invented.

form/extract shows you the current value, which is usually the “off” state and therefore a hint at the vocabulary. When a checkbox refuses to tick, that’s the first thing to check, and it’s not a platform behaviour — it’s the document.

Field typeWhat to send
texta string
checkboxthe template’s export value, e.g. "Yes"
radio groupthe value of the option to select
dropdownone of the template’s listed options

Where the filled document goes

store: true keeps the result on the platform and gives you a url plus retention_days. For anything you need beyond that window, copy it into your own bucket with PUT /v1/storage/object/put/{bucket}/{key} and store your own path — the same rule as any generated artefact.

That copy is one call on the same credential, which is the practical argument for doing document work here: the template lives in your bucket, the fill happens on the same key, the result goes back into your bucket, and one GET /v1/account/usage prices the whole pipeline rather than a document vendor’s invoice plus a storage vendor’s invoice.

Limitations

This fills AcroForm fields. XFA forms — the dynamic kind some government agencies still publish — aren’t the same format and this isn’t a good fit for them. There’s also no way to add a field that the template doesn’t have, so a document needing a box the original author didn’t include has to be rebuilt rather than filled; POST /v1/pdf/generate from HTML is the path for that.

And filling is not signing. A flattened form is tamper-evident only in the weak sense that editing it is obvious to a careful reader — for a cryptographic signature you want POST /v1/pdf/sign with a certificate.

Anvil and DocRaptor both go further on form workflows, including e-signature orchestration and hosted form UIs, so if collecting data through a form and getting it signed is the product, one of those is the better buy. Fill and extract bill per call at rates live in GET /v1/discovery/pdf.form.fill (verified 2026-09-21), and platform rates drift downward as vendor contracts improve.

References

Browse more pdf developer guides