Developers
Formalini API
Read documents and their extracted fields, push documents in from your own systems, correct values, and receive signed webhooks when something changes. Every example below is shown in curl, JavaScript, Python, PHP and C#.
Base URL
All endpoints live under this base URL. Send and receive JSON.
https://api.formalini.com/api/public/v1The short api.formalini.com address is live; it is the address shown above.
Authentication
Create an API key in the app under Integrations, then send it as a bearer token on every request. Keys belong to one workspace and only ever see that workspace's data.
documents.read— read documents and their fieldsdocuments.write— create documents and correct fieldsworkflows.run— send events to your webhooks on demand
Each key has a per-minute request limit (60 by default). Responses carry X-RateLimit-Limit and X-RateLimit-Remaining; over the limit you get 429 with Retry-After.
List documents
GET /documents?limit=25&status=approved — documents.read
Newest first. limit takes 1–100 (25 by default) and status filters by queued, processing, review, approved, exported or failed.
curl -X GET "https://api.formalini.com/api/public/v1/documents?limit=25&status=approved" \
-H "Authorization: Bearer pf_live_your_key"{
"documents": [
{
"id": "b0f1c8d2-5a71-4c0e-9f2a-1d4b6e8c3a90",
"name": "Job sheet 4821.jpg",
"status": "approved",
"average_confidence": 0.94,
"source": "upload",
"created_at": "2026-09-11T09:12:04.000Z",
"fields": [
{ "name": "engineer", "value": "M. Byrne", "type": "text",
"confidence": 0.97, "page": 1, "valid": true }
]
}
]
}Corrected values replace the original reading, so value is always the value you should use.
Get one document
GET /documents/{id} — documents.read
Returns the document with its category, supplier, amount, date and every field.
curl -X GET "https://api.formalini.com/api/public/v1/documents/b0f1c8d2-5a71-4c0e-9f2a-1d4b6e8c3a90" \
-H "Authorization: Bearer pf_live_your_key"Upload a file and get its data
POST /documents/upload — documents.write
Send a page image and get the structured data back in the same response. The document appears in the workbench inbox like any other scan, spends one AI credit per page, and fires your webhooks when it is done.
file— the page image; repeat the field once per page (up to 25) for a multi-page documentname— optional document name; the file name is used when you leave it outtemplate_id— optional template id, so the fields come back with your own namesasync— set to true to get a 202 straight away and poll GET /documents/{id} for the result
curl -X POST "https://api.formalini.com/api/public/v1/documents/upload" \
-H "Authorization: Bearer pf_live_your_key" \
-F "file=@invoice-page-1.jpg" \
-F "file=@invoice-page-2.jpg" \
-F "name=Invoice 2026-0041"{
"document": {
"id": "8c2a1f76-4d33-4a1e-8f10-72b9e5c4a118",
"name": "Invoice 2026-0041",
"status": "review",
"average_confidence": 0.93,
"vendor": "Northbridge Supplies",
"amount": 1249.5,
"currency": "EUR",
"document_date": "2026-09-14",
"pages": 2,
"credits_spent": 2,
"fields": [
{ "name": "invoice_number", "value": "2026-0041", "type": "string",
"confidence": 0.98, "page": 1, "valid": true }
]
}
}JPEG, PNG, WebP and HEIC are accepted, up to 100 MB a page. Reading a page takes a few seconds, so allow a generous timeout or use async=true. A 402 means the workspace is out of credits, storage or its daily allowance, and the response says which.
PDFs are not accepted here yet: render each page to a JPEG or PNG and send them as repeated file fields. Uploading a PDF in the app still works, because the pages are rendered in your browser.
Create a document
POST /documents — documents.write
Push a document you already have data for. name is required; fields is optional and holds up to 400 entries. A document with fields lands in review, one without lands in the queue.
curl -X POST "https://api.formalini.com/api/public/v1/documents" \
-H "Authorization: Bearer pf_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"name": "Invoice 2026-0041.pdf",
"category": "invoice",
"vendor": "Northbridge Supplies",
"amount": 1249.5,
"document_date": "2026-09-14",
"fields": [
{
"name": "invoice_number",
"value": "2026-0041",
"type": "text",
"confidence": 0.99,
"page": 1
},
{
"name": "total",
"value": "1249.50",
"type": "number",
"confidence": 0.96,
"page": 1
}
]
}'{
"document": {
"id": "8c2a…",
"name": "Invoice 2026-0041.pdf",
"status": "review",
"created_at": "2026-09-18T10:02:11.000Z",
"fields": 2
}
}Update a document
PATCH /documents/{id} — documents.write
Change the status or correct field values by name. Setting the status to approved, exported or review also fires the matching webhook.
curl -X PATCH "https://api.formalini.com/api/public/v1/documents/b0f1c8d2-5a71-4c0e-9f2a-1d4b6e8c3a90" \
-H "Authorization: Bearer pf_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"status": "approved",
"fields": [
{
"name": "total",
"value": "1249.50"
}
]
}'{ "ok": true, "updated_fields": 1 }Trigger your webhooks
POST /workflows/dispatch — workflows.run
Sends an event to every active webhook subscribed to it, so another system can be kicked off on demand. Allowed events: document.processed, document.needs_review, document.approved, document.exported.
curl -X POST "https://api.formalini.com/api/public/v1/workflows/dispatch" \
-H "Authorization: Bearer pf_live_your_key" \
-H "Content-Type: application/json" \
-d '{
"event": "document.approved",
"document_id": "b0f1c8d2-5a71-4c0e-9f2a-1d4b6e8c3a90",
"payload": {
"target": "erp"
}
}'{ "ok": true, "event": "document.approved", "document_id": "b0f1…" }Webhooks
Add a webhook URL in the app and pick the events you want. Each delivery is a POST with a JSON body and a signature header, signed with your webhook secret.
POST https://your-app.example.com/hooks/formalini
Content-Type: application/json
X-Paperflow-Signature: sha256=<hex hmac of the raw body>
{ "event": "document.processed", "document_id": "b0f1…", "payload": { … } }Failed deliveries are retried up to five times with a growing delay, and you can replay any delivery from the app.
Verify the signature against the raw request body before trusting a delivery:
// Node.js
import crypto from "node:crypto";
function verify(rawBody, header, secret) {
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
const got = (header || "").replace(/^sha256=/, "");
return got.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
# Python
import hmac, hashlib
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, (header or "").removeprefix("sha256="))
// PHP
$expected = hash_hmac('sha256', $rawBody, $secret);
$got = str_replace('sha256=', '', $_SERVER['HTTP_X_PAPERFLOW_SIGNATURE'] ?? '');
$ok = hash_equals($expected, $got);
// C#
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash(rawBodyBytes)).ToLowerInvariant();
var ok = CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(expected),
Encoding.UTF8.GetBytes(header.Replace("sha256=", "")));Errors
400— the body is not JSON, or a required value is missing or invalid401— no key, or the key is revoked or expired403— the key does not carry the permission this endpoint needs404— no document with that id in this workspace429— per-minute limit reached — wait and retry500— something failed on our side; the response says what