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 SettingsAPI & 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 SettingsIntegrationsAPI 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).
  • referenceId on 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:

  1. Receive the HTTP POST. The body is application/json matching the payload schema above.
  2. 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.
  3. Create a record in your system using the structured data.
  4. 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 a fileUrl, but it is filled in only when the send was triggered through POST /api/v1/…/documents/{documentId}/send — a send someone starts in the app carries fileUrl: null, so an integration that relies on it silently loses the attachment.
  5. Attach the file to your record.
  6. Return a JSON response with accountingReferenceId set to your internal record ID. Finpilo stores it in the document's Accounting Reference field. An empty 200 OK response 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 as item.
  • {"$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:

  • mode is required on anything that divides or roundshalf-up, half-even, up (ceiling) or down (floor). places is 0–28. Finpilo will not choose a rounding rule for you.
  • format is a .NET date patternyyyy-MM-dd, not YYYY-DD. An unrecognised letter passes through as literal text.
  • A {{token}} does not resolve inside an operand. Use {"var": "path"} there.
  • coalesce skips 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 lookup that misses with no default.
  • A sum or count whose where matches nothing answers zero, and sends it. (avg, min and max over nothing have no answer, so they stop the step instead.) A mistyped path inside where looks exactly like a genuinely empty set. Paths are case-sensitive, and a dimension name drops any dot in it (Dept. code reads as dimensions.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.