Skip to main content

Which endpoint should I use?

Journal entries are the source of truth for all financial reports. Every number on the income statement, balance sheet, and cash flow is computed from journal entries. They are sourced from bank transactions, spreads (amortized expenses), capitalizations (depreciation schedules), CSV uploads, transfers, and other accounting operations. Transactions are the raw bank and credit card feed: merchant names, approval status, account source. These are the original records before any accounting rules are applied, so they may not match report totals. The category_id field links everything together. Every category entry on a report, every journal entry, and every transaction shares the same category_id.

Retrieving one category total

Use GET /aggregations/total when you need one signed category total instead of a full financial statement. The total includes the requested category and its descendants. Check the category’s point_in_time field from GET /categories before choosing date parameters. It tells you which request shape the category expects: Dates use YYYY-MM-DD, but totals have monthly granularity and ignore the day. 2026-02-03 and 2026-02-28 select the same February total. Periodic totals include both selected months; point-in-time totals are cumulative through the selected month.
start_month and end_month (in YYYY-MM format) are deprecated but still accepted, so existing integrations keep working. A single request cannot mix month parameters with full-date parameters. Every response returns both representations, so you can migrate at your own pace: read start_date, end_date, and date from the response while still sending months.
You can pass merchant to limit the total to one exact, case-sensitive merchant or counterparty name. The response uses integer cents and USD. Assets use debit minus credit; all other categories use credit minus debit. refreshed_at is the Unix timestamp of the most recent change to data included in the result. It can be null when no data matches, and newly imported activity may not appear immediately.

The drill-down pattern

To break down any line item on a report:
  1. Pull a report (e.g. the income statement).
  2. Find the category you want and grab its category_id.
  3. Fetch the underlying journal entries:

Finding merchant names

Use GET /parties/merchants to find merchant and counterparty names recorded for a category and its descendants across all available months. The API returns each name exactly as recorded. Names are free text and do not have stable IDs. The list excludes held entries, uses cursor pagination, and may not include a newly imported name immediately.

Updating transactions

v1 has two writes: editing one transaction, and inviting one teammate. Everything else is read-only. PATCH /transactions/{id} accepts any non-empty subset of five dashboard-editable fields:
accounting_date is the one field with a bounded range. The resulting date must be:
  • On or after the company’s incorporation date, as returned by incorporation.date from GET /company. When Finta has no incorporation date on file, the floor is two years before the server’s current date instead.
  • Strictly before three years after the server’s current date.
Both bounds are computed from the server’s clock on each request, so the accepted window slides forward over time. A date that passes today can fail later, and a hard-coded far-future date will eventually stop working. A value outside the window returns 422 transaction_update_failed with param: "accounting_date", the same code as any other bookkeeping rejection, so read param rather than assuming the code means an eligibility problem. Setting accounting_date to null is never out of bounds: it restores the transaction date rather than computing a new one. All requested fields are applied atomically in a stable server-defined order. JSON key order has no effect, and if any one change fails, none are applied. A body carrying no supported fields returns 400 parameter_missing; unknown fields and invalid values are rejected with a 400 as well. Success returns the full updated Transaction, including derived fields such as categorized, category_name, and department_name, so update local caches from the response body instead of assuming only the fields you sent changed. Transfer transactions, split transactions, and hidden transactions are not editable through this endpoint, and neither are non-selectable categories or departments that are not assignable leaves. A transaction, category, or department that is missing, unavailable through the API, or carrying the wrong ID prefix returns 404 resource_missing. A change that is well-formed but rejected by eligibility or bookkeeping rules returns 422 transaction_update_failed, with param naming the field at fault.

Departments

Use GET /departments to pull the company’s department hierarchy in display order. Like /categories, it returns the full set in one response and does not paginate. Departments form a tree up to four tiers deep. Each entry carries a tier, a parent_id (null at the top level), and an assignable flag. Only leaf departments with assignable: true can be assigned to a transaction, so filter on that flag before offering a department as a choice rather than assuming every entry is a valid target for PATCH /transactions/{id}. Transactions and journal entries both expose department_id, and GET /journal_entries accepts it as a filter.

Checking integration status

Finta pulls your financial data from outside providers: banks and card issuers, revenue platforms, payroll providers, cap table providers, and more. If one of those connections quietly breaks, the transactions and reports endpoints keep returning 200 OK with data that is silently going stale. GET /integrations is how you detect that. It takes no parameters, and it is not paginated: there is no has_more or next_cursor, and the full set always comes back in one response.
key is the stable machine identifier and the only field you should branch on. name is a display label, not an identifier: it can change if a vendor rebrands.

