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.
Choosing a category
GET /categories returns description and accounting_treatment on every category. Read description before choosing a category for a transaction: it is the same guidance Finta’s AI categorizer uses, maintained by the Finta team and read when you make the request. The text can change between requests. An empty string means no guidance has been written yet; the category is still returned.
accounting_treatment explains what Finta automatically does to the books when a transaction is categorized there. This treatment cannot be turned off. Today only Equipment has a value:
Depreciated straight-line over 5 years: the purchase is recorded as an asset and expensed monthly as depreciation.All other categories return
null. Both keys are always present, but clients must handle an empty description and a null accounting treatment. These fields describe existing categories and bookkeeping; category selection and filters are unchanged.
Adding a category
UsePOST /categories to add one of the standard templates available under Settings > Categories. Requires API access: Manage and Accounting setup: Manage. List categories first and reuse an existing category where possible.
template. You cannot supply a name or parent or invent a category.
201 Created means this call added some or all of the template’s categories, or repaired an existing one. Repairing links an existing same-name category that is missing its template key instead of adding a duplicate. For Equipment, linking the category turns on automatic straight-line depreciation over 5 years. 200 OK means all already existed as the template’s categories and nothing changed. Both return the same list envelope as GET /categories: object: "list", url: "/api/v1/categories", and data containing every category of the template. This is not the company’s entire chart. Equipment returns two rows; R&D returns five. Use their cat_ IDs immediately, and read their description and accounting_treatment as described above.
Retry the identical request after a timeout, dropped connection or 500 category_create_failed, with exponential backoff. No idempotency key is needed. Concurrent adds and retries reuse existing rows instead of creating duplicates.
Finta-only templates are never created here. If absent, they return 403 request_required: request them in Settings > Categories instead. Once Finta has added the template, this call returns its categories with 200 if the plan permits it. The plan check runs first: below Startup, R&D and the three Finta-only templates return 403 plan_upgrade_required. Upgrade in Settings > Plans, then retry. MCP callers should ask through submit_feedback, explaining what the company will use the category for.
Unknown fields, invalid JSON, the wrong content type, a body that is not a JSON object, an unknown or unavailable template, a non-string key, and the retired revenue key return 400 invalid_request. A missing or empty template returns 400 parameter_missing. Correct the input before retrying, and use param to locate the field when present. param is omitted for invalid JSON, the wrong content type, or a non-object body because no single field is at fault. A key owner without Accounting setup: Manage, or a read-only key, receives 403 insufficient_permission; resolve access before retrying. Existing authentication and usage limits still apply. Use the published template enum and never substitute a different template for an unsupported category.
Tax credits
GET /tax_credits returns one US R&D credit per tax year, including estimates, undecided years, and declined years. GET /tax_credits/{id} returns one credit. Both need Taxes: View and API access: Manage.
These records describe credit amounts and claim progress. A filed income-tax claim, a payroll allocation, and submission to payroll are separate facts; none establishes that a refund was received. See Tax credits for the full response, filters, amount sources, and status definitions.
1099 filing records
GET /forms/1099 returns individual tracked vendor filings plus complete yearly summaries in one call. A record exists before a PDF is prepared, so its stage can indicate unconfirmed requirements, missing W-9s, processing, completion, or that filing is not required.
Omit year to discover all available years. Other filters and pagination affect the individual records, while yearly summaries depend only on year. Use a stable f1099_ ID for retrieval; vendor display names are not unique and may appear in multiple merchant/category records in one year. See 1099 Filings for stage definitions, counts, empty states, and document links.
Key metrics
GET /metric_values returns a company’s key metrics already worked out, one row per month: cash, runway, revenue, revenue growth, expenses, gross margin and net income. Use it for questions like “what’s our runway?”, “how fast is revenue growing?” or “what did we burn last month?” instead of fetching full statements and computing the numbers yourself. Every caller gets the same answer, because the definitions are fixed.
GET /metrics lists the metrics with their keys and definitions. Both endpoints need Reports: View, the same as the financial statements. They don’t need Accounting setup access.
The metrics
A key’s suffix is its unit:_cents is integer US cents, _percent is a percentage and _months is a whole number of months. A metric’s group says where it appears in a row:
point_in_time: read on one date, the row’sas_of, like a balance.period: over the row’sstart_datetoend_date, like a total.
definition returned by GET /metrics is the authoritative wording. A few points worth knowing:
- Burn is
net_income_cents. There is no separate burn key. - Growth is a whole number.
12means revenue is up 12%;-40means down 40%. Month-over-month and year-over-year are separate keys, so there is no guessing what “growth” compares against. - Runway is already computed. To answer a runway question, ask for
runway_monthsalone; you don’t need cash and net income too. - Runway has no cap. A company that burns little next to its cash can see a runway of thousands of months. That is the exact number, not an error. The Finta dashboard shows 120 months or more as “10+ yrs”, and the
get_metric_valuesMCP tool tells agents to say “10+ years”. If you show runway to people, consider doing the same. - Revenue and expenses can differ from the income statement. Revenue here is gross, before discounts, credits and refunds, and expenses include taxes and cost of revenue, so neither has to match a section of
GET /reports/income_statement.
GET /metrics rather than hardcoding the list, and ignore keys you don’t recognize.Requesting metric values
metrics is required: pass a comma-separated list of the keys your question needs. There is no “everything” option. Rows list the requested metrics in catalog order, whatever order you asked for them in.
start_datemoves to the first day of its month. It defaults toend_date’s month.end_datemoves to the last day of its month, or to today for the current month. It defaults to today, and a futureend_dateis capped at today.- Without dates, you get the current month to date, one row. Pass dates to see other months.
- One request covers at most 36 months.
group_byaccepts onlymonth, the default. Other groupings may be added later.
The metric values response
Asked on October 30, 2026, the request above returns September and October to date:start_date, end_date), the grouping, the currency (always USD) and the keys each row holds. data has one row per month, oldest first:
null. A requested metric that is null doesn’t apply to that month, as its definition says, for example runway_months in a month with no loss.
Combining months
Revenue, expenses and net income can be added across months to get a quarter or a year. Point-in-time values, percentages and runway can’t: year-end cash is the last month’s cash, not the sum of twelve balances, and a year’s growth is not the sum of twelve monthly growth figures. Work out a quarter’s growth or margin from its summed amounts.Metric value errors
Every request error is400 with type: invalid_request_error:
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 calendar dates in America/Los_Angeles (Pacific Time). Periodic totals include exactly start_date through end_date, inclusive. Point-in-time totals include all activity through the exact date. Determine today in Pacific Time before sending it; the UTC calendar date can be one day later.
start_month and end_month (in YYYY-MM format) are deprecated but still select whole months. Periodic categories take both; point-in-time categories take only end_month and include its last day. A single request cannot mix month parameters with full-date parameters. Responses retain month aliases alongside the exact dates used; a month alias does not mean the calculation rounded full-date inputs.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.
Fast rollups and exact live boundaries
Category-grouped income statements, balance sheets, cash flow reports, and category totals use the same date-source rules. Department-grouped income statements read the entire requested range directly from journal entries.Whole-answer freshness
refreshed_at is a Unix timestamp in seconds covering the whole total, report or grouped page, including partial months. Rollup-only reads use the maximum contributing rollup timestamp, including MTD. Any live journal-entry portion contributes the call time, even when no entries match. A fresh MTD row with zero activity through today still contributes its timestamp, without creating a merchant group for future-only activity.
Department-grouped income statements are an exception: they always return refreshed_at: null because that report path supplies no freshness timestamp.
An empty result can therefore have non-null freshness. Null means there was neither a contributing timestamp nor a live portion; it is not a test for zero activity. Response dates are the boundaries actually used, not secretly rounded month boundaries.
Grouped-page freshness covers its returned groups. In a monthly breakdown, a later month’s MTD or live read does not advance an earlier page’s timestamp. An empty live-read page still contributes the call time, and an empty merchant page backed by zero MTD retains that MTD timestamp.
Grouping a category total
GET /aggregations/totals splits one category’s total into rows in a single call: one row per merchant, ranked by amount, or one row per calendar month. Use it for questions like “which vendors do we spend the most with?”, “who are our top 10 customers?”, or “what did we spend each month this year?” instead of calling GET /aggregations/total once per merchant or once per month.
It takes the same category_id, dates, and merchant filter as GET /aggregations/total, plus a required group_by of merchant or month, and needs the same Reports: View permission.
How the rows add up
Rows are calculated from exactly the same data asGET /aggregations/total, so they reconcile with it:
- Merchant rows, across all pages, add up to
GET /aggregations/totalfor the same category, dates, and merchant filter. - Month rows for a periodic category (
point_in_time: false) add up to that total too. The first and last rows include only the selected days, and months with no activity are included with zero amounts. - Month rows for a point-in-time category (
point_in_time: true) are cumulative closing balances. Intermediate rows close at month-end; the last calendar month’s row closes at the exactend_date. The first row includes earlier activity, becausestart_dateselects the first displayed month rather than the beginning of cumulative activity. Do not add balances together. The last calendar month’s row equalsGET /aggregations/totalwithdate=end_date.
point_in_time, so check it before summing rows.
Dates for grouped totals
Grouped totals use the same exact inclusive Pacific dates and rollup/live source rules as a single total. Which dates to send depends on the grouping as well as the category:start_month and end_month that GET /aggregations/total still accepts are not supported here. period and department_id are rejected, as on the single total.
For example, a periodic range from 2026-02-15 through 2026-03-12 includes February 15–28 in February’s row and March 1–12 in March’s row. The two rows add up to the single total for those dates.
What a merchant row is
A merchant is the counterparty name recorded on each journal entry, whoever is on the other side of the money. Under an expense category the rows are vendors you paid; under an income category they are customers who paid you. It is the same nameGET /parties/merchants returns and the merchant filter matches.
Names are grouped exactly as recorded, so one vendor recorded under different spellings, such as AWS and Amazon Web Services, appears as separate rows. Activity recorded without a merchant name is one row with merchant: null, which keeps the rows adding up to the category total. Merchants with no activity in the period do not appear. If you present a ranking, consider merging obvious spelling variants and saying that you did.
Ordering
order ranks rows by amount in the direction the category normally grows, not by the raw signed total_cents. For assets and expenses that is debits minus credits; for income, liabilities, and equity it is credits minus debits. So desc means “biggest first” for every category:
asc is the exact reverse.
desc list shows total_cents such as -4825000, -182340, -200 down the page. The numbers rise because expense totals are negative, but each row is a smaller spend than the one above it. A merchant whose refunds exceeded its charges has a positive total_cents under an expense category, so it comes last with desc and first with asc.Paging grouped totals
limit (1 through 500) always returns the first rows of whichever order applies:
has_more is true, pass next_cursor back unchanged as starting_after, with every other parameter the same. The cursor is bound to the request that produced it: changing the category, exact dates (including just a day within the same month), merchant filter, group_by, or order returns 400 invalid_request with param: "starting_after" rather than silently skipping rows. Pages are not a frozen snapshot, so rows can shift between pages if the data changes between requests.
The grouped response
object (merchant_total or month_total), either merchant (the exact name, or null) or month (YYYY-MM), and three amounts in integer cents:
total_centsis signed likeGET /aggregations/total: debits minus credits for assets, credits minus debits for every other category, so expenses are negative and income is positive.debit_centsandcredit_centsare the unsigned contributing amounts before that calculation. Point-in-time month rows are cumulative through month-end, except the last calendar month’s row closes at the exactend_date. Periodic month rows include only the requested days within that month.
Grouped total errors
Consuming grouped totals safely
Switch on each row's object
Switch on each row's object
group_by may gain values later, such as another dimension. Read each row’s object rather than assuming every row is a merchant_total or a month_total.Treat the cursor as opaque
Treat the cursor as opaque
next_cursor is an opaque string whose contents and length may change. Pass it back exactly as issued, with the same parameters.Never add point-in-time month rows
Never add point-in-time month rows
point_in_time in the response, and read the last row for the balance at the end of the range.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.
To rank those merchants by amount, use grouped totals with group_by=merchant rather than calling GET /aggregations/total once per name.
Finding transactions by amount
GET /transactions accepts amount_cents for one exact signed amount. Use integer cents: a $35.27 charge is -3527, a $4,500 deposit is 450000, and a zero transaction is 0. The sign matters; a charge and refund of the same size need different queries. There is no range or approximate match.
amount_cents=450000&source_type=bank&transaction_type=standard. An amount match identifies candidates; it does not establish which invoice a deposit paid.
The value must be an integer of at most 100000000000000 in absolute value. Dollars such as 35.27, text, and larger values return 400 invalid_request with param: "amount_cents" instead of widening the search. The filter applies to incremental sync too, matching the transaction’s current state. It is not supported in the filters for POST /transactions/bulk/update.
Finding transactions by department
GET /transactions takes two department filters: department_id for transactions in one department, and no_department=true for the transactions that still need one. Both combine with the other transaction filters (all must match) and with cursor pagination, and both work in the filters for bulk updates. Departments are in early access: a company without them gets 403 for either filter, as described in Departments are early access.
In one department
department_id=dept_... returns transactions in that department. Get IDs from GET /departments.
- Exact match. A parent department does not include its sub-departments: filtering on Engineering does not return transactions in Engineering › Platform. This is the same as the
department_idfilter onGET /journal_entries, and unlikecategory_id, which includes child categories. To cover a parent and its children, make one request per department. - An unknown department is an error, not an empty list. A
dept_ID that doesn’t exist, belongs to another company, or isn’t visible to you returns404 resource_missingwithparam: "department_id". A company without departments gets403for anydepartment_idinstead; see Departments are early access.
Still needing a department
no_department=true returns exactly the transactions on the No Department page in the Finta app. Most transactions without a department are fine, because only some kinds of transactions ever take one. A transaction is returned when all of these are true:
- It is approved, not pending.
- Its
accounting_dateis on or after the company’s department cutoff, the date in the company’s department settings before which departments are not required. A transaction with noaccounting_dateis not returned. - Its top-level category is an expense, and the category is not Taxes, Gain/Loss, Hosting, or Compute & AI.
- It is not a transfer, and it is not split by department (its split lines carry the departments).
- It has no department.
department_id: null and department_name: null. These transactions also have no department, but are not returned:
true is accepted. no_department=false, or any other value, returns 400 invalid_request with param: "no_department". It is refused rather than given a meaning, because the opposite of the No Department page is not “transactions that have a department”: revenue, transfers, taxes, and pre-cutoff expenses have no department either. To list transactions in a department, use department_id.
The filter doesn’t check whether the company uses departments. For a company that doesn’t, it returns its expenses after the default cutoff, which is effectively all of them.
Both filters match a transaction’s current state, including in incremental sync. A transaction stops matching no_department=true once it gets a department, so read the filter hazard before combining either filter with updated_after to maintain a copy.
Tags
Tags are labels you define in Settings to break a category down further, for example Vercel and Webshare under Infrastructure. Each transaction can have at most one tag, and on the Dashboard untagged amounts appear as Other. Tags do not change your books, taxes or reports.Managing tags
Every call requires API access: Manage, plus the permission below. You do not need Transactions: View or Transactions: Manage to list or manage tags.object: "list", url: "/api/v1/tags", and data containing the tags. There are no pagination keys.
Create and rename accept only name, as a JSON object with Content-Type: application/json:
Other is reserved in any letter case. Invalid names, missing or non-string names, unknown fields, invalid JSON, the wrong content type or a body that is not a JSON object return 400 invalid_request. Correct the input before retrying.
Create returns 201; rename returns 200. Both return one tag object, in the same shape as an item in the list:
transaction_count counts visible transactions carrying the tag. vendor_count counts their distinct vendors, and vendor_names contains up to five vendor names ranked by transaction count. All three are null without Transactions: View. Null means you cannot see those statistics, not that there are no transactions or vendors.
Tag limits
Each company can create a limited number of tags, set by its plan:POST /tags returns 403 plan_upgrade_required and creates nothing. Renaming, deleting and assigning existing tags still work. This is separate from the monthly usage limits and sends no retry headers.
GET /tags, or ask the user to upgrade in Settings > Plans and retry once the upgrade completes. Retrying without a higher limit or fewer tags fails again. Don’t delete tags to make room unless the user asks for it. The limit is checked before the name, so at the limit even a duplicate name returns plan_upgrade_required.
Under the limit, a repeated create or a rename to an existing name returns 409 tag_already_exists. List tags and reuse the existing ID, or choose a different name when renaming. A successful delete returns 204 with no body. It removes the tag from every transaction carrying it, but does not delete the transactions. A second delete returns 404 resource_missing. Retry 503 tag_busy or 500 tag_deletion_failed with bounded exponential backoff; a delete retried after an uncertain success can return 404 because the tag is already gone.
Tagging a transaction
UsePATCH /transactions/{id} with a tag_ ID from the list. Requires API access: Manage and Transactions: Manage.
{"tag_id":null} to remove the tag; omit the field to leave it unchanged. Tag changes are atomic with the other requested fields: if any change fails, none apply. An unknown or inaccessible tag returns 404 resource_missing. A concurrent tag conflict returns 503 tag_busy with no fields changed; retry the same body with bounded exponential backoff. Split-part IDs return 404 resource_missing with param: "id"; do not try to tag individual split lines. Bulk updates do not set tags. To tag future matching transactions, create a rule.
Transaction list, retrieve and update responses always include nullable tag_id and tag_name. Both are null when the transaction has no tag. Otherwise, tag_id is the stable ID and tag_name is the current display name.
Finding tagged transactions
Passtag_id to GET /transactions. Requires API access: Manage and Transactions: View.
404 resource_missing, including another company’s tag. Other filters and pagination work as usual. The filter matches current state, so read the incremental-sync filter hazard before using it for a mirror.
Renaming a category, department or tag does not advance its transactions’ updated timestamps. Store their IDs and refresh names from GET /categories, GET /departments and GET /tags. An updated_after sync does not deliver label-only renames.
Splits and spreads
Two terms are easy to confuse:- A split divides one transaction’s amount into lines, each with its own amount, category, and department. For example, a $100.00 charge split into $60.00 Meals and $40.00 Office Supplies. The lines keep the transaction’s date and add up to its amount. A split line belongs to its split transaction and has no ID of its own.
- A spread recognizes an amount evenly over several months instead of all at once. For example, a $12,000.00 annual software payment spread over 12 months counts as $1,000.00 a month in reports. A spread sits on a whole transaction or on one split line.
GET /transactions lists each split transaction once, at its full amount, with transaction_type: split. It never lists the lines. Two read-only lists fill that gap. Both need Transactions: View, the same as GET /transactions. To create, change, or remove a split, see Splitting transactions.
Listing split lines
GET /transactions/splits returns one transaction_split row per line, newest transaction date first:
transaction_id is the split transaction’s txn_ ID from GET /transactions. amount_cents is signed like a transaction’s, and a split’s lines add up to its amount_cents. merchant is the line’s own name, which can differ from the split transaction’s. spread is the number of months the line is spread over, or null.
Filter with transaction_id for one split’s lines, start_date and end_date on the transaction date, and spread=true or spread=false. A transaction that isn’t split returns an empty list; an unknown transaction_id returns 404 resource_missing.
Only splits by category have lines. A department split (one amount divided across departments within one category) and a payroll bulk split are not listed here. Their transactions still appear in GET /transactions with transaction_type: split, and their accounting in GET /journal_entries with source_type: split.
To get every split’s breakdown, page through GET /transactions/splits with no transaction_id, rather than calling it once per split. To pair the lines with their transactions’ bank details, also page through GET /transactions?transaction_type=split and join the two on transaction_id.
Listing spreads
GET /spreads returns approved spreads, newest transaction date first: the same list as the Spreads page in the Finta app. Because a spread can sit on a split line, counting transactions with spread set in GET /transactions can come up short; this list includes both kinds.
split is true when the spread is on one line of a split transaction. transaction_id is then the split transaction, and the amount, category, and dates are the line’s, matching its row in GET /transactions/splits. When split is false, they describe the whole transaction. months is at least 2. Spreads have no ID of their own.
Filter with transaction_id (spreads on that transaction or on its lines), and start_date and end_date on the transaction date.
Only approved spreads are listed. A spread awaiting approval or being removed is not, even though the transaction’s spread field may already show its months, so GET /spreads can briefly list fewer spreads than there are transactions with spread set. The monthly amounts a spread books are journal entries with source_type=spread.
Paging and errors
Both lists use the usuallimit (1 to 500, default 100) and starting_after. Because rows have no ID, next_cursor is an opaque token, not a row ID. Pass it back unchanged with the same filters. A malformed cursor, a cursor from the other list, or one with an out-of-range date or ID returns 400 invalid_request with param: "starting_after".
Unlike GET /transactions, which ignores an unreadable date, these lists refuse one with 400 invalid_date. A spread filter other than true or false returns 400 invalid_request.
Consuming splits and spreads safely
- Rows may gain fields later, possibly an
id. Ignore fields you don’t recognize. - Department splits and payroll bulk splits may be added to
GET /transactions/splitslater. Don’t assume every line is a category split. - These lists are read-only. Change a split’s lines, including their spreads, with
PATCH /transactions/{id}/split. Updating a transaction does not work on split transactions.
Updating transactions
v1’s writes are narrow: editing one transaction or many at once, creating a manual account, adding a category template, managing tags, uploading transactions from a file, matching and unmatching transfers, splitting transactions, the four operations on a team invitation, the three on an automation rule, starting an integration connection, and recording a tax return filed outside Finta with the PDF uploads it takes. Everything else is read-only.PATCH /transactions/{id} accepts any non-empty subset of six 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 tag_id, tag_name, and 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 (change a split’s lines with PATCH /transactions/{id}/split instead), and neither are non-selectable categories or departments that are not assignable leaves. A transaction, category, department, or tag 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.
The Matched Transfer category can’t be assigned here. Sending its category_id returns 404 resource_missing with param: "category_id" and a message pointing to POST /transfers, which pairs both sides of a transfer instead. See Matching transfers.
Bulk updating transactions
POST /transactions/bulk/update applies 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, card, or department, get stable IDs from
GET /accounts,GET /cards, andGET /departments. 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.
Bulk updates do not accept tag_id as a filter or update. To assign or remove a tag, update one transaction per call.
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 eleven 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, card_id, and department_id are checked when you submit. An unknown ID, or one belonging to another company, returns 404 resource_missing with param naming the field, such as filters.department_id; a real card that does not belong to the given account returns 400 invalid_request with param: "card_id". A company without departments gets 403 for a department_id or no_department filter, or a department_id update including null, before any lookup; see Departments are early access.
Filling in departments in bulk
To give a department to transactions that don’t have one yet, filter withno_department: true alongside the other filters. Transactions that already have a department are left alone, where a filter on merchant alone would overwrite them:
202 queued job. The department you assign must be an assignable leaf department.
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.
With a department filter, these changes to a selected transaction also fail the job with transaction_conflict:
- It gets a department, or moves to a different one (
no_departmentordepartment_id). - Its accounting date moves (
no_department). - The company moves its department cutoff past it after the job selected it (
no_department). A cutoff change made while the job is applying its edits waits for the job to finish.
"message": "The transaction no longer needs a department under the current cutoff.". Submitting again with a new Idempotency-Key selects the transactions that match now.
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.Uploading transactions
POST /transaction_uploads adds bank or credit card transactions from a file to one account. Use it for an account that does not sync, such as a bank only reachable through a statement export; create a manual account first if the company does not have one yet.
You read the file and send its rows. Finta does the checking: it confirms the rows add up to the file’s own total, finds rows it cannot read, catches likely duplicates, reversals, and investment trades, and asks when the file is ambiguous. Nothing appears in the company’s books until nothing blocks the upload and you commit it.
Uploading requires Transactions: Manage.
How an upload works
An upload is a hidden draft that builds up over several calls to the same endpoint:- Open it. Send
account_id,file_totalscomputed from the whole file,columns, and the first chunk ofrows. The response is201 Createdwith the upload’supl_ID. - Add rows and answers. Send
upload_idwith the next chunk of rows, plus any answers (date_format,sign_convention), exclusions (exclude), or acknowledgements (acknowledged_warnings). Each response is200 OKand validates every row received so far, not just the new ones. - Commit. Send
commit: true, on the last chunk or on its own. If nothing blocks the upload, it is imported. Otherwise the response lists what still blocks it, and nothing is imported. - Check on a slow import. A commit waits up to about 20 seconds for rules and auto-categorization. If they are still running,
statusisimporting. Posting onlyupload_idreturns the current state and changes nothing.
commit: true.
- At most 500 rows per call and 2,000 per upload. Split a larger file by date range into several uploads.
- Send every row of the file, including the ones you want left out, and mark those in
excludewith a reason.file_totalscovers the whole file, so the totals check only works when every row is sent, and excluded rows are kept as a record of what was left out and why. - Row numbers are keys. Each row carries its own 1-based
rownumber, and sending a row number again replaces that row. That is how you fix a row the response reports as an error. file_totalscannot change after the first call. If it was wrong, start a new upload.- Only the person whose key started an upload can continue it. Anyone else gets
404. - Uploading doesn’t match transfers. After committing a file that contains transfers to or from another of the company’s accounts in Finta, pair each one with
POST /transfers, unless its transactions already have atransfer_id.
Upload request fields
Content-Type: application/json, and unknown fields are rejected with 400.
Dates are sent exactly as the file writes them, such as 03/04/2026, so Finta can work out month/day versus day/month from the whole file. A lone 03/04/2026 could be March or April, but other rows usually settle it (a 13/04/2026 proves day-first), and a wrong guess would shift every row silently. Year-first dates such as 2026-03-04 are also accepted. Two-digit years up to next year are read as this century (26 is 2026, 95 is 1995).
Descriptions can be at most 40 characters. A longer one is reported as a description_too_long error on that row; shorten it and resend the row.
Amounts must be signed decimals such as -45.00 or 1200.50. Convert formats such as (45.00), \$45.00, or 1,200.50 before sending. A zero amount is an error: exclude zero rows with reason not_a_transaction.
Signs. Finta records money out as negative, and by default reads the file the same way (negative_is_money_out). Card exports often show charges as positive; for those, pass sign_convention: "positive_is_money_out" and Finta flips every amount. Never flip signs yourself.
The upload object
Every successful call returns the upload:Errors, warnings, and open questions
Each error and warning hascode, rows (the affected row numbers; empty for whole-upload issues), message, and suggestion. Warnings also carry blocking and acknowledged.
Errors must be fixed by resending corrected rows or excluding them. There is no override.
blocking: true stop the commit until each listed row is excluded or the code is acknowledged in acknowledged_warnings. An acknowledgement covers the rows the warning listed at that time; if new rows add to the warning, it needs acknowledging again.
summary.skipped_exact_duplicates and result.skipped_exact_duplicates. Each existing transaction absorbs one row, so two identical coffees in the file against one in Finta still import one.
Finta compares against the account’s own transactions, matched the same way the account_id filter on GET /transactions matches: what the account’s integration synced, plus every earlier file upload to it, across every record behind the acct_ ID (an integration reconnect can replace the underlying record while the ID stays the same). Account names are not used, so the same transaction is found however an earlier import labeled it, and another account’s transactions never count, even when the two accounts share a name. The same set drives possible_duplicate and overlaps_existing_data.
Open questions have code, parameter (the field that answers it), question, suggestion, default, and why_default. Answer each by sending its parameter. Ask the user only when the file and context cannot settle it; otherwise send default.
Retrying an upload safely
- Resending a chunk is safe. Rows are keyed by row number, so a repeat replaces the same rows.
- Retrying a commit is safe. An upload commits once. Repeating
commit: trueafter a successful commit returns the upload’s status. - After a
503 upload_commit_failed, commit again with theupload_idnamed in the message. Nothing was imported and the draft was kept. The message names the upload because a first call that commits has not otherwise returned its ID. - After a network failure on the first call, you have no
upload_id. Starting a new upload is fine; the abandoned draft is never imported.
upload_id.
Upload errors
200 or 201 as entries in the upload’s errors and warnings, so read those on every response. The authentication, account, and usage limit errors every endpoint can return apply here too.
Consuming uploads safely
Follow next_step
Follow next_step
next_step, which always names what is left to do.Only imported and failed are final
Only imported and failed are final
status values may be added. Treat anything other than imported and failed as still in progress, and keep checking by posting only upload_id.Check the reconciled-through date first
Check the reconciled-through date first
reconciled_through always get the reconciled_period error. Read the date from GET /accounts before the first call, and send only later rows unless the user unreconciles the period in Finta. The comparison is inclusive: a row dated exactly on reconciled_through is refused.A synced account usually means duplicates
A synced account usually means duplicates
allow_synced_account exists for filling a real gap in an integration’s data. Confirm with the user that the sync is missing those dates before sending it.Matching transfers
A transfer is money moving between two of the company’s own accounts, such as checking to savings, or a bank account paying the company credit card. It isn’t income or an expense, but until it is matched, each side looks like one: the money out of one account and the money into the other are separate transactions. A matched transfer pairs those two transactions. Both move to the Matched Transfer category and both show the transfer’stfr_ ID in transfer_id, so neither counts as an expense or income in reports.
Finta matches some transfers automatically, for certain pairs of connected accounts. The rest need matching in the Finta app or with the API. That includes transfers involving uploaded transactions, since uploading doesn’t match anything:
PATCH /transactions/{id}; matching is the only way to assign it.
Matching a transfer
POST /transfers does the same as Match transfer in the Finta app. Send the outgoing transaction (the money-out side, with a negative amount_cents) and the incoming one (the money-in side in the other account, with the opposite, positive amount_cents):
201 Created with the new transfer:
- Be for opposite amounts, the outgoing one negative and the incoming one positive.
- Be dated within 14 days of each other.
- Be in different accounts.
- Have at least one side uncategorized or pending.
- Not include a transaction that already belongs to a transfer.
- Not include a transaction that paid an invoice. Unmatch the invoice payment first.
Finding the other side
There is no endpoint that suggests candidates. Given one side, list transactions with the oppositeamount_cents, transaction_type=standard, and a date range around the first transaction’s date:
transfer_id. Finta’s automatic matching can occasionally pair an uploaded transaction on a later sync, for example an uploaded deposit with a Stripe payout.
Unmatching a transfer
DELETE /transfers/{id} does the same as Unmatch in the Finta app. The id is the transfer’s tfr_ ID, the transfer_id of either transaction. Send no body; it returns 204 No Content.
Transfer errors
Consuming transfers safely
Check transfer_id after a lost response
Check transfer_id after a lost response
409 transaction_already_matched, and unmatching the same transfer again returns 404. After a timeout, read the transactions with GET /transactions/{id} and check their transfer_id to see whether the first request went through.A rematch gets a new ID
A rematch gets a new ID
tfr_ ID, so don’t keep the old one.Handle unknown transfer types
Handle unknown transfer types
transfer_type is bank_transfer or credit_card_payment today. Treat an unknown value as a generic transfer rather than failing.Splitting transactions
A split divides one bank or card transaction into lines, each booked to its own category. A $1,000.00 Amazon order might be split into $600.00 of office supplies and $400.00 of software. Each line has its own amount, category, merchant, department, spread, and accounting date, and the lines always add up to the transaction’s amount. These endpoints do the same as splitting a transaction in the Finta app:GET /transactions/splits lists. Department splits and payroll splits divide a transaction differently and can only be changed in the Finta app.
Splitting a transaction
POST /transactions/{id}/split takes the transaction’s txn_ ID in the path and 2 to 100 lines:
201 Created with the split’s lines, in the order they were sent. It is the same list GET /transactions/splits returns for this transaction, so has_more is always false and next_cursor always null:
department_id, so it kept the transaction’s department (Engineering). The second sent null, so it has none. The second line also left out merchant, so it took the transaction’s.
After the split, GET /transactions still lists the transaction once, at its full amount, now with transaction_type: split, the Split category, and status: approved. Splitting approves a pending transaction, as in the app.
Split lines
lines is the whole body of both POST and PATCH. No other fields are accepted, on a line or at the top of the body. Each line takes:
amount_cents and send the result. If the lines don’t add up, the request returns 400 invalid_request with param: "lines" and a message giving both totals.
Which transactions can be split
Only a transaction in USD that isn’t split already. Splitting one that is returns409 transaction_already_split; change its lines instead. These are refused with 409 transaction_not_splittable, and the message gives the reason:
404 resource_missing), the same as with GET /transactions/{id}.
Changing a split
PATCH /transactions/{id}/split replaces every line of a split. The body is the same as for POST, and the response is 200 OK with the same list.
Send every line, including unchanged ones. Lines have no IDs, so one line can’t be changed on its own. The lines you send become the whole split, and any line you leave out is deleted. To change a split:
- Read its lines with
GET /transactions/splits?transaction_id=txn_.... - Change what’s needed.
- Send all of them back with only the six line fields:
amount_cents,category_id,merchant,department_id,spread, andaccounting_date. The other fields in aGETrow, such asobject,transaction_id, andcategory_name, are refused with400 invalid_request.
GET /transactions/splits row keeps the line as it was. Leaving a field out gives the PATCH defaults above, which differ from POST: most importantly, a line without department_id ends up with no department.
The old lines are deleted and the new ones saved in one step, so a refused update changes nothing. A transaction that isn’t split by category returns 409 transaction_not_split.
Removing a split
DELETE /transactions/{id}/split does the same as Remove split in the Finta app. Send no body; it returns 204 No Content.
transaction_type: standard. It doesn’t get back the category, department, or accounting date it had before it was split. Categorize it next with PATCH /transactions/{id}, or split it again.
A transaction that isn’t split by category, including one whose split was already removed, returns 409 transaction_not_split.
Split errors
param names the field for a line error, such as lines[1].spread, lines[0].accounting_date, or lines[1].category_id, and id for a transaction that isn’t found. Nothing changes when any of these is returned. The authentication, account, and usage limit errors every endpoint can return apply here too.
Consuming splits safely
Check transaction_type after a lost response
Check transaction_type after a lost response
409 transaction_already_split, and removing again returns 409 transaction_not_split. A PATCH with the same lines succeeds again and leaves the split the same. After a timeout, read the transaction with GET /transactions/{id} and check transaction_type, or list its lines, to see whether the first request went through.Send every line in full on PATCH
Send every line in full on PATCH
PATCH is deleted, and a field left out of a line doesn’t keep its old value: a line without department_id ends up with no department. Always start from the current lines in GET /transactions/splits and send each one back with its six line fields, leaving out the rest.Handle unknown 409 codes
Handle unknown 409 codes
409 codes may be added as more kinds of transactions become splittable. Treat an unknown 409 from these endpoints as “this transaction can’t be changed this way” and show the message.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 /transactions and GET /journal_entries both accept it as a filter. It matches that department only, not its sub-departments. To find transactions that still need a department, see Finding transactions by department.
Departments are early access
Departments come with the Growth and Enterprise plans, but Finta turns them on per company, because not every company on those plans wants them. For a company without departments,GET /departments and every request that names a department are refused with 403 before anything is read or changed:
null to clear a department is refused too. Finta can still put a department on a transaction automatically, through its own department rules or a Mercury card’s department, even for a company that doesn’t use departments, and a company without the feature can’t edit department assignments through the API.
The error code tells you what to do next:
type: permission_error and neither is retryable as is. For example, a department_id filter on a Growth company without departments:
insufficient_permission first, and a malformed split body still gets 400 invalid_request first. See Early access endpoints for the order of checks and the plan_upgrade_required message.
Some things don’t change without departments:
- Responses still carry stored departments. Transactions, split lines and journal entries return the
department_idanddepartment_namestored on them, for example a department Finta set automatically. That ID can’t be used in a request while the company doesn’t have departments. - Department reports keep their own error. A department income statement or a department report export still returns
feature_disabledwithout departments, whatever the plan, neverplan_upgrade_required.
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.
Reconciled-through dates
Reconciling an account up to a date locks its history through that day.reconciled_through is that cutoff, and it is the same date Finta enforces, so you can check it before writing instead of finding out from a refusal. For transactions dated on or before it, inclusive:
- Their amount, date, and account can’t change. A split or transfer match that touches the locked range can fail with
409 split_operation_failedor409 transfer_operation_failed. - Uploads can’t add transactions in that range. Those rows get the
reconciled_perioderror. - Their category can still change. Recategorizing a reconciled transaction is allowed.
reconciled_through and send only rows dated after it, or ask someone with access to reconciliation to unreconcile the period first, in Finta or with POST /reconciliations/{id}/unreconcile.
Null means no lock. Either the account has never been reconciled, or it can’t be reconciled because it has no ledger account in the company’s books. Treat it as “nothing is locked”, not as missing data.
The date moves. It moves forward when a later period is reconciled, back when the latest reconciliation is undone, and to null when every reconciliation is undone. Read it fresh before acting on it rather than caching it.
Seeing the date needs no extra permission: any key that can list accounts sees it. For each period’s balances and status, use GET /reconciliations, which needs Reconciliation: View.
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:
reconciled_through is null. An existing account returned with 200 shows its real date, which may not be null. The account appears in GET /accounts right away under the same id, can be the account_id of a transaction upload, and shows 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
Reconciling accounts
Reconciling confirms that an account’s balance in Finta matches the ending balance on its bank or card statement, and then locks the period. It is part of every month-end close. The API does the same thing as Settings → Reconciliation in the Finta web app: see which periods of each bank and card account still need reconciling, reconcile one, or undo one. Each account has one reconciliation per period, whether or not that period is reconciled yet:- A period is the calendar month for most accounts. For statement-dated accounts (Ramp, Brex credit, Mercury credit, and Stripe) it is the bank statement’s own date range, for example August 26 to September 25. A period appears once it has ended.
- A reconciliation has a stable
recon_ID. It stays the same before and after the period is reconciled, and through undoing and reconciling again. - Reconciling checks that Finta’s balance for the period matches the statement’s ending balance to the cent, then locks the period’s transactions. See Reconciled-through dates for what the lock blocks.
The reconciliation object
statement_balance_cents and statement_file are independent.
Balances are in the statement’s terms. For a credit card, the balance owed is positive: a card statement showing $500.00 owed is 50000. Send statement_balance_cents the same way, as the statement prints it.
Finding periods to reconcile
GET /reconciliations lists every period of every bank and card account, reconciled or not, newest period_end first and then by account name. Accounts that can’t be reconciled are left out. It is a standard cursor-paginated list (limit 1 through 100, default 25).
reconciled=false: everything that still needs reconciling, usually zero to two periods per account however long the history.
GET /reconciliations/{id} returns one period in the same shape. Unknown IDs, and IDs from other companies, return 404 resource_missing.
Reconciling a period
POST /reconciliations/{id}/reconcile reconciles the period, the same as the Reconcile button in Finta.
200 with the period, now reconciled: true, with reconciled_at, reconciled_by, and reconciled_via filled in.
The two balances must match exactly. If they don’t, nothing is saved and the request fails with 422 balance_mismatch, which carries finta_balance_cents, statement_balance_cents, and difference_cents (Finta’s minus the statement’s).
A left-out transaction isn’t deleted. It stays unreconciled and is counted in the next period, where it has to be left out again until someone removes it. So for a duplicate, excluding it gets the period reconciled, but the duplicate should still be deleted in Finta.
A typical workflow:
GET /reconciliations?reconciled=falseto see what needs doing.- For a period with
statement_balance_cents: null, ask the user for the ending balance on their statement. - Compare it with
finta_balance_cents. If they differ, look for the cause withGET /transactionsfiltered byaccount_idover the period: a missing transaction (upload it), a duplicate, or an item that clears next period (leave either out withexcluded_transaction_ids). POST /reconciliations/{id}/reconcilewith the statement balance, and, if the user has the statement PDF, itsstatement_file_id.
Statement PDFs
Each period’sstatement_file is the bank statement PDF that the Reconciliation page in Finta shows for that period, however it got there:
statement_file: null. A Mercury or Ramp statement appears once Finta has fetched it.
The PDF and the balance are separate. statement_balance_cents is the ending balance the period is checked against, and it is required to reconcile. statement_file is the document, and it is optional. A period can have either one, both, or neither.
GET /reconciliations/{id}, or list again. There is no separate download endpoint. statement_file is on every response that returns a reconciliation: list, retrieve, reconcile, and unreconcile. Reading it needs only Reconciliation: View.
Attaching a statement PDF
A statement PDF can be attached through the API only while reconciling the period:-
Upload the PDF with
purpose: "bank_statement", using eitherPOST /files(multipart, with apurposeform field next tofile) orPOST /file_uploadsfollowed by a PUT of the bytes. Both work as described in Tax uploads, except for the purpose. Uploading a bank statement requires Reconciliation: Manage, and the PDF can be at most 10 MB, the same limit as the Reconciliation page. -
Reconcile the period with the returned
file_idasstatement_file_id:The response is the reconciled period, with the PDF instatement_file:
- The PDF attaches only if the period reconciles. If the reconcile is refused, for example with
422 balance_mismatch, the PDF isn’t attached and the file stays available until it expires, 24 hours after upload. Send the samestatement_file_idon the retry. - It replaces an existing PDF. If the period already had a statement PDF, such as one fetched from Mercury, the attached one replaces it, as uploading a new one on the Reconciliation page does.
- There is no other way to attach one. No endpoint attaches a PDF to a period without reconciling it, or to a period that is already reconciled.
- It’s optional. Reconciling without
statement_file_idleaves any PDF the period already has in place. - Each file attaches once. A file uploaded as a
tax_document(the default purpose) can’t be attached to a period, and abank_statementfile can’t be used for a tax upload. Either is refused with400 invalid_file. Upload the PDF again with the right purpose.
Undoing a reconciliation
POST /reconciliations/{id}/unreconcile undoes the account’s latest reconciliation, the same as the Unreconcile button in Finta, so the period’s transactions can change again. Send no body. It returns 200 with the period, now reconciled: false.
Only the latest reconciled period on the account can be undone. For an earlier one, the request fails with 409 not_latest_reconciliation, whose last_reconciliation_id names the period to undo first. To go back further, undo one period at a time, newest first. One call never undoes more than the period it names.
Accounts whose bank sends statements to Finta (Mercury, Ramp, Brex, and Stripe) are reconciled automatically each night when the statement balance matches. An undone period on such an account is reconciled again overnight if it still balances, so finish any fix promptly.
Reconciliation errors
Consuming reconciliations safely
Treat null balances as unknown, not zero
Treat null balances as unknown, not zero
statement_balance_cents is null until a balance is provided. Ask for it rather than assuming the account is empty.Don't store statement download links
Don't store statement download links
statement_file.download_url expires about five minutes after the response. Store the period’s id, and retrieve the period again when you need the PDF. Don’t log the link or share it: until it expires, anyone holding it can download the statement.Treat a null statement_file as no PDF yet
Treat a null statement_file as no PDF yet
statement_file is null until a PDF is uploaded, fetched, or attached. It says nothing about the balance, and a period can be reconciled without one.A reconciled period can have null balances
A reconciled period can have null balances
reconciled: true with null balances, because Finta never checked it on its own. Its reconciled_at, reconciled_by, and reconciled_via are the later reconciliation’s.reconciled_by and reconciled_via can be null on reconciled periods
reconciled_by and reconciled_via can be null on reconciled periods
reconciled_via: "web_app".Handle unknown reconciled_via values
Handle unknown reconciled_via values
reconciled_via is a closed enum today, but values may be added if new ways to reconcile are added. Display an unknown value as-is rather than failing.One account can have several periods ending on the same date
One account can have several periods ending on the same date
id, not on account_id and period_end.Retry without an idempotency key
Retry without an idempotency key
Idempotency-Key. Reconciling a period that’s already reconciled is refused with 409 already_reconciled, so a retry can never reconcile twice. After a timeout or a 500, read the period with GET /reconciliations/{id} to see whether the earlier attempt landed.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, a department, or a tag. 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.
Tagging with a rule
Find or create the tag separately with the tags API, then send its stabletag_ ID in actions.tag_id. A rule cannot create a tag inline. For example, this tags future Vercel transactions as Infrastructure:
201 Created response is the complete saved rule:
400 invalid_request identifying actions.tag_id. A well-formed ID that is unknown, deleted or belongs to another company returns 404 resource_missing, without param. If the tag disappears after lookup but before saving, the request can instead return 400 invalid_request identifying rule. Correct the input or refresh the tag list before retrying.
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.
For tag actions, historical application fills missing tags on all matching past transactions regardless of category or approval. Deleted transactions and split lines are excluded, and any existing tag is kept. This does not widen eligibility for the rule’s other actions: a mixed rule can tag transactions that are ineligible for its category, merchant, department or spread action. The API defaults apply_to_existing_transactions to true; send false for future matching only.
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. A company without departments gets 403 for any actions.department_id, on create and on update (including null); see Departments are early access.
The tag is included when checking for duplicate rules. Otherwise identical rules with different tag actions are different definitions.
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.
For a tag action, send {"actions":{"tag_id":"tag_h8i9j0k1l2m3n4"}} to change it or {"actions":{"tag_id":null}} to remove it, preserving all omitted actions. At least one action must remain; removing the only action from a rule that only sets a tag returns 400 invalid_request. Create rejects null action values. Changing or removing the rule’s tag action never changes tags on past transactions. Omitting tag_id preserves 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.Handle rules that only set a tag and partial edits
Handle rules that only set a tag and partial edits
tag_id, including as their only key. Update generated models and code that handles each action key, and preserve omitted actions during edits. Refresh rules after deleting a tag, which can remove rules or change their actions.Reading is read-only
Reading is read-only
Invoices
An invoice in Finta is a record of an invoice the company already sent to its customer. Recording one books its revenue. Finta does not send invoices: there are no emails, PDFs, payment links, invoice numbers, due dates, or customer records, andcustomer_name is free text.
403 feature_disabled and changes nothing. See Early access endpoints.open. Recording the invoice books Accounts Receivable for the total, and revenue for each line item on the invoice date. A line withspread_monthsrecognizes its revenue evenly over that many months instead, starting in the invoice date’s month (deferred revenue). Tax is booked as a liability, not revenue.paid. When the customer’s payment lands in the company’s bank account, match the deposit to the invoice. For a single invoice, the deposit takes its revenue category and invoice date as its accounting date. A deposit can also pay several invoices, using the earliest invoice’s date and category. Its journal entries settle Accounts Receivable for every invoice on the payment.voided. An open invoice can be voided, which removes everything it booked. Voiding can’t be undone.
source: "stripe" and a stripe_url, and every endpoint below works on them.
The invoice object
Every endpoint returns the sameinvoice object, in full. GET /invoices includes each invoice’s line items, adjustments, and payment exactly as GET /invoices/{id} does, so there is no need to retrieve invoices one by one after listing them.
kind, an amount_cents that is always positive, and a description (or null). The kind says which way the adjustment moves the total:
Finding invoices
GET /invoices returns invoices newest invoice date first, cursor-paginated (limit 1 through 500, default 100). These filters are all optional and combine:
next_cursor when has_more is true):
YYYY-MM-DD (2026-09 and 20260901 are refused, and so are the datetimes other endpoints accept), and amount_cents must be an integer of at most 100000000000000 in absolute value. Anything else is 400 invalid_request, not invalid_date, with param naming the parameter.
Recording an invoice
POST /invoices records an invoice the company already sent and books its revenue. It returns 201 Created with the invoice, status: "open".
category_id needs only Invoices: Manage. A key owner who can’t read the categories list can still name a revenue category; a category that isn’t a revenue category is 400 invalid_request, and one that doesn’t exist is 404 resource_missing, both with param naming the line, such as line_items[0].category_id.
Duplicate invoices and retries
Retrying is safe, and there is no idempotency key to send. An invoice that isn’t voided and has the same customer name (ignoring case and surrounding spaces), invoice date, and total is treated as the same invoice. A repeat returns409 invoice_already_exists with the existing invoice’s ID in the message, and creates nothing:
409 invoice_already_exists on a retry means an earlier attempt succeeded. Read the ID from the message, or find the invoice with GET /invoices filtered by customer, start_date and end_date, and amount_cents.
The same rule catches one invoice entered twice from separate sessions. It also refuses two genuinely different invoices to one customer on one date for the same total; record the second with its own date, or put both amounts on one invoice as two line items. A voided invoice doesn’t block entering it again.
Voiding an invoice
POST /invoices/{id}/void voids an open invoice and removes everything it booked, returning the invoice with status: "voided". Send no body; any body is 400 invalid_request. Voiding can’t be undone.
Voiding an invoice that’s already voided returns it unchanged with 200, so retries are safe. A paid invoice can’t be voided: it returns 409 invoice_not_open. Unmatch its payment first.
Matching a payment
POST /invoices/{id}/match records a bank deposit as an open invoice’s payment. It returns the invoice with status: "paid", paid_transaction_id set to the deposit, and paid_date set to the deposit’s date.
source_type=bank, transaction_type=standard, and a start_date around the invoice date, and look for a positive amount_cents equal to the invoice’s:
409 transaction_not_matchable rather than having their split discarded. Remove the split first, or match in the Finta app, which asks how to reconcile it.
Matching the same deposit to the same invoice again returns the invoice unchanged with 200, so retries are safe.
Matching one deposit to several invoices
UsePOST /transactions/{id}/match_invoices when one bank deposit settles 2–100 open invoices. Partial payments and multiple deposits per invoice remain unsupported.
Find candidates with GET /invoices?customer=Acme&status=open, following pagination as needed. Add their amount_cents values and compare the sum with the deposit; filtering each invoice by the deposit’s whole amount would miss a group. Every invoice must belong to the current company and use the deposit’s currency. Confirm with the user before combining invoices for different customers or replacing an already categorized deposit’s category.
Send exactly one field, invoice_ids: an array of 2–100 distinct strings starting with inv_. The deposit must be an unsplit bank deposit, not a transfer, and not already matched to a different invoice set.
200 OK returns every matched Invoice in stable order, with the same fields as GET. This list is not paginated: has_more is false and next_cursor is null.
409 invoice_operation_failed, with the accounting-date reason. Spread month dates have no additional bound. Matching adds no closed-books or reconciliation refusal beyond single matching.
Matching is all-or-nothing: every refusal leaves all invoices and the deposit unchanged. After a timeout or lost response, retry the identical invoice set; order does not matter. A repeat returns 200 unchanged. A different set on that deposit returns 409 payment_already_matched. Fix other 400, 403, 404, and 409 refusals before retrying; see Invoice errors. Authentication, OAuth write scope, API-key permissions, rate limits, and response headers are unchanged.
In the Finta app, a grouped deposit’s spread cannot be changed. To change an invoice’s spread, unmatch the payment, edit the open invoice, then match the group again. Resolving one grouped invoice’s Stripe date review remains available: it changes only that invoice’s recognition date and rebuilds the payment’s entries for every invoice. No API endpoint edits a paid invoice’s spread or date.
Unmatching a payment
POST /invoices/{id}/unmatch takes the same body as single-invoice match: the transaction_id of the deposit the invoice is matched to.
open with its Accounts Receivable booked again, atomically. The deposit returns to Uncategorized Income, with its own date as its accounting date; its category before the match is not restored.
If the deposit was deleted in Finta after the match, by a user or by a bank sync reversing it, it stays deleted. Every invoice on the payment still reopens with its Accounts Receivable booked again, but the deleted deposit’s cash and income are not recreated.
Naming the transaction protects against undoing a different match. If the invoice is now matched to some other deposit, the request is refused with 409 invoice_not_matched and nothing changes. Unmatching a pair that is already unmatched returns the invoice unchanged with 200, so retries are safe.
Invoice errors
Consuming invoices safely
Handle unknown enum values
Handle unknown enum values
status, source, and adjustment kind as closed today, but display an unknown value as-is rather than failing. Finta may add values, such as a partially paid status or another billing source.Retry without an idempotency key
Retry without an idempotency key
Idempotency-Key. Create is made safe by the duplicate rule. Repeating void, single-invoice match, or unmatch returns 200 with the unchanged invoice. Group matching returns the unchanged invoice list when you repeat the exact same set, in any order. The other invoice errors in the table above are not fixed by retrying.One deposit can pay several invoices
One deposit can pay several invoices
paid_with_invoice_ids array on every Invoice response. It lists peers, excluding the invoice itself, and is empty for one-to-one payments or invoices without a payment. Stop assuming paid_transaction_id is unique across invoices. Unmatching any member reopens all of them, even though the response contains one Invoice.A paid invoice's deposit may be gone
A paid invoice's deposit may be gone
paid_transaction_id stays set after the deposit is deleted in Finta, so GET /transactions/{id} can return 404 for it. Don’t treat that as a broken invoice.Stripe invoices can be longer
Stripe invoices can be longer
source: "stripe" can have more line items, so don’t size buffers or validation on the create limit.feature_disabled is not a permission problem
feature_disabled is not a permission problem
403 feature_disabled means the company needs Finta to turn invoices on. Nothing in the key owner’s team settings fixes it, and the same request works unchanged once it’s on.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. connection_methods is present on every entry and lists each way the integration can be connected, where each one stands, and whether the API can start it; see Connection methods. quality and signup_referral are also present on every entry: how reliable the provider’s data is, and a referral offer for opening an account with it. See Choosing a provider.
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 OAuth providers whose method has connect_via: "api", 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. Plaid banks reconnect only in the Finta web app, even though their method has connect_via: "api"; the connect endpoint returns 409 integration_already_connected for an existing Plaid bank, including requires_reconnect. Every other integration is reauthenticated at app.finta.com. Treat requires_reconnect on any banking, revenue, or payroll integration as worth alerting whoever owns the account, because reports and transactions go quietly stale while it persists. not_connected is not an error condition and should not be alerted on.
status describes the integration’s main connection only. An integration can be connected and still need something from the company, such as an invite for Mercury Treasury; that shows up in connection_methods, not in status.
Connection methods
connection_methods is an array of the ways an integration can be connected. Each entry has three fields, all always present:
status is always the same as the integration’s own status.
Mercury and Ramp can have a second entry, type: "grant_access". Their sign-in connection doesn’t include every account Finta needs: Mercury Treasury and Ramp Checking data is only reachable once the company invites Finta as a user in Mercury or Ramp, and Finta asks for that with an item in the company’s Finta inbox. The second entry reflects that request:
- No entry when Finta hasn’t asked, or the company dismissed every such inbox item.
status: "requested"while any such inbox item is still open.status: "granted"once every such inbox item is completed, in the web app or withPOST /inbox/{id}/complete, meaning the company says it has invited Finta. Finta records only that the invite was sent, not that the access works, which is why the value isgrantedrather thanconnected.
status. In the example above, Mercury is connected by OAuth and its grant_access method is still requested.
Order within connection_methods is not significant. Index entries by type.
Method types
Method statuses
requested and granted exist only on methods. The integration’s own status keeps its four values.
Where to connect
Reading connection methods
- Can the API connect this integration? Look for an entry with
connect_via: "api". Calling the connect endpoint for an integration without one returns400 integration_not_connectable. - What does Finta need from the company? Look for methods whose
statusisrequestedorrequires_reconnect.requestedis an open inbox request: invite Finta in the provider.requires_reconnectmeans the user has to sign in to the provider again, through the connect endpoint for an OAuth method withconnect_via: "api", and in the web app for Plaid banks and other methods. For everything Finta is asking for, not only integrations, read the inbox. - Is the integration fully set up? Every method is
connectedorgranted.
Choosing a provider
When a user asks which bank to use, or whether to connect Chase or Mercury, two fields answer it:quality rates the provider, and signup_referral carries the referral offer Finta has with it, if any.
Quality
quality is the same reliability rating the Finta web app shows on each integration card.
quality describes the provider, not the company’s connection. Every company sees the same quality for a given key, connected or not, and it does not drop when a connection needs reconnecting. Use status for connection health.
When recommending a provider, prefer great over good over poor.
Sign-up referrals
signup_referral is an object only when both of these are true:
statusisnot_connected, and- Finta has a referral with the provider.
null, including when status is connected, connecting, or requires_reconnect. In all three the company already has an account with the provider, so an offer to open one doesn’t apply. Once a company connects Brex, for example, Brex’s signup_referral becomes null.
signup_referral, share its link and reward with the user.
signup_referral.link is for a company that doesn’t have an account with the provider yet. It opens the provider’s own sign-up page and connects nothing to Finta. After signing up, or for an account the company already has, connect it with POST /integrations/{key}/connect when it has a connection method with connect_via: "api", or in the Finta web app otherwise.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.The enums are expected to grow
The enums are expected to grow
type, status, and quality 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. - Treat an unrecognized
qualityas unknown rather than failing, in case a rating is added later.
type and connect_via values may be added.- Treat a method with an unrecognized
typeaccording to itsconnect_via. - Treat an unrecognized
connect_vialikeweb_app: send the user to the Finta web app. - Treat an unrecognized method
statusas not actionable, as above.
Connection methods change over time
Connection methods change over time
connect_via: "api" at any time, with no other API change. Read connection_methods from the response rather than hard-coding or caching a list of connectable integrations.More grant_access requests like Mercury’s and Ramp’s may be added for other integrations later. Don’t assume only Mercury and Ramp can have more than one method.Ratings and referrals change over time
Ratings and referrals change over time
quality changes when Finta changes how it connects to that provider. More providers can gain a signup_referral, and an existing referral’s reward or link can change, with no other API change. Read both fields from each response rather than hard-coding or caching them.signup_referral is always present, often null
signup_referral is always present, often null
signup_referral is never omitted, but it is null for most integrations. Check for null before reading reward or link. Code generated from the spec should model it as always present and possibly null, not as optional.Poll slowly
Poll slowly
GET /integrations/{key}/connect instead. The list cannot tell you why an attempt failed.Connecting an integration
During company onboarding, connection creation and status retrieval are available only after Finta approves the company (onboarding_status is connect_banks or syncing). Before approval, both return 403 onboarding_incomplete; GET /integrations remains available. Follow onboarding’s next_step.message for which banks to connect and how. New OAuth and Plaid connections, including Chase and SVB, use the same connect endpoints; the user approves on the returned provider or Plaid Hosted Link page. The first import starts with the last selected API bank connection, or an onboarding update when all selected banks are already connected. For Plaid reconnects, follow the web get-started page instructions in next_step.message, then send PATCH /onboarding with {"answers":{}} to continue setup, both before and during the first import. Hosted Link cannot reconnect an existing Plaid bank (409 integration_already_connected); OAuth reconnects still use the API. After new web bank connections, also send the empty-answer update: a read can return start_import, but cannot start the import itself.
API-started onboarding finishes without a dashboard button, with automatic recovery of interrupted import-start and completion job handoffs. A finished web-started import returns finish_in_web: ask the user to switch to the company named in next_step.message in Finta first, then open next_step.url, choose See dashboard, and read onboarding again. For a failed import, reconnect only if the connection needs reauthentication; an import failure with an active connection goes to support through contact_support.
POST /integrations/{key}/connect starts connecting an integration, or reconnecting an OAuth provider whose status is requires_reconnect, without sending anyone into the Finta web app. Plaid banks support new connections only; reconnect them in the 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. The API returns a provider authorization link or a Plaid Hosted Link. A person opens it and approves; Finta completes the connection after the provider callback or Plaid session report. Provider tokens never reach the API caller.
Which integrations can be connected
Only integrations with a connection method whoseconnect_via is api in GET /integrations. These include Mercury, Brex, Ramp, Slash, and Rho through OAuth, and chase, svb, arc, citi, boa (Bank of America), and american_express through Plaid Hosted Link. Read connection_methods rather than hard-coding a list: more integrations gain an api method over time with no other API change. Calling either connect endpoint for an integration without one 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. For OAuth providers, they must open it in a browser signed in to Finta as the user who started the request, which for an API key is the key’s owner. A different Finta user is rejected. Plaid Hosted Link needs no Finta sign-in: anyone with the link can connect a bank to the company until it expires. Share it only with the user you are working with.The user approves at the provider
Check the result
GET /integrations/{key}/connect until status is complete, failed, or expired. See Polling and API usage for how often.201 Created:
Connecting a Plaid bank
Forchase, svb, arc, citi, boa, or american_express, the link opens a Plaid-hosted bank sign-in page and lasts 30 minutes. Pick the bank requested by the integration key: choosing Citi from a chase link fails with connection_failed.
POST while this request is open returns the same request with 200 OK. Only pending responses contain url.
When the user finishes or exits, Plaid opens the public, read-only Finta page at /integrations/plaid/connected, asking them to return to their agent. That page does not confirm success. Plaid reports the session separately, usually within seconds. Check the request:
pendingcan mean Plaid has not reported the session yet. Check again after a few seconds; automated polling uses the existing 10-second interval.authorizingmeans Finta is finishing the connection. Temporary provider or database errors retry automatically; keep checking this request instead of starting another connection. Interrupted completion resumes the saved connection and queues its initial import once, including onboarding. Web and API completions for one bank are serialized to prevent duplicate connections.- Leaving Plaid without connecting produces
failedwithfailure_code: "access_denied". Reopening the same link and connecting while it lasts can still turn that requestcomplete. Other failures, such as a different bank or revoked permissions, are final. - A late successful session report can turn an
expiredrequestcomplete, even if a replacement link was created. CheckGET /integrationsbefore offering a replacement: the bank may already be connected. Finishing a replacement after the bank connected does not create another bank connection. intentis alwaysconnect. Any existing connection to that Plaid bank returns409 integration_already_connected, even if it needs reconnection. Send the user to Settings > Integrations in the Finta web app to reconnect it. During onboarding, follow the web get-started instructions innext_step.message, then send an empty-answer onboarding update.
The connect request object
Both endpoints return the same object, and every field is always present.instructions field. If your client read it, displayed it, or required it when validating responses, drop it and act on status as described in Polling and API usage.authorizing when expires_at passes stays authorizing while Finta finishes it, rather than turning expired. Keep checking while pending or authorizing; the Plaid exceptions above mean failed after exit and expired can still complete later.
provider_admin_required and provider_error apply to OAuth providers, not Plaid banks.
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 with201if the integration is still eligible. For an expired Plaid request, checkGET /integrationsfirst: a late success may already have connected the bank, in which casePOSTreturns409. There is no separate retry endpoint. - 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. For OAuth providers, a new link is available when the status isrequires_reconnect. Plaid banks return this conflict for every existing connection, includingrequires_reconnect.
GET never changes anything. OAuth failed or expired requests stay that way until the next POST. Plaid can still report a successful session for an expired request or one that failed after the user exited, as described above; the GET itself never completes anything. GET only returns requests started by the calling user, the 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
PollGET /integrations/{key}/connect every 10 seconds while the request is pending or authorizing, and stop once it is 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. An unopened OAuth link stays pending for 15 minutes, costing about 90 calls if polled every 10 seconds. A Plaid link lasts 30 minutes, costing about 180 calls at that interval. 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
For OAuth provider links:- 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. A Plaid url needs no Finta sign-in and lets anyone holding it connect a bank to the company until expiry. Do not log either link or share it with anyone but the user who should open it; do not assume it is on the provider’s domain.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. - Poll only while
statusispendingorauthorizing. Treat any other value, including one added later, as a reason to stop.
The inbox
The Finta web app has an inbox: a list of things Finta needs the company to do, such as reconnecting a bank whose data stopped updating, inviting Finta as a user in Mercury, categorizing transactions, or answering “have you filed your 2025 taxes yet?”.GET /inbox returns the same list, so an agent asked “what does Finta need from me?” can answer from it instead of guessing from integration statuses.
The API uses the web app’s word on purpose. People ask about what they see on screen (“can you complete my inbox items?”), and each item is built for an agent to act on: what kind of request it is, a short instruction, and a link for a person.
What the list contains
- Exactly what the web inbox shows as open: the regular inbox items and the setup checklist items that aren’t done yet.
- Snoozed items are left out until their reminder date, as in the web inbox. Dismissing an item in the web app usually snoozes it for a few days.
- Dismissed and completed items are left out.
- Items about something since deleted are left out, such as a reconnect request for a bank link that no longer exists. The web inbox drops these as well.
- A setup “categorize transactions” item is left out once nothing is left to categorize. The web inbox closes it at that point too.
- Only items in areas the caller can view. Every item belongs to one permission area: Integrations, Transactions or Taxes. A key owner with Integrations: View but no Taxes access gets the integration items and nothing else. Dashboard access isn’t needed.
- Oldest first, by
created. - Not paginated. The list is small and bounded, so the whole set comes back in one response, with no
has_moreornext_cursor. See Unpaginated list endpoints.
data array means Finta needs nothing from the company right now, or nothing in the areas the caller can view.
The inbox item object
Item types
Instructions
instructions takes one of two forms.
When a Finta skill covers the item, it names the skill to run. Today only Mercury Treasury access has one:
Get the grant-access-mercury skill and run it, then mark this item complete.A skill is a step-by-step playbook an AI agent carries out, served by the Finta MCP server. A direct API caller can’t open a skill, so it should send the user to
url instead.
Otherwise, it is the web inbox’s own text for the item, followed by The user needs to do this in the Finta web app. Relay that and share url. This applies even to items the API could act on, such as categorizing transactions. As Finta writes skills for more kinds of item, those items switch to the first form, and only their instructions text changes.
Instructions never name an endpoint path or an MCP tool, so the same text reads correctly to a script calling the API and to an AI agent.
url is the page in the Finta web app where the user handles the item. Items that open a full page link to it, such as the transactions page for categorizing or the year’s Taxes page. Items handled in a dialog link to the company’s inbox page, where the item is shown.
How items close
Every item closes on its own once Finta sees the work done: the integration reconnects, no uncategorized transactions are left, the return is uploaded, and so on. The one exception isgrant_access. The user invites Finta in Mercury or Ramp, and Finta can’t see that, so the item stays open until someone says it’s done. In the web app, that’s the I’ve invited Finta button. In the API, it’s POST /inbox/{id}/complete.
Completing an inbox item
POST /inbox/{id}/complete does what the web app’s I’ve invited Finta button does. It takes no body.
- Only
grant_accessitems can be completed. Every other type returns400 inbox_item_not_completable, with a message saying what closes it. - What it records: only that the company says it sent the invite, not that Finta’s access works. For Mercury it also records that the invitation was sent. For Ramp it closes every open Ramp Checking request, since overlapping syncs can leave more than one.
- Effect on integrations: the integration’s
grant_accessconnection method inGET /integrationsmoves fromrequestedtogranted, notconnected. - Afterwards the item no longer appears in
GET /inbox. - Idempotent: calling it again on a completed item returns
200with the same item, so retries are safe. - No undo. Complete an item only once the user confirms the invite is sent. There is no dismiss or snooze endpoint either; those stay in the web app.
Inbox permissions
GET /inbox needs only API access: Manage. Each item is then checked against its own area, so what you see depends on your Integrations, Transactions and Taxes access.
POST /inbox/{id}/complete needs Manage on the item’s area, which for grant_access is Integrations: Manage. Without it the request returns 403 insufficient_permission. Over the MCP, a read-only connection can’t complete items.
Inbox errors
GET /inbox has no errors of its own beyond the standard authentication, access and rate-limit errors.
Consuming the inbox safely
The type enum is expected to grow
The type enum is expected to grow
type values as it adds kinds of inbox items. Handle a type you don’t recognize by showing title and following instructions and url, which work for every type. Don’t fail validation on it.Follow instructions, don't parse them
Follow instructions, don't parse them
instructions and title are written for people and change as Finta writes skills for more items. Branch on type, and pass instructions to the user or agent as text.Confirm before completing
Confirm before completing
POST /inbox/{id}/complete only after the user confirms they’ve sent the invite in Mercury or Ramp.IDs come from the list
IDs come from the list
id from a recent GET /inbox. An item the list wouldn’t show now, such as one dismissed or snoozed since, returns 404 resource_missing when completed.Plans and limits
UseGET /plans to read the plans Finta offers (Formation, Startup, Growth, and Enterprise) with each one’s monthly list price, standard limits, and features. It takes no parameters and returns the same list for every company, cheapest first and Enterprise last.
It needs no permission beyond API access: Manage. It does not count toward the monthly API limit and keeps answering after the company reaches it, so a caller at its limit can still see what upgrading offers. The per-minute burst limit applies as usual.
The plan object
null for Enterprise, where it is set by contract.
features follows the plan cards on the in-app Plans page, not finta.com. It begins with “Free forever” for Formation and “Everything in” the previous plan for the others, and it includes the limit lines, such as “3,000 categorizations / mo.”.
Consuming plans safely
- A list price is not what a company pays. A company can pay less through a discount or an older price it keeps. Never present
price_centsas a particular company’s price. For what this company pays, callGET /billing. - These are standard limits. Finta can raise an individual company’s limit, so don’t read a company’s own allowance from this list. For this company’s limits and how much of each it has used, call
GET /usage. - Don’t quote an Enterprise price. Enterprise is priced and limited by contract, so its
price_cents,interval, and every limit arenull. Treatnullas “set by contract”, never as zero, and point readers to book a call using the link in itsdescription. - Expect new plans. Finta may add plans, so treat an unknown
idas a new plan rather than an error. - Read numbers from
limits.featuresis display text. Show it as it is, and don’t parse limits out of it. - Errors are the standard ones. A missing or invalid key gets
401, a key whose owner lost API access gets the usual403permission error, and too many requests in a minute gets429 rate_limit_exceededwithRetry-After. This endpoint never returnsmonthly_limit_exceeded.
Billing
UseGET /billing to read the plan the company is on and what it pays for it, laid out like an invoice: the plan’s price for this company, the discount taken off, and the total. The figures are the ones Settings > Billing shows at app.finta.com. It takes no parameters. Billing contacts, payment methods, invoices, invoice links, payment-provider IDs, and promo codes are not returned.
It needs Billing: View as well as API access: Manage. Unlike GET /plans and GET /usage, it counts toward the monthly API limit.
The billing object
status comes from the payment provider:
renews_on is null when there is no card subscription, when the subscription isn’t active or trialing, or when a cancellation is scheduled. A subscription set to cancel at the end of its period stays active until then, but its renews_on is null because it will end rather than renew.
A percentage coupon is applied to the price and rounded to the cent, and a fixed-amount coupon is taken off as is. A coupon counts only against the plans it covers and never takes off more than they cost. A discount that has ended counts as 0.
plan_price_cents and total_cents are null for an Enterprise contract without an annual price on file, and for a legacy subscriber billed outside the card subscription. Formation reports 0 for both:
Consuming billing safely
- Show
total_centsas what the company pays.plan_price_centsis this company’s own price. It can differ from the list price inGET /planswhen the company kept an older price or negotiated one, so don’t work out a discount by comparing the two. Readdiscount_cents. discount_centsis already negative. Add it to the price rather than subtracting it.- Don’t say how long a discount lasts. The response doesn’t say when a discount ends, so don’t describe
total_centsas either temporary or permanent. nullis not free. Anullprice means Finta doesn’t have it on file. Formation, the free plan, reports0.- Read
statusfor whether the subscription is current, andrenews_onfor whether it renews. A scheduled cancellation isactivewithrenews_on: null. - Expect new statuses. They come from the payment provider and the list may grow. Treat an unknown
statusas not active rather than as an error. - Errors. A key whose owner lacks Billing: View gets
403 insufficient_permission. When the payment provider can’t be read, the request gets503 billing_unavailable: retry with exponential backoff, since noRetry-Afteris sent. At the company’s monthly limit it returns429 monthly_limit_exceededlike any other counted endpoint, and the per-minute burst limit applies as usual.
Usage
UseGET /usage to read how much of each limit the company has used: automatic categorizations, stored transactions, AI credits, public API requests, and MCP tool calls. These are the limits Finta enforces for this company, including any limit Finta raised for it, not the standard ones from GET /plans. It takes no parameters.
It needs Transactions: View as well as API access: Manage. No billing access is needed. Like GET /plans, it does not count toward the monthly API limit and keeps answering after the company reaches it, so a caller at its limit reads api_calls.stopped: true instead of getting 429 monthly_limit_exceeded. The per-minute burst limit applies as usual.
The usage object
The response hasobject, always usage, and one meter per limit:
limit and ai_credits.remaining are null, and used is still counted.
Consuming usage safely
- Read
stopped, don’t compute it.usedcan reachlimitwithout anything stopping: a company in its trial is never stopped, and purchased packs letai_credits.usedgo pastlimit. For AI credits left, readremainingrather than subtractingusedfromlimit. - Don’t assume the plan’s numbers.
limitcan be higher than the plan’s standard limit inGET /planswhen Finta raised it for this company, whether or not the meter hascustom_limit. nullis no limit, not zero. Treat anulllimitorremainingas unlimited.- Stored transactions never reset. Don’t tell a company whose
transactionsmeter is stopped to wait forresets_on; it isnull. Held transactions aren’t synced until the plan is upgraded, and the API doesn’t return them until then. - Waiting doesn’t release held categorizations. After
resets_on, new transactions are categorized automatically again, but the ones already incategorizations.heldare categorized only after an upgrade in Settings > Plans at app.finta.com, or by hand. - Counts can lag by a few minutes. Don’t poll faster than that expecting a change.
- Errors are the standard ones. A key whose owner lacks Transactions: View gets
403 insufficient_permission, and too many requests in a minute gets429 rate_limit_exceededwithRetry-After. This endpoint never returnsmonthly_limit_exceeded.
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 app.finta.com.
Reading the directory needs Team & access: View in addition to the usual API access: Manage. Creating an invitation and resending one require the Admin role in the company, on every plan. Changing an invitation’s access and revoking one still use Team & access: Manage and their existing grant and target-management checks.
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 app.finta.com.
first_name, last_name, email, and access_template are all required, and all four must be non-blank strings. The body must be a JSON object sent with Content-Type: application/json, and unknown fields are rejected rather than ignored. That includes company and user IDs: the key already determines who is inviting and which company they are inviting into, so there is nothing to pass. Query parameters are never read as a source of body fields.
Choosing access
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. 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 loses both Growth and Enterprise eligibility 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
Editing has stricter plan and feature checks, but it does not share creation’s Admin-only rule: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 and Enterprise do 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 app.finta.com.
Expiry is not a barrier. An expired pending invitation can still be edited, and editing it does not renew the deadline or reissue the link, so expires comes back unchanged and possibly still in the past.
Reading the response
A success is 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.
Resend requires the Admin role in the company, even when the invitation grants less access than the key owner holds. A non-Admin gets 403 insufficient_permission with no param; Team & access: Manage alone is not enough. Upgrading the plan does not remove this restriction. Resend adds no Growth or role-based access control gate, so an Admin in 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.
Revoke needs Team & access: Manage plus the authority to manage that particular invitation, and adds no Growth or role-based access control gate. Unlike creating or resending, it does not require the Admin role. 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 app.finta.com.
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 endpoints below return report JSON. For PDF, CSV or Excel files with month/quarter/year columns, use the separate asynchronous Report Exports workflow. Balance-sheet file exports require a start/end range even though the JSON balance sheet uses one cutoff date. The API returns three financial reports: income statement, balance sheet, and cash flow. These reports remain day-exact. Dates and defaults such as today and the current quarter use Pacific Time. A balance sheet includes activity through its exactdate; income statement and cash flow include both start_date and end_date. Category-grouped income statements, balance sheets, and cash flow use the same fast rollups and exact live boundaries as category totals, so month-aligned ranges ending today stay fast when MTD is fresh, while other partial-month boundaries are slower.
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 for the entire answer, not just completed-month rollups. It includes MTD rollup timestamps and the call time for any live boundary, including an empty live boundary. Finta returns null only when neither a contributing timestamp nor a live portion is available. See Whole-answer freshness.Department-grouped income statements read journal entries directly and always return refreshed_at: null; this means freshness metadata is unavailable, not that the report is empty or finalized.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, or category-grouped 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. Department-grouped expenses follow the same rule with rows and parent_row_id instead.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.Income statement grouping
The income statement endpoint returns either the standard category-based statement or the app’s grouped by department. Both retainobject: "income_statement" and echo the selected group_by.
group_by, or sending it blank, selects category. Both accept the same
start_date and end_date, defaulting to the beginning of the current calendar
quarter through today in Pacific Time. Both groupings honor exact inclusive boundaries, including
partial months. The parameter selects the expense layout, not a reporting
interval or a department filter. Each request returns the full company statement
for one period; request each month separately for a monthly series.
Both variants require Reports: View. Department grouping also requires the
departments feature; a company without it receives
403 feature_disabled for that variant
and can still retrieve the category-based statement.
Reading department expense rows
Revenue, non-operating income, optional uncategorized income, and taxes keep the usual{total_cents, categories} structure. Expenses contain {total_cents, rows}.
There is no separate uncategorized_expenses section in this variant.
kind values identify three types of row:
department: an actual department, with itsdepartment_idpopulated.category: an actual category, with itscategory_idpopulated.group: the synthetic Cost of Goods Sold or No Department group. Both resource references are null; the IDs aregrp_cost_of_goods_soldandgrp_no_department.
parent_row_id to build the tree and position to order siblings. Identify
rows by resource IDs, even when sibling departments or COGS categories share a name.
A real department named Cost of Goods Sold remains separate from the synthetic group.
Zero-valued expense rows are included in the complete hierarchy, including synthetic
COGS, Hosting, and No Department rows for an empty period; their positions are part
of the returned sibling order.
A parent’s
amount already includes its children; it can also include direct activity.
Sum only root rows to total expenses, or use expenses.total_cents. In the example,
Hosting is included in Cost of Goods Sold, and Engineering is included in Research
& Development. Adding all five rows would count those child amounts twice.
No Department means no department assignment, not an unknown accounting category.
Synthetic group IDs cannot be used to look up or assign a category or department.
Hosting and compute costs appear in COGS. COGS, taxes, and gain/loss entries are
excluded from department subtotals, and unassigned bulk-split parent entries are
excluded from No Department.
The No Department row adds up journal entries, including manual journal entries and
monthly spread slices, and has no department cutoff. It is not the same set as
GET /transactions?no_department=true, so the two
totals will not, in general, match.
Amounts remain signed integer USD cents. Missing optional sections contribute
zero, and the department statement reconciles as follows:
uncategorized_expenses contribution.
Avoid adding calculated subtotals such as operating income back into section totals.
Department grouping aggregates the entire requested date range as one exact
period, so a long range does not create a query range or table column for every
month. This does not add a date cap or a new query parameter. Department
refreshed_at is null because summary freshness metadata is unavailable;
category grouping keeps its existing nullable summary timestamp.
Existing category callers receive the same layout plus the additive group_by
field. If your JSON validator rejects unknown keys, add that field. When opting
into department grouping, branch on group_by before reading expenses. A nonblank
unsupported value such as group_by=month returns
400 invalid_request with param: "group_by".
Spreadsheet documents
A sheet is the whole document (sht_ ID); worksheets are tabs with their own stable IDs and user-facing names. The document URL opens Finta, and revision applies to the whole document. Retrieve metadata first to discover worksheet names, IDs, used ranges and grid bounds, then select bounded A1 ranges.
Sheet values are generic spreadsheet cells in worksheet units, not financial-resource cents fields: a cell representing $20,000 is 20000. Financial reports and transactions retain their _cents contracts. view=both exposes saved formulas beside results; a saved number is a snapshot. Calculation errors and warnings are separate from saved inputs.
Sheet lists use object: "list"; individual sheet and worksheet objects do not contain an object discriminator. Search identifies authored input text, not calculated results.
Create sheet accepts a blank document, a supported template, or custom named worksheets with values, formulas, formats and embedded charts. Statement templates use live FINTA actuals and spreadsheet totals. Blank/custom tabs start at 100×26 and grow within company limits; template dimensions are retained. In write matrices, null skips a cell and an empty string clears its contents. Creation returns the Finta link and stable worksheet/chart IDs, with optional selected calculation results after saving. Calculation errors do not undo a successful save.
Update sheet uses the current document
revision as base_revision for ordered, atomic edits. Later ranges see prior
changes, including worksheet renames and row/column shifts. Tab and chart actions
use the stable IDs from the response, while qualified A1 ranges select tabs by
name. Partial format/chart updates preserve unspecified properties. A stale edit
saves nothing; reread and replan rather than blindly replace the revision. A
successful mutation and a successful optional calculation are separate outcomes.