External API
Finpilo provides an external API for integrating other systems — accounting software, ERP platforms, or custom internal tools — with your workspace.
This page is a reference for developers or integration consultants building a connection between Finpilo and another system. To connect a conversational AI assistant (such as Claude) rather than a system, see AI Assistants — it uses the same capabilities through a login instead of an API key.
What you can do through the API
- Sync dimension values — partners, cost centres, VAT codes, posting accounts — between Finpilo and your accounting system, in both directions. A partner is a dimension like any other.
- Read, search, and total up documents — filter by any field, and get sums, counts or averages grouped by partner, cost centre or period, answered from your data.
- Upload documents and edit their fields, line items, and dimension values.
- Move documents through their statuses.
- Download the original file of any document.
What an API key can never do
Some things belong to a person with a login, not to a key, and no permission grants them:
- Responding to approvals. An approval is assigned to a named person.
- Editing configuration — lifecycles, workflows, integrations, views.
- Creating or deleting entities and managing users. A key with master-data write permission can update an existing entity's details.
An AI assistant connected through a person's login can do these, within that person's role — see AI Assistants.
Choosing a route — read this before you quote
There are three ways to move data, and this page documents only the first. Picking the wrong one costs days of work Finpilo already does.
1. Your system calls Finpilo (this page). You write a client, hold an API key, and push master data in or pull documents out on your own schedule. Right when your system is the one with a scheduler, or when you need to react to something on your side.
2. Your system posts to a workflow. Finpilo gives you an inbound URL and a secret key, and you POST a payload — a whole partner register in one call, up to 10 MB — and a workflow reshapes it and upserts it. No Finpilo client to write. Right when your system can fire a webhook. If your system signs its calls instead of sending our key (an HMAC of the body in a header, as GitHub, Stripe and many ERPs do), the workflow can check that signature with its secret instead. See Workspace Settings.
3. Finpilo calls your system on a schedule. This is the one people miss. A workflow on a cron trigger calls your API, walks your pagination for you (page, offset, cursor or link-header), asks only for records changed since its last successful run, resolves your internal ids, and feeds the rows into an idempotent upsert. You write no client, no scheduler, no cursor bookkeeping and no retry logic — you expose a read endpoint and a workspace admin configures the rest.
If the job is "keep the customer's partners and cost centres in step", route 3 is usually the whole answer. It is configured in the workflow editor, or by asking an AI assistant to build it — see AI Assistants. The send step's options that matter for a correct posting are: pagination, an incremental cursor, resolving your ids before the body is built, an idempotency key, and a rule for reading a refusal out of a 2xx reply.
Enabling API access
The API only works once a workspace admin turns on API & AI access for the workspace (Workspace Settings → Integrations → Connect, where the API's address is too, with a Copy button). While it is off, every request answers 403. A call made with a person's login can do only what that person's role allows in the app. See Workspace Settings.
Base URL
All requests go to:
https://api.finpilo.com
Every path below is relative to it, for example GET https://api.finpilo.com/api/v1/companies/ENT-00001/documents.
Authentication
All API requests must include an X-API-Key header containing an API key.
X-API-Key: fp_your_api_key
Keys are per-connection, not per-workspace: different systems never share credentials, and each can be cut off on its own. Generate one in Workspace Settings → Integrations → API Keys. See Integrations for the full procedure, including rotation with a grace period and revoking.
Each key carries its own permissions, chosen when it is created and editable afterwards. A permission no key can hold is refused when you save, and the message names it:
| Permission | Unlocks |
|---|---|
| Read documents | listing, reading, searching, downloading files |
| Create and edit documents | upload, edit fields and line items, move a document to the current lifecycle |
| Move documents through their statuses | transitions, and the moves a document offers |
| Read partners and dimensions | listing and reading master data |
| Create, edit and delete partners and dimensions | upsert, edit, delete master data |
| Read change history | audit trails |
| Read usage figures | credit consumption |
A key with no permissions ticked can do nothing — it authenticates and is then refused on every call. If you get a 403 on a call you expect to work, check the key's permissions first.
The key reaches only the entities its connection covers. Requests for any other entity return 404, exactly as if it did not exist. When a connection covers all entities, its key works across the whole workspace — never across workspaces.
Rate limits
300 requests per 60 seconds, counted per connection (so during a 24-hour key rotation the old and new keys share one allowance). Over the limit, requests are rejected with 429 rather than queued. Space out bulk work, and prefer the batch endpoints — one upsert of 10,000 partners costs one request.
Reference IDs
Most endpoints use your own reference IDs (the string identifiers you set when creating entities, partners, or dimension values in Finpilo) rather than internal UUIDs. This makes integration simpler — use the same IDs your accounting system uses.
companyReferenceId— the entity's reference ID (e.g.,ENT-00001).referenceIdon dimension values — the identifier you chose.
Documents are the exception — they are addressed by their internal UUID, returned by the list endpoint.
Finding the reference IDs. GET /api/v1/companies lists the entities the key can reach, with their reference IDs. GET /api/v1/companies/{companyReferenceId}/dimensions lists that entity's dimensions and their reference IDs. Start there rather than asking someone to read them off the screen.
Paging
List endpoints return a bare JSON array — no total and no cursor. Page until a page comes back with fewer items than pageSize.
A pageSize outside the allowed range is ignored and replaced by the default, not clamped to the maximum: asking for 5,000 gets you 100, not 1,000.
The two discovery endpoints are different: GET /api/v1/companies returns { total, items }, and GET .../dimensions is not paged at all.
Dimension values
Endpoint prefix: /api/v1/companies/{companyReferenceId}/dimensions/{dimensionReferenceId}/items
dimensionReferenceId is the reference ID of the dimension as set in the entity's Dimensions tab.
Batch upsert dimension values
POST /api/v1/companies/{companyReferenceId}/dimensions/{dimensionReferenceId}/items
Same partial-success semantics as partners.
Limits: 1 to 10,000 items per request.
Request body:
[
{
"referenceId": "DIM-00001",
"name": "Administration",
"description": "Accounting, legal, management fees",
"code": "6100",
"values": { "owner": "K. Ozols", "bankAccounts": ["LV11AAA", "LV22BBB"] }
}
]
Required fields: referenceId, name. description, code and values are optional.
values holds the extra columns the dimension declares — set up in the entity's Dimensions tab, and listed by GET .../dimensions. Send them as { "key": value }, using each column's key; a column that holds a list takes an array. Omit values and the stored ones are left alone; send {} to clear them. A key the dimension does not declare is ignored rather than stored.
code is the value's own short code — the one your users see when they pick the value on a document. It is not the referenceId, which is the identity this endpoint matches on: a code may repeat, may be empty, and may change freely. Omit code and the stored one is left as it is; send "" to clear it.
List dimension values
GET /api/v1/companies/{companyReferenceId}/dimensions/{dimensionReferenceId}/items
Query parameters: page, pageSize (max 1000), updatedSince — same as partners.
Get single dimension value
GET /api/v1/companies/{companyReferenceId}/dimensions/{dimensionReferenceId}/items/{referenceId}
Response 200:
{
"id": "uuid",
"referenceId": "DIM-00001",
"name": "Administration",
"description": "Accounting, legal, management fees",
"code": "6100",
"allocationListReferenceId": "DEPT",
"createdAt": "ISO 8601",
"updatedAt": "ISO 8601",
"archivedAt": null,
"values": { "owner": "K. Ozols", "bankAccounts": ["LV11AAA", "LV22BBB"] }
}
values comes back exactly as it was sent, so you can compare what you hold against what Finpilo holds without translating either side. It is absent when the value carries no extra columns.
You can also filter on a declared column — POST .../items/query takes the same structured filters as the document query, and a declared column is named values. followed by its key, for example values.regNo. So "which partner has registration number X" is one call.
The prefix keeps your columns apart from the fields every value already has, so you can name a column anything you like — even code. code then means the value's own code and values.code means yours, and neither hides the other.
A column holding a list matches when any of its entries does. Greater-than and between need the column declared as a number or a date; sorting by a declared column is not supported, so filter on it and sort by another field.
You can also find documents by a declared column of the value they are coded to. In POST .../documents/query, name it allocations.<dimension>.values.<key> — so "which invoices are from the partner with this registration number" is one call. It reads the value as it is now, not as it was when the document was coded, so correcting a partner's registration number immediately corrects what the search finds.
archivedAt is set when the value has been retired. Retired values are still listed, deliberately: a sync that could not see them would read one as missing and send it again, bringing back something somebody retired on purpose.
Documents
Endpoint prefix: /api/v1/companies/{companyReferenceId}/documents
The endpoints below cover reading and downloading documents. The API also supports uploading a document, editing its fields and line items, searching and aggregating documents with structured filters, and moving statuses. These follow the same reference-ID and authentication conventions. For their detailed schemas, write to help@finpilo.com.
POST /api/v1/companies/{companyReferenceId}/documents/query is worth knowing about: unlike the list endpoint it takes structured filters and returns { total, items }, and its rows carry the document's status, which the payload schema below does not. An empty result carries noMatchReason, which names the condition that emptied it.
Upload returns a different id. POST .../documents/upload answers with a companyFileId, while every other endpoint takes a documentId. The query endpoint returns both, so use it to map between them.
List documents
GET /api/v1/companies/{companyReferenceId}/documents
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
page |
int | 1 | Page number |
pageSize |
int | 50 | Items per page, max 500 |
status |
string | — | Filter by status key, matched exactly |
documentType |
string | — | Filter by document type name |
issuedFrom |
ISO 8601 | — | Issue date range start |
issuedTo |
ISO 8601 | — | Issue date range end |
sentOnly |
bool | — | If true, only documents whose status key is Sent |
status is case-sensitive and workspace-specific. Statuses are defined by the workspace's own lifecycle, so there is no fixed list — Validated and Sent are the usual keys, but validated matches nothing and returns an empty array with a 200, not an error. Read the keys from GET .../documents/{documentId}/status-options (it needs reading documents; the moves are listed only for a key that may also move documents), or ask the workspace admin. The same applies to sentOnly, which looks for the exact key Sent: if the workspace's lifecycle names its delivered state something else, use status= with that key instead.
Response 200: array of document payload objects (see schema below).
Get single document
GET /api/v1/companies/{companyReferenceId}/documents/{documentId}
Returns the full document including all line items, the rows of every other grid, and dimension allocations.
Download original file
GET /api/v1/companies/{companyReferenceId}/documents/{documentId}/file
Returns the original uploaded file as a binary stream. Content-Type matches the original upload (PDF, JPEG, PNG).
Finpilo does not convert or re-encode files — you receive exactly what was uploaded.
Document payload schema
This is the structure returned by the document endpoints. A send workflow can post it to your system when a document is sent, and admins shape what it posts in the workflow (see Integrations).
{
"documentId": "3fa0b1c2-d3e4-5f67-8901-abcdef123456",
"documentType": "Invoice",
"documentNumber": "INV-2025-0042",
"issueDate": "2025-01-15T00:00:00Z",
"dueDate": "2025-02-15T00:00:00Z",
"servicePeriodStartDate": "2025-01-01T00:00:00Z",
"servicePeriodEndDate": "2025-01-31T00:00:00Z",
"currency": "EUR",
"supplierName": "Acme Supplies OÜ",
"supplierRegNumber": "12345678",
"supplierVatNumber": "EE123456789",
"supplierAddress": "Tallinn, Estonia",
"supplierBankAccounts": ["EE382200221020145685"],
"recipientName": "My Company SIA",
"recipientRegNumber": "40003012345",
"recipientVatNumber": "LV40003012345",
"recipientAddress": "Riga, Latvia",
"recipientBankAccounts": null,
"recipientReferenceId": "ENT-00001",
"lineItemsSubtotalExcludingTax": 1000.00,
"allowancesAmountExcludingTax": 0,
"chargesAmountExcludingTax": 0,
"retentionAmountExcludingTax": 0,
"totalExcludingTax": 1000.00,
"taxAmount": 200.00,
"totalIncludingTax": 1200.00,
"prepaidAmountIncludingTax": null,
"payableAmountIncludingTax": 1200.00,
"comment": null,
"validatedAt": "2025-01-20T09:15:00Z",
"entityName": "My Company SIA",
"fileUrl": "https://docmanager-api.../api/v1/companies/ENT-00001/documents/3fa0b1c2-.../file",
"lineItems": [
{
"id": "8c1f0a2b-4d5e-6f70-8192-a3b4c5d6e7f8",
"position": 0,
"title": "Consulting services",
"description": "January 2025",
"supplierCode": "CONS-001",
"unit": "h",
"quantity": 10.0,
"unitPriceExcludingTax": 100.00,
"totalExcludingTax": 1000.00,
"taxRate": 20.0,
"taxAmount": 200.00,
"totalIncludingTax": 1200.00,
"allocations": [
{
"allocationListReferenceId": "DEPT",
"dimensionName": "Department",
"code": "IT",
"name": "IT Department",
"description": null
}
]
}
],
"allocations": []
}
Fields with no data are null (never omitted). lineItems and allocations are always arrays (empty [] if none). Each line item's id is what you send back in a document edit to update that line rather than duplicate it.
The fields are your workspace's. Every field a document holds is here under its key: the ones Finpilo starts you with and every field you create yourself. A field you retire stays here with what each document holds, and you can still filter and search by it; it just can't be set any more. Restore the field to fill it again.
Every grid's rows travel the same way. Line items are the rows of one grid, whose key is lineItems. If your workspace has made other grids (freight lines, for example), each one's rows come beside lineItems under that grid's own key, every value under its field's name, which is the same in every grid. The key addresses the grid everywhere: a document edit sends "<grid key>": [rows] for every grid, lineItems included, and a row value under a name the grid does not hold is refused with that name, never dropped; a query filter names a row value as <grid key>.<field>, as lineItems.title does for line items. Everything written for lineItems keeps working unchanged. An edit that sends a grid's rows replaces that grid's whole set, so include every row you want to keep: a row sent as just its id keeps what it had, and a grid you do not mention is left as it is. A grid somebody has retired takes no new row: a row sent with no id for it is refused, naming the grid, while the rows it already holds can still be changed, and left out to remove them, so you can still empty it.
The status is deliberately not here. To read it, use POST .../documents/query, whose rows carry the status and every field the payload has.
An allocation's code is the dimension value's referenceId — the same identifier you upsert dimension values by, under a different name.
Receiving sent documents
When Finpilo sends a document to your system, follow this typical flow:
- Receive the HTTP POST. The body is
application/jsonmatching the payload schema above. - The request carries the authentication configured on the destination — a Bearer token, Basic credentials, a custom header, an OAuth token, a token from your own login endpoint, or a signature of the body made with a shared secret; it can also present a client certificate (see Integrations). Verify it before processing.
- Create a record in your system using the structured data.
- Fetch the original file, if you need it. Build the URL yourself from the payload:
GET /api/v1/companies/{recipientReferenceId}/documents/{documentId}/file, with your API key. A sent payload carriesfileUrl: null, so an integration that relies on it silently loses the attachment. - Attach the file to your record.
- Return a JSON response with
accountingReferenceIdset to your internal record ID. Finpilo stores it in the document's Accounting Reference field. An empty200 OKresponse is also valid if you do not track the reference.
Finpilo waits up to 30 seconds for your response. Requests that exceed this are marked as failed.
Answer with an error status when you refuse a document. By default Finpilo treats any 2xx as a successful send, so a 200 carrying {"ok": false} marks the document delivered. If your API cannot help returning 2xx on a refusal, the workspace admin can configure the send step to read the body instead — tell them which field says so.
There is no automatic retry of a failed POST unless the workspace admin turns one on, precisely because a retried POST can post twice. If you can accept a retry safely, say so and ask them to enable it. If you support an idempotency key, say which header you want it in.
Tell the admin where your error message is. When your system refuses a send, the run shows your own message if the admin names where it is (for example body.message); otherwise it shows your status code and the start of your reply. A failed send can be sent again once the cause is fixed, from the step that failed, without repeating the steps before it.
Large lists and rate limits. If you accept at most a certain number of items per request, or a certain number of requests per minute, say so: Finpilo can split a list into batches and space its requests to your limit.
Computed values in the payload
The send step's body is a JSON template. Most values are copied from the document with {{path}} — {{subject.documentNumber}}, {{subject.totalExcludingTax}}. Where your system needs something the document does not hold, a value can be computed instead, so nothing has to sit between Finpilo and you.
Six forms are available anywhere a value goes:
"{{path}}"copies a value."{{path?}}"allows it to be missing.{"$map": {"from": "subject.lineItems", "as": { ... }}}reshapes a list, with each row asitem. Add"groupBy": ["taxRate"]to send one element per group of rows with the same value, for example one line per VAT rate. Each group's first row isitemand all its rows arelines, so{"$value": {"aggregate": {"op": "sum", "over": {"var": "lines"}, "field": "totalExcludingTax"}}}is that group's total. Rows with no value there form a group of their own. The document itself never changes. Add"sortBy": ["account", {"path": "totalExcludingTax", "order": "desc"}]to send the rows in that order (ascending unless it saysdesc). Rows with no value there go last. Inside a group, a$mapwith"from": "lines"lists that group's own rows.{"$value": <operand>}computes one.{"$csv": {"rows": <a $map>, "separator": ";", "header": true}}writes rows as one CSV text: the first row's columns, a value holding the separator, a quote or a line break in quotes.{"$lines": <a $map of texts>}joins lines into one text. For a fixed-width file, build each line withpadandtruncateto its widths.
For a system that imports files instead of taking calls, Finpilo can put the file (a CSV, fixed-width or XML text, or the document's own PDF) in your SFTP folder. It checks the server's identity before sending, and never overwrites a file already there. Ask your Finpilo contact to set it up.
{"$xml": { ... }}writes a node as an XML string instead of JSON, for a system that wants XML — as the whole body, or carried inside one JSON field. Sending it as the whole body still needs aContent-Typeheader (for exampleapplication/xml) set on the step — without one it goes out as plain text.
An operand is a literal, {"var": "path"}, an aggregate, or one of these operations:
| Operation | Form |
|---|---|
| add, sub, mul | {"add": [a, b]} |
| div | {"div": [a, b, places, mode]} |
| round | {"round": [n, places, mode]} |
| concat, coalesce | {"concat": [a, b, ...]} — at least two values |
| pad | {"pad": [text, width, fillChar]}, or a fourth argument "left" or "right" (default left) |
| upper, lower | {"upper": [text]} |
| trim | {"trim": [text]} — removes spaces at both ends |
| truncate | {"truncate": [text, 50, "…"]} — at most 50 characters, the ending counted inside them. A shorter text is sent as it is. The ending is optional |
| replace | {"replace": [text, "\n", " "]} — every occurrence, exactly as written |
| regexReplace | {"regexReplace": [text, "[^0-9]", ""]} — by pattern. This one keeps only the digits |
| parseNumber | {"parseNumber": ["1 234,50", ",", " "]} — reads a number written with a decimal comma (or ".") and the thousands mark you name (any one character, or leave it out for none) |
| parseDate | {"parseDate": ["30.09.2026", "dd.MM.yyyy"]} — reads a date in your shape. The shape needs its year, month and day |
| numberFormat | {"numberFormat": [amount, 2, ",", " "]} — writes 1 234,50. The thousands mark is any one character, or leave it out for none. It never rounds, so round first |
| dateFormat | {"dateFormat": [date, "dd.MM.yyyy"]} |
| dateAdd | {"dateAdd": [date, days]} — answers a full timestamp, so wrap it in dateFormat for a date |
| if | {"if": [<condition>, then, else]} |
| lookup | {"lookup": [key, {"ours": "theirs"}, default]} — a table written in the send. To keep the table as data instead, give a register a column for their code (a register column is added through the API or the Finpilo assistant) and link the line or document to its value. The send reads the column by its key: {{item.dimensions.vat.fields.theirCode}} for a line's own link inside a $map, {{subject.dimensions.vat.fields.theirCode}} for the document's. You then keep the table up to date without changing the send |
| split | {"split": ["INV-2026/0042", "/"]} — the parts: ["INV-2026", "0042"] |
| at | {"at": [list, -1]} — one element: 0 is the first, -1 the last |
| distinct | {"distinct": [list]} — each value once |
| flatten | {"flatten": [list]} — lists inside the list become one list |
| aggregate | {"aggregate": {"op": "sum|avg|min|max|count|join", "over": <operand>, "field": "name", "where": <condition>}} — join writes the texts one after another, separated by ", " or the separator you give |
Conditions use eq, ne, gt, gte, lt, lte, in, contains, empty, on (on = the same calendar day), combined with and, or, not, any, all.
For example, the total of only the lines on one cost centre:
{ "ccTotal": { "$value": { "aggregate": {
"op": "sum", "over": { "var": "subject.lineItems" }, "field": "totalExcludingTax",
"where": { "eq": [ { "var": "dimensions.cost_centre.code" }, "CC-100" ] } } } } }
The paths available are the document's own fields (subject.supplierName, subject.lineItems[].totalExcludingTax, subject.dimensions.<dimension key>.code), an earlier step's reply at outputs.<step id>.body, and a received webhook payload at input.webhook. Name a dimension by its key. The key never changes, while a dimension's name stops matching once someone renames it.
Points where a mapping usually goes wrong:
modeis required on anything that divides or rounds —half-up,half-even,up(ceiling) ordown(floor).placesis 0–28. Finpilo will not choose a rounding rule for you.formatis a .NET date pattern —yyyy-MM-dd, notYYYY-DD. An unrecognised letter passes through as literal text.- A
{{token}}does not resolve inside an operand. Use{"var": "path"}there. coalesceskips a null but not an empty string.- A path that does not exist fails the step rather than sending a blank — as does a division by zero, an unreadable date, or a
lookupthat misses with no default. - A
sumorcountwhosewherematches nothing answers zero, and sends it. (avg,minandmaxover nothing have no answer, so they stop the step instead.) A mistyped path insidewherelooks exactly like a genuinely empty set. Paths are case-sensitive, and a dimension name drops any dot in it (Dept. codereads asdimensions.Dept code.code). Test it against a real document and read the number.
An AI assistant connected to the workspace can build and test these for you — see AI Assistants.
Headers sent by Finpilo
Every send request includes:
Content-Type: application/json
plus the authentication header from the destination: Authorization: Bearer <token> for Bearer auth, Authorization: Basic <credentials> for Basic auth, an OAuth access token under whatever scheme you specify, or the custom header you named. A destination with No auth sends no authentication header.
Two behaviours worth designing around: redirects are not followed, so a 301 or 302 from you is treated as a failure — give the final address. And Finpilo only calls addresses reachable on the public internet. A private, internal or loopback address is refused before the request is made.
Error responses
| Status | Meaning |
|---|---|
400 Bad Request |
Validation failure (empty batch, batch > 10,000 items) — or both an X-API-Key and an Authorization header were sent |
401 Unauthorized |
Missing or invalid X-API-Key |
403 Forbidden |
Allowed to ask, not allowed to do — see below |
404 Not Found |
Reference ID not found, or in an entity this key does not cover |
409 Conflict |
The records changed since the preview a bulk edit was based on. Re-read and try again |
429 Too Many Requests |
Over the rate limit. Slow down and retry |
A 403 has four different causes and four different fixes, so read the message rather than the code:
- API & AI access is switched off for the workspace. An admin turns it on.
- The workspace's service is suspended — an unpaid or ended subscription. Every call is refused until it is settled, so a sync that worked yesterday and answers 403 across the board today is usually this.
- The key lacks the permission. Reissue or edit the key with what it needs.
- The person's role does not allow it. A call made with a person's login can do only what their role allows in the app; an admin changes the role on the People tab.
- The endpoint needs a person's login — approvals, configuration, entities, users. No key can do it.
Most errors use the shape { "error": "description" }. Errors raised deeper in the service layer — including every 409 — use { "status", "code", "message", "errors" } instead, so read both. A 401 carries no body at all, because nothing has identified itself yet.