Skip to content

For developers

Fill any PDF form via API, including scans

The Ausfüllpilot API detects the fields in any PDF – fillable, flat or scanned – and fills them from natural-language instructions, JSON data or supporting documents. It runs in the EU.

The dashboard and most of this site are in German; the API reference is in English. Diese Seite auf Deutsch

What the API does

Detect fields

POST /detect_form returns a JSON Schema with the position, type and name of every field – also for scans and PDFs without form fields.

Fill forms

POST /edit fills from instructions, arbitrary JSON (config.data) or documents and returns a PDF plus filledValues.

Bring your schema

Pass a stored schema to skip detection, or map your own data model onto the form with inputSchema.

Async runs and webhooks

Start long forms with /edit_runs and get notified by signed webhooks with retries.

Careful writing

Comb boxes, date formats, checkboxes, tables and signatures; flattened by default or left as editable fields. Digitally signed PDFs are saved incrementally so signatures are preserved.

Review list

Uncertain or shortened values are listed in output.review for a human check before you send anything.

Quickstart

Upload, fill, download. Set FORMFILL_API_URL and a key from the dashboard; test keys start with ff_test_.
cURLbash
export FORMFILL_API_URL="https://api.ausfuellpilot.de"
export FORMFILL_API_KEY="ff_test_…"

# 1. Upload the PDF
FILE_ID=$(curl -sS "$FORMFILL_API_URL/files/upload" \
  -H "Authorization: Bearer $FORMFILL_API_KEY" \
  -F file=@form.pdf | jq -r .id)

# 2. Fill it (synchronous; for long forms use POST /edit_runs and GET /edit_runs/:id)
curl -sS "$FORMFILL_API_URL/edit" \
  -H "Authorization: Bearer $FORMFILL_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{
    "file": { "id": "'"$FILE_ID"'" },
    "config": {
      "instructions": "Fill the form for Erika Mustermann, born 12.08.1990 in Musterstadt, living at Musterstraße 1, 12345 Musterstadt. Sign with her name and today's date.",
      "advancedOptions": { "flattenPdf": true }
    }
  }' > run.json

# 3. Download the result (link valid for 15 minutes)
curl -sS -o filled.pdf "$(jq -r .output.editedFile.presignedUrl run.json)"
TypeScript (Node 20+)ts
import { readFile, writeFile } from "node:fs/promises";

const API = process.env.FORMFILL_API_URL!;
const auth = { Authorization: `Bearer ${process.env.FORMFILL_API_KEY}` };

// 1. Upload the PDF
const form = new FormData();
form.append("file", new Blob([await readFile("form.pdf")], { type: "application/pdf" }), "form.pdf");
const file = await (await fetch(`${API}/files/upload`, { method: "POST", headers: auth, body: form })).json();

// 2. Fill it
const res = await fetch(`${API}/edit`, {
  method: "POST",
  headers: { ...auth, "Content-Type": "application/json", "Idempotency-Key": crypto.randomUUID() },
  body: JSON.stringify({
    file: { id: file.id },
    config: { instructions: "Fill the form for Erika Mustermann, born 12.08.1990 in Musterstadt, living at Musterstraße 1, 12345 Musterstadt. Sign with her name and today's date.", advancedOptions: { flattenPdf: true } },
  }),
});
const run = await res.json();
if (!res.ok) throw new Error(`${run.code}: ${run.message}`);
if (run.status === "FAILED") throw new Error(`${run.failureReason}: ${run.failureMessage}`);
console.log(run.output.filledValues, run.output.review);

// 3. Download (link valid for 15 minutes)
const pdf = await fetch(run.output.editedFile.presignedUrl);
await writeFile("filled.pdf", Buffer.from(await pdf.arrayBuffer()));
Python (requests)py
import os, uuid, requests

API = os.environ["FORMFILL_API_URL"]
AUTH = {"Authorization": f"Bearer {os.environ['FORMFILL_API_KEY']}"}

# 1. Upload the PDF
with open("form.pdf", "rb") as f:
    file = requests.post(f"{API}/files/upload", headers=AUTH,
                         files={"file": ("form.pdf", f, "application/pdf")}, timeout=60).json()

# 2. Fill it
res = requests.post(
    f"{API}/edit",
    headers={**AUTH, "Idempotency-Key": str(uuid.uuid4())},
    json={
        "file": {"id": file["id"]},
        "config": {
            "instructions": "Fill the form for Erika Mustermann, born 12.08.1990 in Musterstadt, living at Musterstraße 1, 12345 Musterstadt. Sign with her name and today's date.",
            "advancedOptions": {"flattenPdf": True},
        },
    },
    timeout=300,
)
res.raise_for_status()
run = res.json()
if run["status"] == "FAILED":
    raise RuntimeError(f"{run['failureReason']}: {run['failureMessage']}")

# 3. Download (link valid for 15 minutes)
pdf = requests.get(run["output"]["editedFile"]["presignedUrl"], timeout=60)
with open("filled.pdf", "wb") as f:
    f.write(pdf.content)

Signed webhooks

Configure webhook endpoints per organization in the dashboard. Events:

  • edit_run.processed
  • edit_run.failed
  • form_detection_run.processed
  • form_detection_run.failed
  • batch.completed

Every delivery carries x-formfill-signature: t=<unix>,v1=<hex> – an HMAC-SHA256 of "<t>.<body>" with your webhook secret – plus x-formfill-event-id and x-formfill-event-type. Failed deliveries are retried with exponential backoff.

Verify the signature – TypeScriptts
import crypto from "node:crypto";

/** Verify x-formfill-signature: t=<unix>,v1=<hex hmac_sha256(secret, "<t>.<body>")> */
export function verifyFormfillSignature(rawBody: string, header: string, secret: string, toleranceSec = 300): boolean {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2) as [string, string]));
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(parts.v1 ?? "", "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
Verify the signature – Pythonpy
import hashlib, hmac, time

def verify_formfill_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    t = int(parts.get("t", "0"))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Idempotency, limits and keys

Idempotency-Key
Send a unique Idempotency-Key with POST requests. Retries with the same key within 24 hours return the original response – no duplicate run, no duplicate charge.
Rate limits
Up to 600 requests per minute on the API plan; above that you get 429 with Retry-After.
Test keys
ff_test_…: free, up to 50 pages per day, outputs carry a “TEST” watermark.
Live keys
ff_live_…: billed per page. Keys are stored only as hashes and shown once.

Per-page pricing

Billed monthly by page with graduated tiers; the first 100 pages of each kind are free every month. Prices exclude VAT.
API price tiers
Pages per monthFill incl. detectionFill with schemaDetect only
1 – 100freefreefree
101 – 10,0000,03 €0,01 €0,02 €
10,001 – 100,0000,025 €0,008 €0,015 €
from 100,0010,02 €0,006 €0,01 €

Example: 2,000 filled pages per month cost 57,00 €, 20,000 pages 547,00 € (excluding VAT). With a supplied schema or saved template, detection is skipped and the lower “fill with schema” rate applies. Amounts in euros.

Hosting and data protection

  • Files and results are stored in Frankfurt am Main (EU) and deleted automatically after the configured retention; download links expire after 15 minutes.
  • AI requests are processed exclusively in EU data centres (Microsoft Azure, EU region) on every plan, via OpenRouter's EU endpoint, with zero data retention and no training. OpenRouter itself is a US company; account and billing metadata may be processed in the US – details on the security page (German).
  • A data processing agreement under Art. 28 GDPR (in German) is available for all business customers.

Loslegen

Fill your first form via API