Types

notifications is the odd one out. A notifications integration sitting at not_connected says nothing about whether a company’s financial data is complete, unlike every other type.

Statuses

requires_reconnect is the value to act on. It is the one state that means data has stopped flowing and will not resume on its own, whether because credentials expired, the provider revoked access, the connection was severed on the provider’s side, or Finta now needs broader permission scopes than were originally granted. It cannot be resolved through the API. There is no reconnect endpoint; the remedy is always a human reauthenticating at app.finta.com. Treat requires_reconnect on any banking, revenue, or payroll integration as worth alerting whoever owns the account, because reports and transactions go quietly stale while it persists. not_connected is not an error condition and should not be alerted on.

Consuming the list safely

The response contains only the integrations available to the authenticated company, which depends on its plan, onboarding state, and feature flags. An integration a company cannot use is omitted from the array entirely rather than returned with status: "not_connected".Stripe, for example, only appears once a company has completed onboarding, so a brand new company has no stripe entry at all. Code equivalent to integrations.find(i => i.key === "stripe").status will break on those companies. Look the entry up defensively and handle the missing case.
Entry order in data is neither alphabetical nor stable across releases. Index the array by key rather than reading by position.
type and status are closed sets today, but Finta adds integrations regularly and each one may bring a new key, occasionally a new type. New keys appearing between one call and the next is normal and is not a breaking change.
  • Treat an unrecognized key as a new integration. Skip it or display it generically; do not throw.
  • Treat an unrecognized type as an “other” bucket rather than failing validation.
  • Treat an unrecognized status as not actionable: do not assume it means connected, and do not alert on it as though it were requires_reconnect.
Integration status changes on the order of days, not seconds. Checking every few minutes is more than sufficient. A tight polling loop will burn through your company’s monthly call allowance for no benefit.

The team directory

Use GET /team to read who belongs to the company, who has been invited, and what access each person has. It takes no parameters and returns the same records, in the same order, as Settings > Members at app.finta.com. Reading the directory needs Team & access: View in addition to the usual API access: Manage. Creating an invitation, changing one’s access, resending one, and revoking one all need Team & access: Manage.
The permissions objects above are abbreviated. Every response carries all fourteen areas on every member and invitation. See the Retrieve Team reference for the complete example.
This is one document, not a list. members and invitations sit directly on the response, so there is no object: "list", url, data, or cursor to follow, and there are no filters or pagination parameters. Both arrays are always present and are [] when empty, never null. Active members come before inactive ones, matching Settings. Beyond that, no ordering is promised: do not assume the array is alphabetical or stable across calls. members covers active and inactive memberships. Deleted memberships are excluded, and invitations carries only invitations still in the pending state. Finta support staff who hold a membership appear as ordinary members, with no staff marker.

Members

created is not the date the person signed up for Finta. The same user joining a second company gets a second, later created in that company’s response, while id stays the same.

Invitations

Invitation objects have no state field, because only pending invitations are returned. Pending does not mean unexpired: an invitation is expired when expires is at or before the current time, and expired invitations are still listed. Compare expires against the clock yourself if you want to separate the two. Resending an invitation extends expires but leaves created and invited_by untouched, so invited_by always names the person who first sent it, not whoever resent it most recently. That person may have since been deactivated or removed, so a lookup of invited_by against the members array can legitimately come up empty. Handle the miss rather than assuming the join always resolves. version is an opaque revision string identifying the saved state of the invitation. The same value comes back from GET /team and from every write on the resource, so any of them tells you which revision you are looking at. It is informational and output-only. No endpoint accepts a version as input: editing neither takes one nor checks one, and resending loads the current revision itself rather than asking you for it. Sending one is a 400 invalid_request. Holding a revision therefore does not reserve the record against another writer, and a version you captured earlier has no effect on a later call. Treat it as a token you never look inside. Do not parse it, do not compare two of them for ordering, and do not construct one. It is not an acceptance credential and gives no ability to accept an invitation on someone’s behalf. Delivering the invitation email does not change it, so a version that looks stale is not evidence that delivery failed. Unlike id, version is never null, so its presence tells you nothing about whether the public ID is available. Acceptance tokens, delivery metadata, and avatar data are never returned.

Access templates and permissions

