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/v1

The 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.readread documents and their fields
  • documents.writecreate documents and correct fields
  • workflows.runsend 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=approveddocuments.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/uploaddocuments.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.

  • filethe page image; repeat the field once per page (up to 25) for a multi-page document
  • nameoptional document name; the file name is used when you leave it out
  • template_idoptional template id, so the fields come back with your own names
  • asyncset 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 /documentsdocuments.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/dispatchworkflows.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

  • 400the body is not JSON, or a required value is missing or invalid
  • 401no key, or the key is revoked or expired
  • 403the key does not carry the permission this endpoint needs
  • 404no document with that id in this workspace
  • 429per-minute limit reached — wait and retry
  • 500something failed on our side; the response says what