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, and trigger a send to the connected accounting system.
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, entity field settings.
- Creating or deleting entities, changing their field settings, 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. 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 → API & AI Access). While it is off, every request answers 403. Each role's permissions over the API are set on that same tab, and narrow what a key can do further. 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:
| Permission | Unlocks |
|---|---|
| Read documents | listing, reading, searching, downloading files |
| Create and edit documents | upload, edit fields and line items |
| Move documents through their statuses | transitions, send |
| 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"
}
]
Required fields: referenceId, name. description and code are optional.
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"
}
Documents
Endpoint prefix: /api/v1/companies/{companyReferenceId}/documents
The endpoints below cover reading, downloading and sending 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, 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 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.
Send document
POST /api/v1/companies/{companyReferenceId}/documents/{documentId}/send
Triggers the send flow — Finpilo runs the Send to Accounting workflow, which posts the document to the entity's destination. An entity with no destination cannot send, and there is no fallback. See Integrations.
Query parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
resend |
bool | false | If true, allows re-sending a document that was already sent |
Response 200:
{
"success": true,
"accountingReferenceId": "INV-2026-0042"
}
accountingReferenceId is the value returned by the receiving system (if any).
Response 400 — the send could not run, for example when the entity has no destination configured, the document cannot be sent from its current status, or it was already sent and resend was not set:
{ "error": "description of the failure" }
A 400 does not always mean nothing was posted. Finpilo waits up to 45 seconds for the send to finish. If it is still running when that runs out, you get a 400 reading "The send is still running. The document's status will show the result." Do not retry that one — the document may well post. Poll the document's status instead. resend=true is the only safe way to send a second time.
Document payload schema
This is the structure returned by the document endpoints. It is also the default payload the Send to Accounting workflow posts to your system when a document is sent — admins can adjust that payload in the workflow (see Integrations).
{
"documentId": "3fa0b1c2-d3e4-5f67-8901-abcdef123456",
"documentType": "Invoice",
"direction": "Incoming",
"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": 1,
"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.
Two fields are deliberately not here: status and accountingReferenceId. To read either, use POST .../documents/query, whose rows carry the status.
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, or an OAuth token (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. The payload also carries afileUrl, but it is filled in only when the send was triggered throughPOST /api/v1/…/documents/{documentId}/send— a send someone starts in the app 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.
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.
Three 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.{"$value": <operand>}computes one.
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]} |
| 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]} |
| aggregate | {"aggregate": {"op": "sum|avg|min|max|count", "over": <operand>, "field": "name", "where": <condition>}} |
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.<name>.code), an earlier step's reply at outputs.<step id>.body, and a received webhook payload at input.webhook.
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, send errors) — 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 role matrix has narrowed it. An admin changes it in Workspace Settings.
- 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.