permissions is the stored access configuration for that person, with every area always present: api_access has no view level. none is no access to the area, view is read access, and manage is management access. access_template is a summary of that object, not a separate source of truth:
These are the permissions stored for each person, and they describe the Finta product, not this API. They are not a substitute for the API’s own authorization: every request is still checked live against the key owner’s current permissions. Reading api_access: "manage" here tells you what is configured, not that a given call will succeed.

Names

name is built from the stored first and last name: each part is trimmed of surrounding whitespace, blank parts are dropped, and what remains is joined with a single space. The API never derives a name from the email address. A shared mailbox like accounting@acme.com with no stored name returns "name": null, not "Accounting". The Settings page still shows an email-based placeholder, so the page and the API can disagree by design. Choose your own display fallback rather than treating a null name as an error, and never copy a UI placeholder into a system that expects a real person’s name.

Null IDs

id on either object, and invited_by on an invitation, can be null. The field is always present; only the value is missing.
A null ID means the public identifier is unavailable for that record, not that the record is invalid. The endpoint still returns 200, keeps the record in its array, and serves every other field normally. UUIDs, numeric database IDs, and acceptance tokens are never substituted in.
  • Do not drop the record. A member with a null id is still a member of the company.
  • Do not use id as a map key without handling the null case, or two ID-less records will collide.
  • Expect nulls to become non-null. An ID can be populated later, so treat its absence as temporary rather than a permanent property of the record.

Inviting a teammate

POST /team/invitations creates a pending invitation, sent as the key owner, in the key owner’s company. It is one of four writes on the team resource, alongside editing a pending invitation’s access, resending one, and revoking one. Accepted members are the gap: they cannot be edited or removed through the API, only in Settings > Members at app.finta.com.
first_name, last_name, email, and access_template are all required, and all four must be non-blank strings. The body must be a JSON object sent with Content-Type: application/json, and unknown fields are rejected rather than ignored. That includes company and user IDs: the key already determines who is inviting and which company they are inviting into, so there is nothing to pass. Query parameters are never read as a source of body fields.

Choosing access

The two named presets expand server-side. Sending a permissions field next to admin or read_only is a 400 invalid_request, and that holds even when you send it as null: the field is forbidden, not merely ignored. custom replaces the entire set, so the object has to carry all fourteen areas listed under Access templates and permissions. Missing areas, extra areas, and unsupported levels are all rejected, and api_access accepts only none or manage.
Responses label permissions by whichever preset they match, so a custom set that happens to be every area at view with api_access: "none" comes back as access_template: "read_only". custom on the way out means the stored permissions match neither preset exactly. Do not expect the template you sent to be echoed back. Three separate checks then sit in front of the access you asked for: The plan and feature rules read the resulting permissions, not the label you sent. A custom object matching Admin exactly satisfies the plan rule; read_only does not. Growth never buys you grant authority, and a rejected selection is never quietly promoted to Admin: you get an error instead of an invitation that grants more than you intended.

Reading the response

Both success cases return the saved invitation alongside a changed flag:
The invitation object carries exactly the same fields as an entry in the invitations array from GET /team, including the opaque version and the same nullable id and invited_by. The permissions above are abbreviated; real responses carry all fourteen areas. The status code tells you what happened to the record. changed reports something narrower: whether an existing record was altered.
Use the status code, not changed, to detect a new invitation. A first creation returns 201 with changed: false, the same flag an unchanged replay returns with 200. changed reports whether an existing record was altered, so on this endpoint it cannot tell the two apart.changed: false on a 201 does not mean the creation failed, that nothing was saved, or that no email was requested. The record exists and delivery was asked for.
Read both. changed is not a substitute for the status code, and neither one promises the email arrived: they report what the operation did, not what the mail server did.

Retrying a creation safely

“Exact” is strict. A request matches an existing invitation only when the inviter, the first name, the last name, the email ignoring case, and the permissions all agree. A matching email alone is not a match. An email that matches while something else differs is a conflicting invitation and returns 422 invitation_invalid, and an email already belonging to a teammate returns the same. That matching rule is what makes the endpoint safe to retry. There is no idempotency key to generate and no separate retry mechanism:
  • On a timeout, a dropped connection, or a 500 invitation_create_failed, resend the identical body with exponential backoff. An existing match comes back as 200 rather than becoming a second invitation.
  • Do not adjust the names, the inviter, or the permissions between attempts. Any change breaks the match and can create a duplicate.
  • Expect the checks to re-run on every attempt, replays included. A company that downgraded from Growth between two calls gets 403 plan_upgrade_required on the second, even for a body that succeeded the first time.

Editing a pending invitation

