401 vs 403
The API draws a sharp line between authentication and permission:- 401 Unauthorized means the bearer credential itself is the problem (missing, malformed, or unrecognized). The fix is to obtain a valid API key.
- 403 Forbidden means the credential was successfully looked up, but the caller is not allowed to access the resource. The fix is to regain access (reactivate the user, restore company access, restore an active subscription, or ask an administrator for the required product permission), not to rotate the key.
error.type, not the status code alone, to decide between “rotate the key” (authentication_error) and “regain access” (permission_error).
Permissions
An API key acts as its owner and has no separate permission snapshot of its own. Every request requires the owner to currently hold API access: Manage, plus the permission the endpoint itself requires:
A key whose owner is missing either piece gets
403 insufficient_permission. Permission changes take effect on the next request; keys do not cache a permission snapshot, so there is nothing to refresh or reissue after an administrator grants access.
Creating and editing an invitation are checked twice. Team & access: Manage buys you the endpoint; it does not decide what access you may hand out. The invitation’s own permissions are checked separately against what the key owner holds, and against the company’s plan and feature flags. See plan_upgrade_required and feature_disabled.
PATCH /team/invitations/{id} applies the stricter version of those checks. Every edit requires role-based access control to be on and an active or trialing Growth subscription or Finta’s internal Enterprise entitlement, Admin and no-op replays included, and the key owner has to be allowed to manage the access the invitation holds now as well as the access being requested. POST /team/invitations still accepts Admin invitations below Growth; editing never does.
POST /team/invitations/{id}/resend and DELETE /team/invitations/{id} go the other way. Neither grants anything new, so neither adds a plan or feature gate: plan_upgrade_required and feature_disabled do not apply to either. Both still require Team & access: Manage and the authority to manage that particular invitation.
Removing API access is different: it permanently revokes the owner’s active keys for that company. Restoring the permission does not bring those keys back, and a new key has to be created.
Early access endpoints
Some endpoints are in early access: documented here, but turned on per company while they roll out. Today that is the invoice endpoints. For a company that doesn’t have one yet, every request to it returns403 feature_disabled with type: permission_error and changes nothing:
feature_disabled first. A gated endpoint never answers 404, so it can’t be mistaken for a path that doesn’t exist.
This is not retryable, and it is not something the key owner can fix in team settings. Contact Finta to request access for the company. Once the feature is on, the same request works with no changes.
Summary
Bulk update jobs report their own failures inside a
200 response, as entries in the job’s errors list. Codes there, such as transaction_conflict and update_failed, describe the job rather than your request, and some reuse names from this table. See Reading job errors.Transaction uploads work the same way: problems with individual rows, such as invalid_date or possible_duplicate, come back in a 200 or 201 as entries in the upload’s errors and warnings, not as HTTP errors. See Errors, warnings, and open questions.invalid_api_key
Type:authentication_error | Status: 401
The API key in your Authorization header is missing, malformed, or unrecognized. This covers credential-level problems only. If the key was valid but the underlying user, company, or subscription has lost access, you will see a 403 with a permission_error instead (see below).
- Verify the key starts with
finta_and is copied in full with no trailing spaces. - Check that you’re passing it as
Authorization: Bearer finta_...(not as a query parameter or other header). - If the key was revoked, generate a new one from your Finta dashboard under Settings > API Keys. Revoked keys cannot be un-revoked.
user_inactive
Type:permission_error | Status: 403
The user that owns this API key has been deactivated. The key was valid, but the underlying user account is no longer active.
- Reactivate the user from Settings > Members at app.finta.com, or
- Create a new API key under an active user.
user_removed_from_company
Type:permission_error | Status: 403
The user that owns this API key no longer has access to the company the key was created for. API keys belong to people, not companies, so removing the user from the company also revokes their keys.
- Ask a company admin to re-grant the user access to the company.
- Once access is restored, create a new API key. The old key cannot be reactivated.
company_closed
Type:permission_error | Status: 403
The company associated with this API key is closed, closing, or deleted. The API is not available for closed companies.
- Contact Finta support if the company should not be in a closed state.
subscription_required
Type:permission_error | Status: 403
The company does not have usable subscription access. This is returned when a subscription is required (no plan), the trial has ended, or the company’s subscription status blocks access. Finta’s internal Enterprise entitlement bypasses ordinary Stripe subscription lockouts, but company closure and permission checks still apply.
- Visit Settings > Plans at app.finta.com to start or restore a subscription, then retry.
staging_access_denied
Type:permission_error | Status: 403
The staging API only accepts keys owned by users with an @finta.com email address.
- Send production requests to
https://app.finta.com/api/v1. - For internal staging tests, use an API key owned by a user with an
@finta.comemail address.
insufficient_permission
Type:permission_error | Status: 403
The key is valid and its owner still has access to the company, but that owner lacks either the API access: Manage permission or the specific product-area permission the endpoint requires. See Permissions for the endpoint-to-permission table.
user_removed_from_company, which means the owner lost access to the company altogether rather than to one product area.
On the invitation endpoints the same code covers a second situation: the key owner holds Team & access: Manage, but the invitation would hand out access the owner does not have. Here param is set to permissions, and the message is about granting rather than about a missing permission.
param to tell the two apart. Absent means the endpoint permission is missing; permissions means the requested grant is too broad. API access: Manage carries no authority to grant Admin.
PATCH /team/invitations/{id} checks two grants, not one. The key owner has to be allowed to manage the access the invitation holds now as well as the access being requested, so an edit can fail on the existing permissions even when the new ones are well within what the owner holds. Lowering the request does not help in that case.
POST /team/invitations/{id}/resend and DELETE /team/invitations/{id} send no permissions, so there is nothing to grant. Both still check that the key owner may manage the invitation’s existing access, which means an invitation you cannot edit is usually one you can neither resend nor revoke. On revoke the param is invitation_id rather than permissions, because the problem is the target, not a requested grant.
POST /integrations/{key}/connect and GET /integrations/{key}/connect check a pair: Integrations: Manage and Transactions: Manage, the same pair the web app checks before starting a banking connection. GET needs both even though it only reads. The message names what is missing:
POST /transactions/bulk/update and GET /jobs/{id} both need Transactions: Manage, GET included even though it only reads. Like every permission, it is checked on each call, so a key owner who loses it while polling a job gets this error on the next poll, and replaying a submission with the same Idempotency-Key does not get around it. The submitter’s access is also rechecked when the job runs; losing it then fails the job with a permission_denied entry rather than this error.
POST /accounts needs Transactions: Manage, while GET /accounts needs only Transactions: View. A key that can list accounts can still get this error when it tries to create one.
POST /invoices/{id}/match and POST /invoices/{id}/unmatch check a pair: Invoices: Manage and Transactions: Manage, because they recategorize and re-date the deposit. A key that can create and void invoices can still get this error when it matches one. Choosing a line item’s category_id on POST /invoices needs only Invoices: Manage, not access to the categories list.
How to fix:
- Have a company administrator grant the key owner the permission named in the message, under Settings > Members at app.finta.com.
- Retry immediately after. Permission changes apply to the next request, so the existing key starts working without being reissued.
- Do not rotate the key. A new key created under the same user has exactly the same permissions and fails identically.
- For
param: "permissions", either lower the access you are requesting, or have an administrator raise the key owner’s own access first.
plan_upgrade_required
Type:permission_error | Status: 403
The invitation asks for access the company’s plan does not allow. Any permission set that is not full Admin requires an active or trialing Growth subscription or Finta’s internal Enterprise entitlement. On every other plan, POST /team/invitations accepts Admin invitations only.
custom object that happens to match Admin exactly satisfies it. read_only, or a custom set with even one area below manage, does not.
Editing has no Admin exception. PATCH /team/invitations/{id} requires Growth or the internal Enterprise entitlement for every edit, including setting a pending invitation to Admin and including a replay of the permissions already stored. A company without Growth or Enterprise can still invite Admins; it cannot change a pending invitation at all.
This is not subscription_required, which means the company has no usable subscription at all and blocks every endpoint. plan_upgrade_required means the subscription works but sits below Growth.
How to fix:
- Upgrade to Growth in Settings > Plans at app.finta.com, then retry the same body.
- On
POST /team/invitations, sendaccess_template: "admin"if full access is appropriate for this person. Granting Admin is still subject to the key owner’s own access. - On
PATCH /team/invitations/{id}, switching toadminis not a workaround. Upgrade, or change the invitation in Settings > Members instead. - Re-check the plan before every retry. It is evaluated on each request, including on an otherwise exact replay, so a company that downgrades after a successful call starts failing here.
feature_disabled
Type:permission_error | Status: 403
A feature the request needs is not turned on for this company. It covers early-access endpoints, department-grouped income statements, and invitation access controls.
Early access endpoints. The endpoint is in early access and the company doesn’t have it yet. This applies to every request to the endpoint, whatever the body, and nothing changes. param is absent.
GET /reports/income_statement?group_by=department requires the departments feature.
A company without it receives the same feature-disabled envelope, with no param.
Category grouping, including requests that omit group_by, remains available under
the usual Reports: View permission.
group_by=category when you need the standard
statement. Repeated polling does not enable the feature.
Invitations. Role-based access control is turned off for this company, so Admin is the only access level that can be invited. Any other access_template is rejected, including a custom permission set that matches Admin exactly.
read_only on a company without RBAC gets you this error, not an Admin invitation you did not intend to send.
On PATCH /team/invitations/{id} this blocks the endpoint outright. Editing requires role-based access control on every request, so with it switched off no pending invitation can be edited at all, not even to Admin and not even to the access it already has. Here param is permissions rather than access_template.
How to fix:
- For department-grouped income statements, contact Finta for access or select
group_by=category. - On an early access endpoint, contact Finta to request access for the company, then retry the same request.
- On
POST /team/invitations, sendaccess_template: "admin"if the person should have full access. - On
PATCH /team/invitations/{id}, there is no body that works. Change the invitation in Settings > Members at app.finta.com instead. - Otherwise stop and resolve the feature availability for the company before retrying. Nothing in the request body works around it.
- Do not treat this as retryable. It is a property of the company, not a transient condition.
resource_missing
Type:invalid_request_error | Status: 404
On the tax uploads, a file_id that does not exist, belongs to another company, or expired unattached 24 hours after upload returns this code with param: "file_id". Upload the PDF again.
On the tax credit endpoints, a missing or other-company ID returns this code with param: "id". A cursor absent from the filtered company list returns it with param: "starting_after"; restart discovery with the desired filters and no cursor. No matching records on the initial list is a successful empty list, not a 404.
The endpoint path does not exist, or a requested resource is missing, hidden, or unsupported for that operation. For write endpoints, Finta may return resource_missing for unsupported resources so callers do not learn whether a hidden or unauthorized object exists.
This one code covers two cases: the endpoint itself does not exist (an unknown, removed, or renamed path), or a specific record was not found. Both return this same envelope as JSON, regardless of the Accept header you send. The message distinguishes the two, but it is human-readable and may change, so compare the request path against the API Reference rather than branching on the message.
Authentication is checked first, so a request to an unknown path with a missing or invalid API key returns 401 invalid_api_key, not a 404. A 401 is not proof that the path exists; fix authentication first, then interpret the result.
- Double-check the endpoint path. All endpoints are listed in the API Reference.
-
If a call that used to work starts returning
resource_missing, check whether the path was renamed or removed. Report paths use underscores, not hyphens (/reports/income_statement, not/reports/income-statement). -
Verify you’re using the correct base URL:
https://app.finta.com/api/v1. - If the endpoint accepts an ID, verify the ID prefix and that the object is visible to the API key owner.
-
On
GET /transactions, astarting_aftervalue the server cannot use returns this code: an unknown transaction id in the classic listing, or, in incremental sync mode, anything that is not a cursor token the server issued (withparam: "starting_after"). Discard the stored cursor and restart, from the first page in the classic listing or from your last stored watermark in incremental sync. (GET /accountshandles unusable cursors differently: they areinvalid_request, not this code.) -
On
GET /cards, anaccount_idfilter value the server cannot match returns this code withparam: "account_id". Unknown, malformed, blank, and foreign account IDs all receive the same generic message, so the response never reveals whether an account exists in a company you cannot see. Obtain a valid ID fromGET /accountsand correct the request before retrying. A known account with no linked cards is not this error; it returns200with an empty list. -
On
GET /forms/1099/{id}, unknown and foreign-companyf1099_IDs return this same code withparam: "id". Numeric or malformed IDs instead return400 invalid_request. An absent report on a year-filtered list is not a 404: it returns anunknownsummary and empty records. -
On
GET /journal_entries/{id}, a malformed ID, an unknown ID, and a real entry in another company all return this same error withparam: "id", so the response never confirms whether an ID exists elsewhere. Journal entry IDs are also not permanent: rebuilding the books behind a source deletes its entries and creates new ones with new IDs, so a previously valid ID can start returning this error. Do not retry it; re-list with a filter such astransaction_idto find the current entries. -
On
POST /transactions/bulk/update, anaccount_idorcard_idfilter the server cannot match returns this code withparamnaming the field. Unknown and other-company IDs get the same generic response, and no job is created. Get valid IDs fromGET /accountsorGET /cards. Missing transaction IDs intransaction_idsare not this error: they are found when the job runs and fail the job. -
On
POST /transaction_uploads, an unknownaccount_idreturns this code, and so does anupload_idthat does not exist or that another user started. Only the person whose key opened an upload can continue it, so a teammate holding the same ID gets this error rather than the upload. After a lost first response, start a new upload; the abandoned draft is never imported. -
On
GET /aggregations/totalandGET /aggregations/totals, acategory_idthat is malformed, unknown, not available through the API, or another company’s returns this code. The response does not say which case applies. Get a valid ID fromGET /categories. -
On
GET /jobs/{id}, an unknown job, a job in another company, and a job past its 30-day retention all return this same error withparam: "id", so the response never says which case applies. Finished jobs expire 30 days after they complete; jobs still running do not expire. Do not retry it. -
On
GET /taxes/{id}, a well-formed ID that is unknown, staff-only, or outside the caller’s company returns this code withparam: "id". OnGET /taxes, a cursor absent from the filtered company collection returns it withparam: "starting_after"; restart from the first page with your chosen filters. -
On
GET /rules, astarting_aftercursor the server cannot use returns this code withparam: "starting_after". Unknown, deleted, malformed, and foreign-company cursors all get the same generic message, so the response never reveals another company’s rules. Discard the stored cursor and restart from the first page. -
On
GET /rules/{id}andPATCH /rules/{id}, a missing rule, a deleted rule, another company’s rule, and Finta’s own global, internal, and category- or department-owned rules all return this same error withparam: "id"and the messageRule not found, so the response never confirms that a rule exists somewhere you cannot see. Onlyrule_IDs are ever looked up; a numeric ID, a UUID, or a malformed value isinvalid_requestinstead. (DELETE /rules/{id}follows the invitation-revoke convention: an absent rule returns an empty204, not this error.) -
On
POST /rulesandPATCH /rules/{id}, a referenced resource that is missing or not visible to you, such asactions.category_id,actions.department_id, or anaccount_idorcard_idcondition, returns this code with the offending path in the message (for exampleactions.category_id: resource not found). Missing and foreign references get the same response, so the error never reveals another company’s data. -
On the invoice endpoints, an invoice ID that is malformed, unknown, or in another company returns this same error with
param: "id", so the response never confirms whether an invoice exists elsewhere. Unlike the rules endpoints, a malformed invoice ID is this code, notinvalid_request. On match and unmatch, atransaction_idthat doesn’t exist in this company returns it withparam: "transaction_id"; onPOST /invoices, so does a line item’s unknowncategory_id, withparamnaming the line (such asline_items[0].category_id). OnGET /invoices, astarting_aftercursor that isn’t found returns it too. Apaid_transaction_idfilter that matches nothing is not this error; it returns an empty list. -
On
POST /team/invitations, a team or invitation the caller may not act on is concealed this way, withparamnamingcompanyorinvitation_id. A404here does not tell you whether the record exists under someone else. -
On
PATCH /team/invitations/{id}, an invitation that has already been accepted, one belonging to another company, and an ID that never existed all return this same error withparam: "invitation_id". A well-formed ID that is not a pending invitation in your company is indistinguishable from one that is not there. A malformed ID isinvalid_requestinstead, because it is rejected before the lookup. -
POST /team/invitations/{id}/resendconceals the same three cases in the same way, with the same ID rules. -
DELETE /team/invitations/{id}is the exception. A missing ID and an invitation in another company return an empty204, not this error, so that the endpoint never confirms whether a record exists somewhere you cannot see. It returnsresource_missingonly for an invitation that has already been accepted, withparam: "invitation_id". That person is a member now, and revoke never removes a teammate, so retrying the404will not deactivate them. -
On
POST /integrations/{key}/connectandGET /integrations/{key}/connect, akeyfor an integration that is not available to the company returns this code, matching the keysGET /integrationsomits. An integration that is available but not connectable through the API is400 integration_not_connectableinstead. -
On
GET /integrations/{key}/connect, this code also means the key owner has not started a connection for this integration yet.GETonly returns requests the calling key’s owner started, and the message says to callPOSTfirst:
parameter_missing
Type:invalid_request_error | Status: 400
A required parameter was not provided. The param field in the error response tells you which one.
POST /accounts, a field sent as null or as a blank string (empty or whitespace only) counts as missing too, and returns this code rather than invalid_request.
On GET /aggregations/totals, a missing group_by returns this code, and so does a date the category or grouping requires, such as start_date and end_date for group_by=month. See Dates for grouped totals.
POST /transaction_uploads does not use this code. A first call missing account_id or file_totals is invalid_request instead.
How to fix:
- Check the
paramfield in the error response to identify the missing parameter. - Refer to the endpoint’s documentation in the API Reference for required parameters.
invalid_request
Type:invalid_request_error | Status: 400
On GET /transactions, amount_cents must be signed integer cents of at most 100000000000000 in absolute value. A decimal dollar amount such as 35.27, text, or a larger number returns this code with param: "amount_cents"; an invalid amount is not ignored.
On the tax credit endpoints, this covers an invalid type, four-digit year, claim_status, or limit, and an ID that does not match tcred_ followed by a nonempty alphanumeric suffix. param names the invalid field. submitted is a payroll-submission status, so passing it as claim_status is invalid.
On the 1099 filing endpoints, invalid year/stage/boolean/name filters, invalid page sizes, and malformed or differently scoped cursors return this code. error.param identifies the input to repair. Retrieval accepts only f1099_ followed by a nonempty alphanumeric suffix; numeric database IDs are rejected before lookup.
A parameter is malformed or does not apply to the requested endpoint. The param field identifies the problem. Current uses include sending the wrong date shape for a category’s point_in_time value on GET /aggregations/total, mixing deprecated month parameters with full-date parameters on that same endpoint, an invalid filter value on GET /transactions or GET /journal_entries, a limit outside 1 through 500 on GET /parties/merchants, GET /accounts, GET /cards, GET /rules, or GET /jobs/{id}, a rule ID that is not rule_ followed by a nonempty alphanumeric suffix on the /rules/{id} endpoints (rejected with param: "id" before any lookup runs; numeric IDs and UUIDs are never accepted), an invalid rule definition on the rule writes (unknown fields, reversed between bounds, legacy account/card_name conditions submitted as new conditions, a null action value on a create where an update would accept null as action removal, or an unsupported combination, with a useful path such as conditions[0].value or actions.spread in the message), an empty PATCH /rules/{id} body or one carrying only an empty actions object, any request body at all on DELETE /rules/{id} (an empty {} included), and an updated_after value on GET /transactions that is not an integer between 0 and 253402300799. On that last one, check your units: a JavaScript consumer passing Date.now() is sending milliseconds where the API expects seconds.
On GET /accounts and GET /cards, a starting_after cursor the server cannot use is also this code, with param: "starting_after". Malformed cursors, unknown cursors, cursors issued for another company or another collection, and, on /cards, a cursor issued under a different account_id filter all return the same generic message, so the response never confirms whether a cursor exists elsewhere. Note the asymmetry with the classic GET /transactions listing, where an unknown cursor is resource_missing instead; either way, discard the stored cursor and restart from the first page (with the filter you want, on /cards) rather than retrying it.
On POST /transactions/bulk/update, this code covers everything checked before a job is accepted, and none of it creates a job:
- A body that is not JSON sent with
Content-Type: application/json(form-encoded bodies included) or is malformed JSON. - A missing, empty, whitespace-only, or longer-than-255-character
Idempotency-Key, withparam: "Idempotency-Key". - A body with both selectors or neither, an empty
filtersorupdatesobject, an unknown key, a bad ID, date, or enum value, an inverted date range, duplicate or more than 5,000transaction_ids, oraccounting_datesent withaccounting_shift. Update errors carry the field’s path asparam, such asupdates.spread. - A
card_idfilter for a real card that does not belong to theaccount_idfilter, withparam: "card_id".
GET /aggregations/total, a periodic start_date after end_date is this error with param: "start_date", even within one month. On GET /aggregations/totals, the same ordering is rejected for periodic requests and for group_by=month on any category, including point-in-time categories. Full dates are exact inclusive Pacific calendar dates, not month selectors.
On GET /aggregations/totals, this code also covers a group_by, order, or limit outside the allowed values, a malformed date or one that does not fit the category or grouping, a month grouping longer than 120 months (with param: "end_date"), period or department_id, and a starting_after cursor that is malformed or came from a different request. Cursors are bound to the exact category, dates, merchant filter, group_by, and order they were issued for: changing only a day within the same month invalidates the cursor too. Keep every parameter the same while paging, or start again without a cursor.
On GET /reports/income_statement,
a nonblank group_by other than category or department returns this code with
param: "group_by". Omit it for the category-based statement. month is not a valid
value here; request separate date ranges for a monthly series.
group_by before retrying; repeating the unchanged request will not help.
On POST /transaction_uploads, every malformed request is this code, and nothing is saved: a body that is not a JSON object sent as application/json, an unknown field, a first call missing account_id or file_totals, a malformed file_totals (including a row_count above 2,000), rows without columns, more than 500 rows in one call, a row with the wrong number of values or a row number outside 1 to row_count, a bad exclude entry or acknowledged_warnings code, an attempt to change file_totals or account_id after the first call, and an account_id for a Ramp or Brex card account, whose transactions sync automatically and cannot be uploaded. param names the field when there is one. Problems with row values are not this error; they come back in the upload’s own errors list.
On POST /accounts, this code covers a field that is not a string, a type other than bank or credit, any field besides account_name, vendor, and type (with param naming it; institution and name are not accepted aliases), and a body that is not a JSON object sent with Content-Type: application/json. Nothing is created.
On the invoice endpoints, this code covers malformed JSON, an unknown or invalid field on create, match, or unmatch (with param naming it, such as line_items[0].spread_months), a category_id that is not a revenue category, any body at all on POST /invoices/{id}/void, and a GET /invoices filter that is repeated, a date not written exactly as YYYY-MM-DD, or an amount_cents that isn’t an integer of at most 100000000000000 in absolute value. On GET /invoices, a bad date is this code, not invalid_date.
On GET /jobs/{id}, starting_after pages through the job’s errors and must be an errors.next_cursor issued for that same job. A malformed, tampered, empty, or foreign cursor returns this code with param: "starting_after". This is an error in the retrieval, not a change to the job’s status; restart from the first page of errors without a cursor.
On the invitation endpoints it also covers the request body: an unknown field, a permissions object sent next to admin or read_only (even as null), and a custom set that is missing an area, carries an extra one, or uses an unsupported level. On PATCH /team/invitations/{id} the rejected fields include first_name, last_name, email, and version, since that endpoint changes access only. A path ID that is not invite_ followed by letters and digits is rejected the same way with param: "id", before any lookup runs.
POST /team/invitations/{id}/resend and DELETE /team/invitations/{id} are stricter still: they take no request body and no query parameters, so anything you send is this error. That includes an empty JSON object, null, whitespace, a company override, and a version field, which earlier drafts of resend accepted and neither shipped endpoint does.
- Check
param, then use the parameter rules on the endpoint’s reference page. - Do not retry the same request unchanged. Correct or remove the named parameter first.
invalid_date
Type:invalid_request_error | Status: 400
A date query parameter (date, start_date, or end_date) was present but is not a valid ISO 8601 date. The whole value must parse as a calendar date (2025-12-31) or a datetime (2025-12-31T19:00:00Z, 2025-12-31T23:59:00-08:00). Trailing characters (2025-01-31oops), locale formats (12/31/2025), and loose or partial forms (Jan 2025, bare 2025) are rejected rather than coerced to a default or to a leading date. The param field names the offending parameter.
An absent or blank date parameter is not this error: it falls back to the endpoint’s documented default.
GET /transactions/splits and GET /spreads return this code for an unreadable start_date or end_date. GET /transactions ignores one instead.
GET /invoices does not use this code. Its start_date and end_date must be exactly YYYY-MM-DD, and anything else, a datetime included, is invalid_request.
- Send dates in strict ISO 8601.
2025-12-31(date only),2025-12-31T19:00:00Z(UTC), and2025-12-31T23:59:00-08:00(with offset) are all accepted. - To accept the default, omit the parameter entirely or send it blank. Do not send a placeholder string.
- Check the parameter name. The balance sheet uses
date; the income statement and cash flow usestart_dateandend_date. An unrecognized name is silently ignored (you get the default, not an error), so a200with an unexpected date can mean the name was wrong rather than the value.
invalid_date_range
Type:invalid_request_error | Status: 400
On the income statement or cash flow, the start_date is after the end_date. The param field is set to start_date.
- Swap the dates so
start_dateis on or beforeend_date. - Both must be valid ISO 8601 dates (for example
YYYY-MM-DD); an unparseable value returnsinvalid_dateinstead.
integration_not_connectable
Type:invalid_request_error | Status: 400
The integration is available to the company but cannot be connected through the API yet: its api_connectable is false in GET /integrations. Both POST /integrations/{key}/connect and GET /integrations/{key}/connect return it, with param: "key".
404 resource_missing instead, matching the keys GET /integrations omits.
How to fix:
- Connect the integration in Settings > Integrations at app.finta.com.
- Do not retry. The same request fails until the integration’s
api_connectablebecomestrue. - Read
api_connectablefromGET /integrationsbefore calling rather than hard-coding which integrations are connectable. It can change fromfalsetotrueat any time.
closed_books
Type:invalid_request_error | Status: 400
POST /invoices was sent an invoice_date in a tax year the company has finished with Finta. Books for a filed year are locked, because filed numbers can’t change. Nothing was created.
- Use an
invoice_dateafter the locked year. The message names the first open date. - Do not retry the same request. The lock is a property of the company’s books, not a transient condition.
invalid_file
Type:invalid_request_error | Status: 400
The file a tax upload was given cannot be used: it is not a PDF, or, for a file from POST /file_uploads, its bytes were never PUT to the upload URL. POST /files returns it with param: "file" when the uploaded file is not a PDF; the tax uploads return it with param: "file_id". The content is checked, not the file name or declared type. Nothing was recorded.
- For a direct upload whose bytes were never PUT, PUT the PDF to the returned
urlwith every header inheadersbeforeexpires_at, then retry the tax upload with the samefile_id. - If the URL expired, or the file is not a PDF, upload the PDF again for a new
file_id. A URL only accepts the bytes whosesizeandchecksumit was created for.
wrong_document_type
Type:invalid_request_error | Status: 400
POST /taxes/income_uploads was given a Delaware franchise tax report (a PDF whose text contains “Annual Franchise Tax Report”), not an income tax return. Nothing was recorded and the file is still unattached.
- Send the same
file_idtoPOST /taxes/franchise_uploads.
transaction_update_failed
Type:invalid_request_error | Status: 422
The update reached the bookkeeping layer, but eligibility or validation rules rejected it. The param field names the field that was rejected. The message varies by validation, so branch on error.code, not exact message text.
accounting_date also has to land inside a bounded window, and a date outside it is rejected with this same code:
- Check
paramto see which field was rejected, then drop or correct that field and retry. - Verify you are editing a visible standard transaction. Transfers and splits reject many changes that are valid elsewhere.
- Verify
category_idpoints to a selectable category anddepartment_idto an assignable leaf department. - For
accounting_date, check the value against the company’sincorporation.datefromGET /companyand against the three-year forward bound. - Surface the response message to a human if bookkeeping rejects a change that looks valid.
category_update_failed
Type:invalid_request_error | Status: 422
The legacy code for a rejected category update, superseded by transaction_update_failed. It remains in the error enum so existing handlers keep compiling. Handle both codes if you are matching on 422 responses.
invitation_invalid
Type:invalid_request_error | Status: 422
The body was well-formed and every access check passed, but the invitation service rejected the data itself. On POST /team/invitations this covers an email that is not a valid address, an email that already belongs to a teammate (active, inactive, or deleted), and a pending invitation that conflicts with the one you are creating. param names the field at fault.
200 OK instead. You get this error when the email matches but something else does not.
On PATCH /team/invitations/{id} and POST /team/invitations/{id}/resend it means something different: the stored invitation fails validation, not your request. Both validate the whole stored record, so a legacy invitation with, say, a blank first name is rejected with param: "first_name" even though the edit body only carried access_template and the resend carried nothing at all. Adding the named field to the body does not fix it, because identity fields are rejected there with 400 invalid_request, and resend has no body to add anything to. An incomplete custom permission set is also a 400, not this error.
The message wording varies by validation. Branch on error.code and read param.
How to fix:
- Correct the field named in
paramand retry. - If
paramnames an identity field on an edit or a resend, repair the invitation in Settings > Members at app.finta.com first, then retry the same call. - If the person is already a teammate or already holds a different pending invitation, use Settings > Members.
POST /team/invitationsonly creates; changing a pending invitation’s access isPATCH /team/invitations/{id}and sending its email again isPOST /team/invitations/{id}/resend. Withdrawing one isDELETE /team/invitations/{id}. Editing or removing an accepted teammate is still a Settings action.
rule_already_exists
Type:invalid_request_error | Status: 409
Returned by POST /rules and PATCH /rules/{id} when the resulting definition exactly matches an automation rule the company already has. The comparison is against the normalized definition: condition order, action key order, and the casing of case-insensitive text values do not make two definitions different, and neither does the create-only apply_to_existing_transactions flag. Rules with the same conditions but different actions are not duplicates.
- Read the existing rule with
GET /rules/{id}using the ID from the message, and use or edit that rule instead of sending the same definition again. - If you meant to create a different rule, change the definition. Replaying the identical body accomplishes nothing and never schedules another historical run.
- Expect this response when retrying an uncertain create. If your first attempt timed out after the rule was saved, an identical retry returns this conflict rather than a second
201. Treat it as confirmation that the rule exists and retrieve it. - If the duplicate’s ID is temporarily unavailable, you get
503 rules_not_readyinstead of this error. Retry later; no extra rule or historical run is created in the meantime.
integration_already_connected
Type:invalid_request_error | Status: 409
POST /integrations/{key}/connect was called for an integration that is already connected for this company. A new connection link is only available when the integration’s status is requires_reconnect.
- Stop. There is nothing to connect. The request conflicts with the integration’s current state, so a retry returns the same error.
- If you expected a reconnect, check the integration’s
statusinGET /integrations.POSTreturns a link only once it isrequires_reconnect.
connect_link_in_progress
Type:invalid_request_error | Status: 409
Another user in the company already holds the open (pending or authorizing) connect request for this integration. A company has at most one open request per integration, so POST /integrations/{key}/connect returns this until that request finishes or expires. Your own open request never produces this error: calling POST again returns it with 200.
authorizing, that includes the extra time Finta allows to finish it.
How to fix:
- Do not retry with backoff. This is not a transient failure.
- Wait for the other user’s request to finish or pass the time in the message, then call
POSTagain. - Check
GET /integrationsbefore trying again. If the other user’s attempt succeeded, the integration is connected and there is nothing left to do.
idempotency_key_conflict
Type:invalid_request_error | Status: 409
POST /transactions/bulk/update was called with an Idempotency-Key that already identifies an accepted bulk update with a different body. Keys are scoped to the company and the user who owns the API key, so another API key belonging to the same user shares them.
null differs from an omitted field. Sending the same key with the identical body is not this error; it returns the original job’s 202 again.
How to fix:
- If you are retrying after a timeout, resend the exact body you sent the first time. Do not edit it between attempts.
- If you already have the original job’s ID, poll it instead of changing the key to find out whether the work ran.
- If you meant to start different work, use a new key for it.
upload_committed
Type:invalid_request_error | Status: 409
POST /transaction_uploads was asked to change an upload that is already committed: new rows, exclusions, answers, or acknowledgements. A committed upload cannot be edited.
upload_id is never this error, and neither is repeating commit: true: both return the upload’s current status, so a commit retried after a timeout is harmless.
How to fix:
- Post only
upload_idto read the upload’sstatusandresult. - If the imported rows look wrong, the user can review or undo the upload in Finta at
result.url.
invoice_already_exists
Type:invalid_request_error | Status: 409
POST /invoices found an invoice that is not voided with the same customer name (ignoring case and surrounding spaces), invoice date, and total. Finta treats it as the same invoice, so nothing was created. The message names the existing invoice’s ID.
- If this was a retry, treat it as success: the first attempt created the invoice. Read its ID from the message, or find it with
GET /invoicesfiltered bycustomer,start_dateandend_date, andamount_cents. - If it really is a second invoice to the same customer on the same date for the same total, record it with its own date, or put both amounts on one invoice as two line items.
- A voided invoice never causes this error, so an invoice voided by mistake can be entered again.
invoice_not_open
Type:invalid_request_error | Status: 409
POST /invoices/{id}/void or POST /invoices/{id}/match was called on an invoice that is not open. Only open invoices can be voided or matched. Nothing changed.
200. Neither is repeating a match with the deposit it’s already matched to.
How to fix:
- To void a paid invoice, unmatch its payment first, then void it.
- To match a different deposit to a paid invoice, unmatch the current one first.
- A voided invoice can’t be reopened. Record a new invoice instead.
payment_already_matched
Type:invalid_request_error | Status: 409
POST /invoices/{id}/match named a deposit that already paid another invoice. A deposit pays at most one invoice. Nothing changed.
- Find the invoice the deposit paid with
GET /invoices?paid_transaction_id=txn_.... If that match is wrong, unmatch it first. - Otherwise pick another deposit.
transaction_not_matchable
Type:invalid_request_error | Status: 409
POST /invoices/{id}/match named a transaction that can’t pay this invoice. It must be a bank deposit for exactly the invoice’s amount and currency, not a transfer, and not split. Nothing changed.
- Pick another deposit. See Matching a payment for how to find candidates.
- For a split deposit, remove the split first, or match it in the Finta app, which asks how to reconcile it.
invoice_not_matched
Type:invalid_request_error | Status: 409
POST /invoices/{id}/unmatch named a transaction the invoice is not matched to. Naming the transaction protects against undoing a different match, so when the invoice is now matched to some other deposit, nothing changes.
200.
How to fix:
- Re-read the invoice with
GET /invoices/{id}and check itspaid_transaction_idbefore deciding whether to unmatch it.
invoice_operation_failed
Type:invalid_request_error | Status: 409
A void, match, or unmatch would break a bookkeeping rule, for example matching an invoice dated before the company was incorporated. The message gives the reason. Nothing changed.
- Do not retry the same request. It fails the same way until the underlying condition changes.
- Show the message to the user, and resolve the condition in the Finta app.
file_already_attached
Type:invalid_request_error | Status: 409
A tax upload was given a file that is already attached to a tax record. A file attaches once.
GET /taxes.
How to fix:
- To attach the PDF to another independently eligible record, upload it again and use the new
file_id. A fresh file cannot replace an existing return or confirmation.
tax_upload_not_allowed
Type:invalid_request_error | Status: 409
A tax upload is not available in the filing’s current state. The message explains whether the year is unavailable or locked, a Taxes page blocker remains, an income filing or Delaware confirmation already exists, or a Finta onboarding/preparation/payment/filing workflow is protected. Nothing changes and the file remains unattached.
- Show the message to the user and resolve an actual eligibility blocker in the Finta app.
- Do not retry unchanged filing state or create a new file ID to bypass the refusal. Existing documents and protected Finta workflows cannot be replaced through these endpoints.
- When retrying a request whose response was lost, read the tax record first to see whether it already succeeded.
rate_limit_exceeded
Type:rate_limit_error | Status: 429
You’ve exceeded 6,000 requests per 60 seconds. Authenticated requests are rate limited per API key. Unauthenticated requests (missing or invalid key) are rate limited to 120 requests per 60 seconds per IP address.
The response includes a Retry-After header with the number of seconds to wait, plus the standard X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers.
- Honor
Retry-Afteras the minimum wait. Do not retry sooner. - For retries beyond the first, apply exponential backoff with jitter and cap the total number of attempts (e.g. 3 to 5) so you fail fast rather than retrying indefinitely.
- Treat
X-RateLimit-Remainingas a leading indicator: if it is near zero on a successful response, slow down voluntarily. - If you are paginating, use larger
limitvalues (up to 500) to reduce the number of requests.
monthly_limit_exceeded
Type:limit_error | Status: 429
Your company has exceeded its monthly API call limit. Each plan has a cap on total API calls per company per calendar month:
The limit is per company, not per API key. If a company has multiple API keys, they all share the same monthly pool. The count resets at midnight Pacific Time on the 1st of each month. A temporary override set by Finta support also applies to the whole company, every user and key alike, and the message then quotes that limit instead of the plan’s.
Blocked requests are not counted toward the limit.
Retry-After is not sent in this case, deliberately. The meaningful remediation is to upgrade the plan or wait until the 1st of next month, not to schedule a retry.
The error type is
limit_error, not rate_limit_error. These are different situations: rate_limit_error means “slow down and retry in a few seconds.” limit_error means “you are out of quota for the month.” Upgrade your plan or wait for the reset.- Branch on
error.codefirst to distinguish this fromrate_limit_exceeded. Naive retry loops that do not check the code will burn quota that is already exhausted. - Stop retrying. Surface the error to your user with the reset date from the message body.
- Upgrade your plan in Settings > Plans at app.finta.com.
- If you can’t upgrade, wait for the limit to reset on the 1st of next month.
- Reduce unnecessary API calls by caching responses and using larger pagination
limitvalues.
invitation_create_failed
Type:api_error | Status: 500
The invitation cleared validation, permissions, plan, and feature checks, but could not be saved. This is one of the four invitation api_error codes, alongside permissions_update_failed, invitation_resend_failed, and invitation_revoke_failed. The rules endpoints add three more stable-coded 5xx responses of their own: rules_not_ready, rule_busy, and rule_deletion_failed. Connecting an integration adds connect_link_failed, creating an account adds account_create_failed, and committing a transaction upload adds upload_commit_failed.
200 OK rather than creating a second one, so resending is not a duplicate risk. There is no idempotency key to send.
How to fix:
- Retry the same body with exponential backoff and a bounded number of attempts.
- Do not change the names, email, or permissions between attempts. Any difference breaks the exact match and can create a second invitation. Matching email alone does not make a request a replay.
- Treat an uncertain network result the same way: resend the identical body and read the status code to learn what happened.
- If it keeps failing after a few attempts, surface it rather than looping. The condition is on Finta’s side and will not clear because you asked again.
permissions_update_failed
Type:api_error | Status: 500
The edit cleared validation, grant authority, plan, and feature checks, but the new permissions could not be saved. It is the edit-path member of the invitation api_error family and is returned by PATCH /team/invitations/{id} only.
invitation_create_failed, and internal exception detail is never exposed in the message.
This is safe to retry with the identical body. The endpoint sets permissions to the state you asked for rather than appending anything, so a request that actually landed before the error comes back as changed: false on the retry.
How to fix:
- Retry the same body with exponential backoff and a bounded number of attempts.
- Treat an uncertain network result the same way. Resend and read
changedto learn whether the first attempt had already applied. - Expect every check to run again on each attempt. A retry can return
403instead if the plan, the feature flag, or the key owner’s access changed in between. - If it keeps failing, surface it rather than looping. The condition is on Finta’s side. Read
GET /teamto confirm the invitation’s current access before deciding what to do next.
invitation_resend_failed
Type:api_error | Status: 500
The resend cleared authorization and the invitation lookup, but renewing the link or requesting delivery failed. It is returned by POST /team/invitations/{id}/resend only. An internal revision check that fails surfaces here too, and there is no client-side version parameter that could correct it, because the endpoint accepts no body.
- Retry with bounded exponential backoff only if another email and a replaced link are acceptable. That is a product decision, not a transport one.
- If it is not acceptable, read
GET /teamand compare the invitation’sexpiresandversionagainst what you saw before. A renewal that already landed shows there. - Treat a timeout or a dropped connection the same way. The failure tells you nothing about whether the email went out, and an automatic retry layer that does not know this will send duplicates.
- Do not send an
Idempotency-Keyheader expecting deduplication. The endpoint does not implement one.
invitation_revoke_failed
Type:api_error | Status: 500
The revoke cleared authorization, but the deletion itself failed. It is returned by DELETE /team/invitations/{id} only.
204 just the same. There is no version and no idempotency key involved.
How to fix:
- Retry the same
DELETEwith bounded exponential backoff and a capped number of attempts. - Treat a timeout or a dropped connection identically. You cannot tell whether the deletion landed, and it does not matter.
- Remember that each attempt still runs authorization and still counts against your monthly call allowance, so cap the loop rather than retrying indefinitely.
- A retry can come back
401or403even though the deletion already succeeded, if the credential or the key owner’s access changed in between. Confirm withGET /teamrather than assuming the invitation survived.
taxes_not_ready
Type:api_error | Status: 503
GET /taxes cannot return its matching records because at least one is missing a public tax ID. The request fails instead of returning a partial list or inventing an ID.
Retry-After is not sent for this error. Retry with exponential backoff and a bounded number of attempts. If it persists, contact support. Reading taxes never assigns IDs or runs a backfill, so the retry itself does not repair the condition. See Tax filings.
rules_not_ready
Type:api_error | Status: 503
The rules endpoints could not return or reference saved rules faithfully. This covers eligible rules that are still waiting on a public rule_ ID during Finta’s ID rollout, a stored definition the API cannot represent, and a duplicate rule whose ID is temporarily unavailable when a write hits 409 rule_already_exists territory.
GET /rules fails the whole request rather than returning a page with a rule missing or altered, and GET /rules/{id} fails rather than returning a partial object. Only the requested rule matters on retrieve: unrepresentable definitions or missing IDs on other rules do not block it.
How to fix:
- Retry with exponential backoff.
Retry-Afteris not sent for this error. - Do not treat the response as partial success. Nothing was returned, and on a write nothing was saved or scheduled.
- A
503from a read does not mean the read started any work. Reading rules never assigns missing IDs, runs rules, or changes transactions, so retrying aGETdoes not fix the condition; time does. - If the error persists, the stored definition needs attention on Finta’s side. Contact support rather than continuing to retry.
forms_not_ready
Type:api_error | Status: 503
The 1099 endpoints cannot faithfully return the selected records yet. This covers report years that are missing or outside 1000–9999, historical records waiting for public identities, and unsupported workflow data. An empty report with an invalid year is also rejected.
unknown summary and must not be interpreted as no filing requirements.
How to fix:
- Retry with exponential backoff and a bounded number of attempts. No
Retry-Afteris sent for this error. - Do not treat it as partial success: the response does not provide a usable page or filing record.
- GET does not generate reports, assign IDs, or repair data; retrying does not start those operations.
- If it persists, contact support instead of polling indefinitely.
rule_busy
Type:invalid_request_error | Status: 503
The rule is currently applying itself to existing transactions, so a conflicting mutation was refused. A historical run requested at create time holds a lock on the rule while it executes; an update or delete arriving mid-run returns this error and changes nothing.
- Retry the same request with exponential backoff. The run ends on its own, and the retry succeeds once the lock is released.
- Do not treat the error as a partial write. The rule’s definition, and any queued historical work, are exactly as they were before the request.
- There is no way to cancel a running application through the API, and no job or polling endpoint to watch. Retrying the mutation is the mechanism.
rule_deletion_failed
Type:api_error | Status: 500
A DELETE /rules/{id} cleared validation and authorization, but the deletion itself failed. Everything rolls back together: the rule still exists, linked transactions keep their categories, and queued historical work is not cancelled. A failed deletion is never converted into a 204.
- Retry the same
DELETEwith bounded exponential backoff. Deletion is safe to retry: once the rule is gone, a repeat returns204without repeating any effects. - Treat a timeout or a dropped connection the same way. Rule deletes follow the same absent-is-
204convention as invitation revokes, so a retry tells you the outcome. - If it keeps failing, surface it rather than looping. The condition is on Finta’s side and needs operator attention.
connect_link_failed
Type:api_error | Status: 500
POST /integrations/{key}/connect passed its checks, but the connection link could not be created.
- Retry the same
POSTwith exponential backoff and a bounded number of attempts. Repeated calls never produce a second link: while a request is open,POSTreturns it with200. - Remember that each attempt counts against your monthly call allowance, so cap the loop.
- If it keeps failing, surface it rather than looping. The condition is on Finta’s side.
account_create_failed
Type:api_error | Status: 500
POST /accounts validated the request, but the account could not be saved. No account was created.
- Retry the same request with exponential backoff and a bounded number of attempts. Creation is duplicate-safe: an existing manual account with the same
type,vendor, andaccount_nameis returned with200rather than created again, so a retry cannot produce two accounts. See Retries and duplicates. - Treat a timeout or a dropped connection the same way, and read the status code of the retry:
201means this attempt created the account,200means an earlier one did. - If it keeps failing, surface it rather than looping. The condition is on Finta’s side.
upload_commit_failed
Type:api_error | Status: 503
POST /transaction_uploads passed every check on a commit, but the import could not be started. Nothing was imported, and the draft is kept exactly as it was.
- Commit again with the
upload_idfrom the message andcommit: true, with exponential backoff and a bounded number of attempts. An upload commits once, so a retry cannot import it twice. - Do not start a new upload to work around it. The draft already holds every row, exclusion, and acknowledgement you sent.
- If it keeps failing, surface it rather than looping. The condition is on Finta’s side.
tax_upload_failed
Type:api_error | Status: 500
A tax upload passed every check but the record could not be saved. Nothing changed, and the file is still unattached.
- Retry with the same body and
file_id, with backoff, while the file is still valid (24 hours from upload). If it persists, contact support.