Which endpoint should I use?
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
UseGET /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:
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.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:- Pull a report (e.g. the income statement).
- Find the category you want and grab its
category_id. - Fetch the underlying journal entries:
Retrieving one journal entry
If you already hold a singleje_ ID, from an earlier list call or a log line, GET /journal_entries/{id} reads that entry directly instead of re-listing and searching the pages. This is the same list-and-retrieve pair transactions have.
data array. It is not wrapped in a list envelope: there is no object: "list", data, has_more, or next_cursor. The endpoint uses the same Reports: View permission and the same visibility checks as the list, so an entry can be retrieved exactly when it would appear in the list for the same key.
A malformed ID, an unknown ID, and a real entry belonging to another company all return the same 404 resource_missing with param: "id", so the response never reveals whether an ID exists in a company you cannot see.
Each retrieve counts as one API call against the monthly allowance, the same as one list page, so when you need several entries, one filtered list call is usually cheaper than one retrieve per entry.
Finding merchant names
UseGET /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’s writes are narrow: editing one transaction or many at once, creating a manual account, the four operations on a team invitation, the three on an automation rule, and starting an integration connection. 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.datefromGET /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.
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.
Bulk updating transactions
POST /transactions/bulk/update applies the same edits to up to 5,000 transactions in one all-or-nothing operation. The request does not do the work itself. It is accepted as a job that runs in the background, and GET /jobs/{id} reports the job’s outcome: whether it has finished, how many transactions it selected and changed, and why it failed if it did.
The workflow has four steps:
- Discover. If you want to select by account or card, get stable IDs from
GET /accountsandGET /cards. To select specific transactions, collect theirtxn_IDs fromGET /transactions. - Submit. Send the selection and the edits with a fresh
Idempotency-Key. Store the key, the body, and the returned job ID. - Poll. Read the job at the returned
Locationuntil it reachessuccessorfailed. - Read errors. If the job failed, page through its
errorsto see why.
GET /jobs/{id} needs it even though it only reads. Any key owner who holds it can read any job in the company; reading is not limited to whoever submitted the job.
Submitting a bulk update
202 means the job was accepted, not that any edits happened. The body is a snapshot saved at acceptance, always queued with matched_count: null, updated_count: 0, and no errors, even if the job has already finished by the time the response arrives. The Location header is the relative path of the job; read it for the current outcome.
The body must be JSON sent with Content-Type: application/json. Any other content type, form-encoded bodies included, and malformed JSON return 400 invalid_request without creating a job.
A body holds exactly one selector, either filters or transaction_ids, plus a non-empty updates object. Unknown keys anywhere in the body are rejected. There is no partial-success mode, cancellation, or undo.
Choosing transactions
By filter.filters is a non-empty object of up to nine fields. Every field you supply must match (they combine with AND), and they are compared against each transaction’s values before the update. An empty filters object is rejected so a request can never edit the whole company by accident.
source_vendor and source_type are not accepted, even though GET /transactions supports them.
A filter selects every match at once, with no pagination, when the job starts running. It is not limited to one page of GET /transactions. If more than 5,000 transactions match, the job fails with transaction_limit_exceeded rather than editing the first 5,000. A filter that matches nothing is a successful job with zero changes. Split transactions (both the parent and the records derived from it) are never selected by a filter.
account_id and card_id are checked when you submit. An unknown ID, or one belonging to another company, returns 404 resource_missing with param naming the field; a real card that does not belong to the given account returns 400 invalid_request with param: "card_id".
By ID. transaction_ids is an array of 1 to 5,000 unique txn_ IDs. Duplicates, malformed IDs, and a 5,001st ID are rejected with 400 before any job is created. IDs that pass that check but point to a missing, split, or other company’s transaction are discovered when the job runs, and fail the whole job.
Updates
updates is a non-empty object of the fields to change. Omitted fields stay as they are.
{"department_id": null, "spread": null, "accounting_date": null}.
accounting_shift moves each transaction’s current effective accounting date, clamping to the last day of the destination month when needed. In 2026, January 31 shifted by 1 becomes February 28 and shifted by 2 becomes March 31. A later, separate +1 shift on that February 28 date gives March 28, not March 31. A shift of 0 leaves every date unchanged and skips the date checks, even on transfers; any other edits in the request are still validated. accounting_date and accounting_shift cannot be sent together, even when accounting_date is null.
The shape of each field is checked when you submit, and a bad value returns 400 invalid_request with param set to the field’s path, such as updates.spread. The same bookkeeping rules as editing one transaction (transfer restrictions, selectable categories, assignable leaf departments, the accounting-date window) are applied when the job runs. A transaction that breaks one fails the whole job with transaction_update_failed.
Once a job starts running, it freezes its set of target transactions. If a relevant field of a target changes before the edits are applied, or a transaction stops matching its account or card, the job fails with transaction_conflict instead of overwriting the change. Unrelated edits to the same transactions do not cause conflicts. The submitter’s permissions are checked again at that point too, so a submitter who lost access fails the job with permission_denied.
Idempotency and retries
Every submission needs anIdempotency-Key header: a string of 1 to 255 characters that is not blank or whitespace-only. It is used exactly as sent, case-sensitive and untrimmed. A missing, blank, or oversized key returns 400 invalid_request with param: "Idempotency-Key".
A key is scoped to the company and the user who owns the API key, so two API keys belonging to the same user in the same company share keys, while different users never collide.
null is different from leaving a field out.
- Use a new key for each piece of work you intend, and store it with the body and the job ID.
- After a timeout or lost response, retry with the same key and the same body. You will get the original job back if the first request went through. Do not mint a new key just because a response went missing: a new key is new work, and an
accounting_shiftwould apply a second time. - On a
409, poll the job you already have rather than changing the key to find out whether the work ran.
Reading a job
403 insufficient_permission on the next poll. GET /jobs/{id} does not take an Idempotency-Key and never returns a Location header.
Job statuses
success and failed are terminal. A job is all or nothing: on success every change committed together, and on failed none did, so there is never a partial result to unwind.
success describes the bookkeeping, not the reports built on it. Report summaries and caches can finish refreshing shortly after a job reaches success, so a report read immediately afterward may not reflect the changes yet.
Matched and updated counts
matched_count is how many transactions the job selected; updated_count is how many it actually changed. They differ whenever a selected transaction already had the requested values, so do not read matched_count as a change count.
matched_countstaysnulluntil selection succeeds. It is null on aqueuedjob, and it can still be null on a job that failed before selection finished.updated_countcounts each changed transaction once, however many of its fields changed. Turning an existing rule-assigned department into a manual assignment counts as a change, even when the department itself stays the same.- A selection that matches nothing is a
successwith both counts at0. - A
failedjob always reportsupdated_count: 0.
Reading job errors
errors is an embedded list envelope with object, url, has_more, next_cursor, and data. Each entry in data has four fields:
200 response describing the job, not errors in the request you made.
Page through errors with limit (1 through 500, default 100) and starting_after on the same GET /jobs/{id} URL. When errors.has_more is true, pass errors.next_cursor back unchanged as starting_after, URL-encoding it in the query string. You may change limit between pages, and each page returns the whole job again with the next slice of errors. matched_count and updated_count always describe the entire job, not the current page. When there are no more errors, has_more is false and next_cursor is null.
The cursor is an opaque signed token bound to that one job. It is not a transaction ID or an offset, and it does not work on any other job. A malformed, tampered, empty, or foreign cursor, or a limit outside 1 through 500, returns 400 invalid_request. That is an error in your request, not a change to the job.
Polling and retention
Poll the job’sLocation until status is success or failed, backing off between calls. There is no guaranteed completion time. Every submission, identical replay, poll, and page of errors counts as one call toward the per-minute rate limit and the company’s monthly API allowance. There is no per-transaction charge: a job that edits 5,000 transactions costs one submission call. On 429 rate_limit_exceeded, wait at least Retry-After, then resend the same request (for a submission, the same key and body); on 429 monthly_limit_exceeded, stop until the allowance resets.
Finished jobs, including every page of their errors, are kept for 30 days after they complete. After that, GET /jobs/{id} returns 404. Jobs that are still queued or processing do not expire. An unknown ID, a job in another company, and an expired job all return the same 404 resource_missing, so the response never says which one happened. Save what you need from a job before its 30 days are up.
Bulk update errors
OnPOST /transactions/bulk/update:
GET /jobs/{id}:
Consuming bulk updates safely
202 means accepted, not done
202 means accepted, not done
queued snapshot. Never treat a 202 as proof that edits happened; read the job for its outcome.A failed job is not a failed request
A failed job is not a failed request
200 with status: "failed", zero updates, and error details. Branch on status to learn the job’s outcome, and on the HTTP status only to learn whether the retrieval itself worked.Polling never reruns a job
Polling never reruns a job
failed, it stays failed. Polling it again returns the same result, and replaying the same key and body returns the same job, so neither restarts the edits. To try again after fixing the cause, submit with a new key.Read every error page
Read every error page
errors is not a summary. Follow next_cursor until has_more is false before deciding which transactions failed.Tolerate unfamiliar codes
Tolerate unfamiliar codes
code you do not recognize as a generic failure and show its message, rather than rejecting the response. status and type are closed enums in the current spec, but avoid crashing if a future API version adds values.Departments
UseGET /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.
Discovering source accounts
Every transaction names where it came from in itssource object: a vendor, a type, and an account_name. All three are labels, and the transaction carries no account identifier, so labels are all a feed consumer used to have. Names can change or repeat (“Checking” twice at two banks), which makes them unreliable keys.
GET /accounts fixes that. It lists the company’s locally known transaction-source accounts, bank, credit, and synthetic source identities alike, each with a stable, opaque acct_ ID. These are the accounts behind the transaction feed, not chart-of-accounts categories; for those, use GET /categories.
limit 1 through 500, default 100) with no filter parameters. Results come back in identity creation order, oldest first, regardless of labels, and paging is not a frozen snapshot. An empty inventory is a normal 200 with "data": [], has_more: false, and next_cursor: null.
Consuming the inventory safely
Presence is not connectivity
Presence is not connectivity
GET /integrations.Key on the ID, not the name
Key on the ID, not the name
id is the durable identity: it survives renames and provider deletions. account_name is a nullable display label and duplicates are allowed, so two accounts can legitimately share a name. Store and join on id.Both string fields are open sets
Both string fields are open sets
vendor and type are open strings. Treat an unrecognized value as a new source rather than failing validation, and expect account_name and type to be null when the metadata is unknown.Account types are broader than the transactions filter
Account types are broader than the transactions filter
source_type filter on GET /transactions accepts only bank, credit, and reimbursement. An account type outside that set, such as intercompany_expense, is not a valid source_type value and will be rejected. Transactions also expose no acct_ ID in their source object, so there is no ID field to join the two lists on.GET is read-only
GET is read-only
Creating a manual account
POST /accounts creates a manual bank or credit account: one Finta knows about but that has no integration, so transactions reach it only when someone uploads them. Use it when a statement belongs to an account that does not sync, such as a checking account at a bank that is not connected. Accounts that sync on their own (Mercury, Brex, Ramp, Plaid-connected banks, and so on) come from connecting the integration, never from this endpoint.
GET /accounts returns. The body must be a JSON object sent with Content-Type: application/json, and any other field is rejected rather than ignored.
The response is the account object itself, not wrapped, in exactly the shape of an item in the GET /accounts data array:
GET /accounts right away under the same id, and in the Finta web app, where it can be chosen when uploading a CSV. Creating it does nothing else: no transactions are imported or changed, no category is created, and no report, total, or balance moves.
vendor comes back lowercase and normalized for display, the same rule every account in GET /accounts follows. It can differ from what you sent in more than case: Chase becomes chase, JPMorgan becomes jp morgan, Wells-Fargo becomes wells fargo, and a trailing ID word is dropped (Acme ID becomes acme). Do not compare it with your input to decide whether the call worked. Read the status code and store id; account_name can change later if someone renames the account in Finta.
Retries and duplicates
The endpoint is safe to call again with the same account, and there is no idempotency key to send. Before creating anything, Finta looks for an existing manual account of the sametype whose vendor and account_name match:
- Case does not matter, and leading, trailing, or repeated whitespace is ignored.
chase checking 4821atCHASEmatchesChase Checking 4821atChase. vendormatches either as originally sent or in the normalized form accounts return. An account created withJPMorganis listed asjp morgan, and postingJPMorgan,jpmorgan,JPMORGAN, orjp morganreturns it instead of creating a second one. Resending your original request and postingvendorexactly asGET /accountsshows it both find the account.typehas to match too. Abankaccount and acreditaccount with the same vendor and name are different accounts.
500 account_create_failed, resend the same request with exponential backoff. It returns the account, with 201 or 200, however many earlier attempts reached Finta, and it never creates two.
Permissions and errors
Creating an account requires Transactions: Manage, one level above the Transactions: View that listing needs, so a key that can readGET /accounts can still get 403 insufficient_permission here.
Discovering individual cards
GET /cards answers the same question as GET /accounts, one level lower: it lists the company’s locally known individual cards, physical and virtual, each with a stable, opaque card_ ID. Before this endpoint, card identity had to be inferred from display labels or the last four digits on recent transactions, and neither is reliable: labels repeat and change, and four digits are not unique.
GET /accounts and the transactions endpoints. It is a standard cursor-paginated list (limit 1 through 500, default 100). Results come back in identity creation order, oldest first, regardless of labels, and paging is not a frozen snapshot. An empty inventory is a normal 200 with "data": [], has_more: false, and next_cursor: null.
Filtering by account
Passaccount_id with a stable ID from GET /accounts to narrow the list to that account’s cards. Filtering happens before pagination, and a cursor is bound to the exact filter it was issued under, so keep the same account_id on every page; to change filters, start a new sequence without a cursor. A known account with no linked cards returns an empty list, while an unknown, malformed, blank, or foreign account_id returns 404 resource_missing with param: "account_id". Every one of those cases gets the same generic response, so the error never reveals another company’s accounts.
Card-to-account relationships
account_id is populated only from provider records that prove the link: the existing Brex card-account, Mercury credit-account, and Slash account associations. Ramp and Rho cards, cards whose backing provider record is gone, and any relationship Finta cannot prove return account_id: null. Matching names or vendors never establish a relationship. The filter and the field agree by construction: a card serialized with account_id: null will not match any account_id filter.
Do not read a null account_id as proof the card can never belong to an account. Like the rest of the metadata, it can become known after a later synchronization.
What the inventory covers
Brex, Ramp, Mercury, and Slash cards have dedicated inventory: inactive cards, cards with no booked transactions, and identities retained after the provider deleted the card all appear. Rho is narrower: only cards already known from stored provider transaction metadata are listed, with a label when known and nullaccount_id, last_four, type, and status, and no provider-wide Rho fetch happens. Plaid-backed credit sources are accounts, not individual cards, so they appear in GET /accounts instead.
This inventory is deliberately broader than card filters derived from transactions, such as the ones in the Finta dashboard: a card with no booked transactions is still real inventory here.
Consuming the card inventory safely
Presence is not connectivity
Presence is not connectivity
GET /integrations.Key on the ID, not the label or last four
Key on the ID, not the label or last four
id is the durable identity: it survives renames, metadata changes, and provider deletions. name is nullable and not unique, and last_four is not unique either, so two cards can legitimately share both. Store and join on id.Deleted provider cards leave a stub
Deleted provider cards leave a stub
card_ ID and last known name remain discoverable, while account_id, last_four, type, and status return null. Treat such a stub as a retained identity, not as a live card.Null means unknown, and unknown can change
Null means unknown, and unknown can change
type and status are closed enums in the current spec, but avoid crashing if a future API version adds values.status is stored state, not a live check
status is stored state, not a live check
status reflects the provider state Finta last stored, normalized to active or inactive. It is not a live authorization or availability check, so do not treat active as permission to transact, and do not treat unknown (null) as either value.GET is read-only
GET is read-only
Automation rules
Automation rules are the company’s saved categorization automations: each rule matches transactions against a set of conditions and applies actions such as a merchant name, a category, or a department. The rules the API returns are the same ones managed in the Finta dashboard, and they come back as complete definitions, so there is no need for a retrieve call per row.GET /rules lists them, GET /rules/{id} reads one, and POST /rules creates one, optionally applying it to existing transactions. PATCH /rules/{id} changes a rule’s future matching, and DELETE /rules/{id} removes one.
Reading rules requires Accounting setup: View, and every write requires Accounting setup: Manage. Transactions: Manage is additionally required exactly where existing transactions can change: on a create that applies the rule to existing transactions, and on every delete. Updates never touch existing transactions, so they do not need it. The collection is the company’s own automation rules only: Finta’s global and internal system rules, and rules owned by a specific category or department, are never returned, and asking for one is indistinguishable from asking for a rule that does not exist.
limit 1 through 500, default 100), newest first. Pagination is live keyset pagination, not a snapshot, and an unusable cursor returns 404 resource_missing rather than a page. Retrieve returns exactly this rule object without the list envelope. A rule that cannot be represented faithfully fails explicitly with 503 rules_not_ready rather than being silently skipped or simplified.
A rule always carries id, object, conditions, and actions, and nothing else: no priority, no enable state, and no status for historical application. Rule IDs are stable, opaque rule_ identifiers. Do not assume a suffix length, and do not send numeric IDs or UUIDs; they are rejected before any lookup.
Conditions
conditions is a nonempty flat AND list: every condition must match for the rule to apply, and repeating a field expresses several constraints on it. There are no OR groups and no public condition IDs. Each condition is exactly field, operator, and value:
greater_than means greater than or equal to, less_than means less than or equal to, and between includes both ends. {"field": "amount_cents", "operator": "between", "value": [-2000, -100]} matches the inclusive signed interval -2000 through -100.
account and card_name are compatibility fields for rules that predate stable account and card identities. They are name-matching text predicates, returned with their original stored text and their original case-insensitive matching, under which duplicate names can genuinely match more than one account or card. Reads preserve them faithfully; the API never converts a legacy name into a guessed identity, and you should not either. New conditions must reference accounts and cards by account_id and card_id instead: the writes reject account and card_name. Handle every variant in this table when consuming rules, and tolerate new ones appearing in future.
Actions
actions is a nonempty object carrying only the configured actions. Unconfigured actions are absent, never null.
category_id and department_id references are returned faithfully even when the referenced record is no longer selectable for a new rule; do not substitute another one. A nonzero accounting-date offset cannot be combined with an Asset category action.
Creating a rule
apply_to_existing_transactions is a strict boolean defaulting to true: leave it on to have Finta apply the rule to existing transactions asynchronously, or send false to save a rule that only matches future imports. The flag is request-only. It is never returned as saved state, and it cannot be sent on an update.
A 201 confirms the rule is saved, not that historical application has finished. The requested work is durable (it survives transient enqueue failures and is recovered automatically), but there is no job object, no polling endpoint, and no Idempotency-Key to send. Historical application normally considers approved, uncategorized transactions; a department action also fills in eligible transactions that lack a department, and it never replaces a department someone already assigned. Accounting-date offsets never apply historically, though a mixed rule still applies its other actions. A rule outlives a failed historical run, and revoking the creator’s Transactions: Manage before the run starts cancels it.
Sending a definition that exactly matches an existing rule returns 409 rule_already_exists with the existing rule’s ID in the message, and never schedules a second run. Invalid definitions return 400 with a path to the offending input, and references you cannot use return 404 without revealing whether they exist elsewhere.
Updating a rule
PATCH /rules/{id} changes what a rule does from now on. It never runs historical application and never repairs past transactions, which is why it needs Accounting setup: Manage but not Transactions: Manage. Removing an action changes the rule, not the transactions that action already touched.
The two top-level fields behave differently, and the difference is the point of the endpoint:
200 always carries the complete saved rule, exactly as List and Retrieve would render it.
The merged result is validated as a whole, atomically. It must keep at least one condition and one action, every write rule from creating a rule applies (including the Asset-category and accounting-offset restriction), and an invalid update changes nothing. An empty body, an actions-only edit with an empty object, unknown fields, and apply_to_existing_transactions are all rejected; the apply flag is create-only. Explicitly resending a category_id or department_id requires it to be currently selectable or assignable, even when it matches the saved value, while omitting it preserves the saved reference even when it no longer is. Legacy account and card_name conditions survive actions-only edits, but cannot be submitted as new conditions.
A few behaviors worth building against:
- A no-op is a success. Sending what is already saved returns
200with the unchanged rule and does not cancel queued historical work. Condition order and text casing do not make a definition different. That makes an uncertain PATCH safe to retry: if the first attempt landed, the retry is a no-op200. - A real edit cancels a queued historical run from
POST /rulesbefore it starts. A run already executing instead returns503 rule_busyand leaves the rule untouched; retry with backoff. - Concurrent PATCHes merge; they do not clobber. Each merges into the latest committed definition, so two edits touching different actions both survive. An edit that would duplicate another rule’s definition returns
409 rule_already_exists.
Deleting a rule
DELETE /rules/{id} removes a rule. It takes the public ID in the path and nothing else: there is no request body at all, and anything you send, an empty {} included, is a 400 invalid_request.
204 No Content with an empty body. An ID already absent from your scope returns the same empty 204, the convention invitation revoke uses, so retrying after a lost response is safe and tells you the outcome. Retries never repeat the deletion’s effects, and there is no Idempotency-Key. Foreign-company rules and Finta’s system- and owner-specific rules are treated as absent and left untouched, so a 204 is not evidence the rule ever existed. A malformed ID is still a 400.
Deleting requires Accounting setup: Manage and Transactions: Manage on every request, absent-ID retries included, because deletion can change existing transactions. The permission check runs before the body and ID are validated, so a caller missing Transactions: Manage gets a 403 even for a malformed request.
What deletion does to existing transactions is specific, and narrower than an undo:
- Transactions directly linked to the rule can have their categories reset to amount-based defaults.
- Matched transfer legs keep their categories and are simply detached from the rule.
- Nothing else is reverted. Merchant names, departments, spreads, and accounting dates the rule applied stay where they are, and transactions that merely matched the rule at some point are not touched.
503 rule_busy without changes, and a deletion that fails on Finta’s side rolls everything back and returns 500 rule_deletion_failed, never a false 204. Deletion does not validate the stored definition first, so a rule the API cannot represent, one that returns 503 on retrieve, can still be deleted.
Consuming rules safely
Key on the ID, and treat it as opaque
Key on the ID, and treat it as opaque
rule_ IDs are stable and opaque, with no promised suffix length. Do not derive relationships from an ID’s suffix, translate IDs to internal database keys, or submit numeric IDs or UUIDs; the endpoints reject anything that is not a rule_ ID before looking it up.Reads fail closed
Reads fail closed
503 rules_not_ready, never a silently shortened list or a partial object. Retry with backoff rather than treating the response as data. On retrieve, only the requested rule matters; broken definitions elsewhere in the company do not block it.Expect legacy and future condition variants
Expect legacy and future condition variants
account_id and card_id breaks on older rules carrying account or card_name text conditions. Support every documented variant and tolerate unrecognized ones rather than failing validation.Reading is read-only
Reading is read-only
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 returning200 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. api_connectable is present on every entry and says whether the integration can be connected or reconnected through the API; see Connecting an integration.
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.
The remedy always needs a human to reauthenticate. For an integration with api_connectable: true, you can start that from the API: POST /integrations/{key}/connect returns a link the person opens to re-approve access, and no new connection is created. Every other integration is reauthenticated at finta.lol. 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
A missing key means 'not available', not 'not connected'
A missing key means 'not available', not 'not connected'
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.Order is not specified
Order is not specified
data is neither alphabetical nor stable across releases. Index the array by key rather than reading by position.Both enums are expected to grow
Both enums are expected to grow
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
keyas a new integration. Skip it or display it generically; do not throw. - Treat an unrecognized
typeas an “other” bucket rather than failing validation. - Treat an unrecognized
statusas not actionable: do not assume it meansconnected, and do not alert on it as though it wererequires_reconnect.
api_connectable changes over time
api_connectable changes over time
api_connectable can change from false to true for an integration at any time, with no other API change. Read it from the response rather than hard-coding or caching a list of connectable integrations.Poll slowly
Poll slowly
GET /integrations/{key}/connect instead. The list cannot tell you why an attempt failed.Connecting an integration
POST /integrations/{key}/connect starts connecting an integration, or reconnecting one whose status is requires_reconnect, without sending anyone into the Finta web app. GET /integrations/{key}/connect reports how that attempt is going.
The API cannot connect a provider by itself. These providers only grant access through a browser consent page, where the account holder signs in and approves, and no API grant skips that step. So the API hands back the provider’s authorization link, a person opens it and approves, and the provider sends them back to Finta, which completes the connection exactly as it does when the connection starts in the web app. Provider tokens never reach the API caller.
Which integrations can be connected
Only integrations withapi_connectable: true in GET /integrations. Read the field rather than hard-coding a list: it becomes true for more integrations over time with no other API change. Calling either connect endpoint for an integration with api_connectable: false returns 400 integration_not_connectable, and the message tells the user to connect it in the Finta web app.
{key} is the integration’s key from that list, such as mercury. A key for an integration the company cannot use, one of those GET /integrations omits, returns 404 resource_missing.
The connection flow
Start the connection
POST /integrations/{key}/connect. The response is a connect request with a url.Hand the link to the user
url to the user, or open it in their browser with their permission. They must open it in a browser where they are signed in to Finta as the owner of the API key that made the request. If a different Finta user completes it, the connection is rejected.The user approves at the provider
Check the result
GET /integrations/{key}/connect and follow the instructions in each response until status is complete, failed, or expired.201 Created:
The connect request object
Both endpoints return the same object, and every field is always present.authorizing when expires_at passes stays authorizing while Finta finishes it, rather than turning expired. Keep checking until one of the three terminal statuses.
One request at a time
A company has at most one open (pending or authorizing) connect request per integration.
- Calling
POSTagain while a request is open returns that same request with200and an identical body. Retries and repeated calls are safe; they never produce a second link. - After a request is
failedorexpired, the nextPOSTstarts a new one and returns201. There is no separate retry endpoint: to try again, callPOSTagain. - While a different user in the company holds the open request,
POSTreturns409 connect_link_in_progressuntil that request finishes or expires. The message gives the time it expires, which for a request alreadyauthorizingincludes the extra time Finta allows to finish it. POSTfor an integration that is already connected returns409 integration_already_connected. A new link is only available when the integration’s status isrequires_reconnect.
GET never changes anything. A failed or expired request stays that way, with its failure_code, until the next POST, so you always get to see how an attempt ended. GET only returns requests started by the calling API key’s owner; before that user has started one for this integration, it returns 404 resource_missing with a message saying to call POST.
Polling and API usage
Theinstructions ask you to poll GET /integrations/{key}/connect every 10 seconds while the request is pending or authorizing, and to stop at complete, failed, or expired.
Every call to either endpoint counts toward the per-minute rate limit and the company’s monthly API allowance, like any other API call. A link that is never opened stays pending for its full 15 minutes, so polling it every 10 seconds costs about 90 calls. On plans with a small monthly allowance, that can use up the month’s calls.
Permissions
The API key’s owner needs Integrations: Manage and Transactions: Manage, the same pair the web app checks before starting a banking connection. Both endpoints need this, includingGET, and API access: Manage still applies first, as on every endpoint. A missing permission returns 403 insufficient_permission.
Errors
Known limitations
- If a different Finta user opens the link, the connection is rejected after they approve at the provider. The request is not marked
failed; it stayspendinguntil it expires. - If the integration is connected in the web app while an API connect request is open, that request stays
pendinguntil it expires. CheckGET /integrationsfor the integration’s own status. - The link’s 15 minutes run from when the request was created, not from when it was opened.
- A user who is signed out of Finta when the provider sends them back has to sign in first. The provider’s authorization code can expire in the meantime.
Consuming connect requests safely
Treat the link as sensitive
Treat the link as sensitive
url contains a one-time state value. Do not log it or share it with anyone but the user who should open it.The enums are expected to grow
The enums are expected to grow
status, failure_code, and intent are closed sets today and may gain values.- Treat an unrecognized
failure_codelikeconnection_failed. - For an unrecognized
status, followinstructionsand stop polling unless they say to continue.
The team directory
UseGET /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 finta.lol.
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.
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.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
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:
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.
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.
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
idis still a member of the company. - Do not use
idas 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 finta.lol.
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
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.
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:
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 achanged flag:
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.
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 returns422 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 as200rather 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_requiredon 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.
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: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 same404 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 finta.lol.
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 always200 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 a500 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.
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.
{}, 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:
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.
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.
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: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 finta.lol.
Retrying a revocation safely
Revoke is the most retry-friendly write on the resource. Repeat the sameDELETE 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.Sign convention
Sign convention
net_income_cents. The same convention applies to cash flow: inflows are positive, outflows are negative.Point-in-time vs. period activity
Point-in-time vs. period activity
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).Report freshness
Report freshness
refreshed_at, the Unix timestamp for its latest data refresh. Finta returns null when that timestamp is unavailable.Category hierarchy
Category hierarchy
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.Section totals
Section totals
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.Drilling into line items
Drilling into line items
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.