PATCH /team/invitations/{id} changes the access on an invitation that is still pending. Access is the only thing it touches: the names, the email, the acceptance link, created, expires, and invited_by all stay as they are, and no email goes out.
The path takes a public invitation ID from GET /team or from a creation response, and it has to look like one: invite_ followed by letters and digits. Numeric database IDs, UUIDs, and acceptance tokens are rejected with 400 invalid_request and param: "id" before any lookup runs, so a malformed ID never reveals whether a record exists. The body works the same way as choosing access on creation. access_template is required, and omitting it is a 400 parameter_missing. admin and read_only expand server-side and forbid a permissions field even when it is null, and custom has to carry all fourteen areas with api_access limited to none or manage. Nothing else is accepted. first_name, last_name, email, version, company and user IDs, and any other key are rejected with 400 invalid_request naming the field, and query parameters are never read as a source of body fields. custom replaces the entire permission set: a partial object is rejected for the areas it leaves out rather than merged into what is stored.

Stricter than creation

The same three checks sit in front of the access you ask for, but two of them are tighter here: Do not carry creation’s Admin exception into editing. Setting a pending invitation to Admin on a company below Growth, or with role-based access control switched off, fails with 403 plan_upgrade_required or 403 feature_disabled, even though the identical selection would have been accepted when the invitation was created. Replaying the permissions already stored is still an edit and is still checked. Growth does not buy grant authority here either. An invitation whose current access you may not manage does not become editable because the company upgraded.

What you can reach

Only pending invitations in the key owner’s company are reachable. An accepted invitation, one belonging to another company, and an ID that never existed all return the same 404 resource_missing with param: "invitation_id", so the error never separates “not yours” from “not there”. No membership is changed by this endpoint: once someone accepts, their access moves to Settings > Members at app.finta.com. Expiry is not a barrier. An expired pending invitation can still be edited, and editing it does not renew the deadline or reissue the link, so expires comes back unchanged and possibly still in the past.

Reading the response

A success is always 200 OK, carrying the saved invitation and a changed flag in the same shape as the creation response:
changed is true when the stored permissions actually differed and false when you asked for what was already there. Both are successes, and there is no 201 on this path. The permissions above are abbreviated; real responses carry all fourteen areas. Read access_template off the response rather than assuming it echoes what you sent. As on creation, a custom set that matches a preset exactly comes back labeled as that preset.

Retrying an edit safely

Editing is safe to retry. Resend the same body with bounded exponential backoff after a timeout, a dropped connection, or a 500 permissions_update_failed; a request that already landed comes back as changed: false rather than doing the work twice. There is no idempotency key and no separate retry mechanism. Every authorization, plan, and feature check runs again on each attempt, so a retry can fail where the first attempt would have succeeded.
This is a last-write-wins endpoint. version is not a precondition here: the request does not accept one, and there is no compare-and-swap to reject a stale write. Two clients editing the same invitation overwrite each other silently, and changed: false only tells you the permissions matched at that moment. Re-read GET /team when you need to know the current state.
A 422 invitation_invalid here is about the stored record, not about your body. The endpoint validates the whole invitation, so a legacy record with, for example, a blank first name is rejected with param: "first_name". Do not try to repair it by adding that field to the request, because identity fields are not accepted. Fix the record in Settings > Members, then retry. An incomplete custom object is a different problem and returns 400 invalid_request instead.

Resending an invitation

POST /team/invitations/{id}/resend sends the invitation email again and issues a fresh acceptance link. It takes the public invitation ID in the path and nothing else.
There is no request body. Not a JSON object, not {}, not null, not a version, and no query parameters either. Anything you send is a 400 invalid_request. No Content-Type header is needed, since there is nothing to type. The ID has to match invite_ followed by letters and digits, exactly as on editing, and the same 404 resource_missing hides missing, accepted, and other-company invitations behind one response. Do not fetch a version first. Earlier drafts of this endpoint took one; the shipped version does not, and the server reads the current revision itself. Both expired and unexpired pending invitations can be resent. Access rules are looser here than on editing. Resend needs Team & access: Manage and the authority to manage that particular invitation, but it adds no Growth or role-based access control gate, because it grants nothing new. A company that cannot edit a pending invitation can still resend it.

Reading the response

