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. To mirror the feed without refetching it on every run, use incremental sync. The accounts supplying that feed have stable identifiers of their own, and so do individual cards; see Discovering source accounts and Discovering individual cards. 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:

Retrieving one journal entry

If you already hold a single je_ 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.
The response is a single journal entry with exactly the same fields and rules as each item in the list’s 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.
Journal entry IDs are not permanent. Journal entries are accounting records derived from a source such as a transaction, a spread, or an invoice. When the books for that source are rebuilt, for example after a correction to the transaction, Finta deletes the source’s entries and creates new ones with new je_ IDs. There is no tombstone and no redirect, so an ID that retrieved successfully yesterday can return 404 resource_missing today. That is expected behavior, not an error in your integration:
  • Treat a 404 on a previously valid ID as “this entry no longer exists” and do not retry it.
  • Re-list to find the current entries: GET /journal_entries?transaction_id=txn_... when the entry had a transaction_id, or a category_id and date range otherwise.
  • Store transaction_id rather than je_ IDs when you need a durable key. Transaction IDs are stable.
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

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’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.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.

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:
  1. Discover. If you want to select by account or card, get stable IDs from GET /accounts and GET /cards. To select specific transactions, collect their txn_ IDs from GET /transactions.
  2. Submit. Send the selection and the edits with a fresh Idempotency-Key. Store the key, the body, and the returned job ID.
  3. Poll. Read the job at the returned Location until it reaches success or failed.
  4. Read errors. If the job failed, page through its errors to see why.
Both endpoints require Transactions: Manage, the same permission that edits a single transaction, and each is checked on every call, including polls and replays. 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

