For developers
Fill any PDF form via API, including scans
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
FORMFILL_API_URL and a key from the dashboard; test keys start with ff_test_.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)"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()));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.processededit_run.failedform_detection_run.processedform_detection_run.failedbatch.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.
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);
}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
| Pages per month | Fill incl. detection | Fill with schema | Detect only |
|---|---|---|---|
| 1 – 100 | free | free | free |
| 101 – 10,000 | 0,03 € | 0,01 € | 0,02 € |
| 10,001 – 100,000 | 0,025 € | 0,008 € | 0,015 € |
| from 100,001 | 0,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.