200 OK returns the saved invitation and a changed flag, in the shape you get from creating or editing:
A normal resend renews expires and advances version, replaces the acceptance link, and asks for another email. Identity, permissions, created, and invited_by are untouched. changed is normally true; it can come back false when another operation changed the invitation between the lookup and the service’s own check, which is a concurrency outcome rather than a “nothing to do” signal. Neither 200 nor changed: true means the email arrived. It means the work was requested. Delivery can still fail afterwards, and nothing in a later response reports that.
Resend is not safe to retry blindly. Unlike creating and editing, it has no replay semantics: every call is a fresh resend. A repeat renews the link again and asks for another email, even when the first delivery is still in flight, and the newer link replaces the one already sitting in the invitee’s inbox. There is no idempotency key, and supplying an Idempotency-Key header does nothing.After a timeout or a dropped connection you cannot tell whether the first call landed. Retry only if a second email and a replaced link are acceptable. Otherwise read GET /team and compare expires before deciding.
On a 500 invitation_resend_failed, the same rule applies: back off, bound your attempts, and retry only when another resend is acceptable. A 422 invitation_invalid is about the stored record, as on editing, and cannot be fixed from a request that carries no body. Repair the invitation in Settings > Members, then resend.

Revoking an invitation

DELETE /team/invitations/{id} withdraws a pending invitation. Like resend, it takes the public ID in the path and nothing else: no request body, no query parameters, no Content-Type, and anything you do send is a 400 invalid_request.
Success is 204 No Content with an empty body. There is no JSON, no invitation snapshot, and no changed flag to read, so the status code is the entire result. Expired and unexpired pending invitations can both be revoked. Access rules match resend rather than editing. Revoke needs Team & access: Manage plus the authority to manage that particular invitation, and adds no Growth or role-based access control gate. An invitation whose access outranks the key owner’s returns 403 insufficient_permission with param: "invitation_id".

Missing is a success, accepted is not

Revoke resolves a missing target differently from every other endpoint on this resource, and the split matters: Absent and foreign-company IDs return the same empty 204 a real revoke returns, so a 204 is not evidence that an invitation existed or that you deleted anything. That is deliberate: it keeps the endpoint from confirming whether a record exists in a company you cannot see. Compare GET /team before and after if you need to know what actually changed. Editing and resending take the opposite approach and return 404 for the same targets. The accepted case is the one real 404. Someone who has already joined is a member, not an invitation, and this endpoint never removes a teammate or changes a membership. Do not retry that 404 hoping to deactivate the person; that is a Settings > Members action at app.finta.com.

Retrying a revocation safely

Revoke is the most retry-friendly write on the resource. Repeat the same DELETE after a timeout or a 500 invitation_revoke_failed with bounded exponential backoff: once the invitation is gone, every subsequent call returns 204 anyway, so there is no second effect to cause. No version and no idempotency key are involved. Two caveats. Authorization runs on every attempt, so a retry can still fail with 401 or 403 after the deletion already succeeded. And every attempt counts against your monthly call allowance, including the ones that find nothing to do. Fix the request before retrying anything else: correct the ID for a 400, the credential for a 401, and the access or account condition for a 403.

Reading financial reports

The API returns three financial reports: income statement, balance sheet, and cash flow.
Income and revenue are positive, expenses and taxes are negative. On the income statement, summing all section totals gives you net_income_cents. The same convention applies to cash flow: inflows are positive, outflows are negative.
The balance sheet is a point-in-time snapshot, so its category entries use balance_cents (the balance as of a single date). The income statement and cash flow statement are period reports, so their category entries use amount_cents (activity over a date range).
Each report returns refreshed_at, the Unix timestamp for its latest data refresh. Finta returns null when that timestamp is unavailable.
Categories form a tree. Parent categories (e.g. “Cash”, “Admin”) appear in the array alongside their children, and each child references its parent via parent_category_id. Root-level categories have parent_category_id: null. To reconstruct the tree, group entries by parent_category_id. The position field gives you the sort order among siblings that share the same parent.The cash flow statement also includes presentation groupings (“Changes in Working Capital”, “Non-Cash Expenses”) as entries in operating_activities.categories. These use synthetic IDs prefixed with grp_ (e.g. grp_changes_in_working_capital). Their children reference them via parent_category_id, following the same tree pattern.
Each section (e.g. revenue, expenses) includes a total_cents that equals the sum of its root-level categories (those with parent_category_id null). Parent categories contain subtotals of their children, so summing all entries would double-count. You can use total_cents directly without summing categories yourself.
Each category entry includes a category_id. Pass it to GET /journal_entries?category_id=cat_xxx (with the same date range) to see every journal entry behind that line item. Synthetic grouping IDs (prefixed with grp_) are not real categories and cannot be used with the journal entries endpoint.