A 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. To clear every nullable field at once, send {"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 an Idempotency-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. When comparing bodies, the order of keys inside an object does not matter, but array order does, and an explicit 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_shift would 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.
Keys are released 30 days after their job finishes. After that, the same key starts brand-new work, which can shift dates again. Do not rely on replay protection beyond that window, and never wait out the expiry as a way to retry an unknown outcome.

Reading a job

A key owner who loses Transactions: Manage mid-job gets 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_count stays null until selection succeeds. It is null on a queued job, and it can still be null on a job that failed before selection finished.
  • updated_count counts 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 success with both counts at 0.
  • A failed job always reports updated_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:
The codes above are not a closed set; shared validation can report others. Some share a name with an HTTP error code, but these are entries inside a 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’s Location 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

On POST /transactions/bulk/update: None of these creates a job. A network error, timeout, or server error leaves the outcome unknown: retry with the same key and body, with bounded backoff. On GET /jobs/{id}: The authentication, account, and usage limit errors every endpoint can return apply to both.

Consuming bulk updates safely

The submission response is always the saved queued snapshot. Never treat a 202 as proof that edits happened; read the job for its outcome.
A job whose edits failed still comes back as 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.
Once a job is 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.
The first page of errors is not a summary. Follow next_cursor until has_more is false before deciding which transactions failed.
Treat an error 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

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.

Discovering source accounts

Every transaction names where it came from in its source 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.
Reading the list requires Transactions: View, the same permission as the transactions endpoints. It is a standard cursor-paginated list (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

The list is the local inventory, not a health check. It includes accounts with no booked transactions, and it retains identities whose provider records were deleted, still carrying their last known labels. It also does not promise complete coverage of everything at the remote provider. To find out whether data is still flowing, check GET /integrations.
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.
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.
The 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.
Listing accounts never creates or backfills identities and never changes bookkeeping. Every page still counts against the monthly call allowance.

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.
All three fields must be non-blank strings. They carry the same names as the fields on the account object, so you can send back what 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:
The body is the same either way, so use the status code to tell whether the account is new. The account appears in 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 same type whose vendor and account_name match:
  • Case does not matter, and leading, trailing, or repeated whitespace is ignored. chase checking 4821 at CHASE matches Chase Checking 4821 at Chase.
  • vendor matches either as originally sent or in the normalized form accounts return. An account created with JPMorgan is listed as jp morgan, and posting JPMorgan, jpmorgan, JPMORGAN, or jp morgan returns it instead of creating a second one. Resending your original request and posting vendor exactly as GET /accounts shows it both find the account.
  • type has to match too. A bank account and a credit account with the same vendor and name are different accounts.
After a timeout, a dropped connection, or a 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.
Only manual accounts are matched. An account that syncs from an integration is never matched or returned, so posting vendor: "Mercury" creates a new manual account even when the company has Mercury connected. Call GET /accounts first and use an existing account if one fits. Uploading a statement to a separate manual account for a bank that already syncs usually produces duplicate transactions.

Permissions and errors

Creating an account requires Transactions: Manage, one level above the Transactions: View that listing needs, so a key that can read GET /accounts can still get 403 insufficient_permission here. The authentication, account, and usage limit errors every endpoint can return apply here too.

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.
Reading the list requires Transactions: View, the same permission as 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

Pass account_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 null account_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

The list is the local inventory, not a health check or a live provider query. It does not promise that a listed card is currently usable, that the connection behind it works, or that everything at the remote provider is covered. For connection health, check GET /integrations.
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.
After a provider deletes a card, its 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.
Expect null metadata anywhere it is allowed, and expect previously null fields to become known after a later synchronization. type and status are closed enums in the current spec, but avoid crashing if a future API version adds values.
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.
Listing cards never creates or backfills identities and never changes bookkeeping. Every page still counts against the monthly call allowance.

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.
The list is a standard cursor-paginated list (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: Amounts keep their signs: there is no absolute-value conversion, no currency conversion, and no separate direction field, so a signed bound is how a rule constrains inflows versus outflows. The comparison operators are inclusive: 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. Saved 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

The body is the same conditions-and-actions shape the reads return, plus one create-only flag. 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: An actions-only edit is a merge; a conditions edit is a replacement. Starting from a rule with a merchant, a category, and a department:
The merchant changed, the untouched category survived, and the department is gone from the response, because removed actions are omitted rather than nulled. A 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 200 with 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-op 200.
  • A real edit cancels a queued historical run from POST /rules before it starts. A run already executing instead returns 503 rule_busy and 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.
Success is 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.
A queued historical run is cancelled atomically with the deletion. A run already executing returns 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

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.
A rule that cannot be represented faithfully is an explicit 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.
A consumer that handles only 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.
Listing and retrieving rules never assign missing IDs, run rules, enqueue historical work, or change transactions. Every call still counts against the monthly call allowance.

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. 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

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.
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.
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.To follow a connection you started, check 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 with api_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

1

Start the connection

Call POST /integrations/{key}/connect. The response is a connect request with a url.
2

Hand the link to the user

Show the 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.
3

The user approves at the provider

The user signs in at the provider and approves access. Do not fill in, click through, or otherwise automate the provider’s page. The approval has to come from the person.
4

Check the result

Call GET /integrations/{key}/connect and follow the instructions in each response until status is complete, failed, or expired.
Starting a connection:
A new request comes back as 201 Created:
Checking on it later:

The connect request object

Both endpoints return the same object, and every field is always present. A request that is already 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 POST again while a request is open returns that same request with 200 and an identical body. Retries and repeated calls are safe; they never produce a second link.
  • After a request is failed or expired, the next POST starts a new one and returns 201. There is no separate retry endpoint: to try again, call POST again.
  • While a different user in the company holds the open request, POST returns 409 connect_link_in_progress until that request finishes or expires. The message gives the time it expires, which for a request already authorizing includes the extra time Finta allows to finish it.
  • POST for an integration that is already connected returns 409 integration_already_connected. A new link is only available when the integration’s status is requires_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

The instructions 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.
Where you can talk to the user, hand over the link, ask them to say when they have approved it, then call GET once. Poll only when you have to wait on your own.

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, including GET, 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 stays pending until it expires.
  • If the integration is connected in the web app while an API connect request is open, that request stays pending until it expires. Check GET /integrations for 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

status, failure_code, and intent are closed sets today and may gain values.
  • Treat an unrecognized failure_code like connection_failed.
  • For an unrecognized status, follow instructions and stop polling unless they say to continue.

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 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.
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 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

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 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 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 finta.lol.

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.