openapi: 3.0.3
info:
  title: Finta API
  version: "1.0"
  description: |
    The Finta API provides programmatic access to your company's financial data,
    including transactions, journal entries, categories, financial reports, sheets, and the team directory.

    ## Authentication

    Each API key is scoped to a single company. There is no company ID parameter.
    The key already knows which company it belongs to. If you have access to multiple
    companies, create a separate key for each.

    A key acts as its owner; it does not have a separate permission snapshot. The owner
    must currently have **API access: Manage**, plus the permission required by the endpoint:

    | Endpoint | Required permission |
    |----------|---------------------|
    | `GET /sheets`, `GET /sheets/{id}`, `GET /sheets/{id}/cells` | Forecast: View |
    | `POST /sheets`, `PATCH /sheets/{id}`, `DELETE /sheets/{id}` | Forecast: Manage; existing sheet entitlements apply |
    | `GET /company` | Company: View |
    | `GET /onboarding`, `PATCH /onboarding` | Company: Manage and Integrations: Manage, as onboarding needs in the web app |
    | `GET /taxes`, `GET /taxes/{id}` | Taxes: View |
    | `POST /files`, `POST /file_uploads`, `POST /taxes/income_uploads`, `POST /taxes/franchise_uploads` | Taxes: Manage |
    | `GET /team` | Team & access: View |
    | `DELETE /team/invitations/{id}` | Team & access: Manage; target-management limits apply. No additional Growth/RBAC gate. |
    | `POST /team/invitations/{id}/resend` | Admin role required. No additional Growth/RBAC gate. |
    | `PATCH /team/invitations/{id}` | Team & access: Manage; RBAC and active/trialing Growth required, with grant and target limits. |
    | `POST /team/invitations` | Admin role required; requested access must also be allowed by the plan. |
    | `GET /categories`, `GET /departments`, `GET /rules`, `GET /rules/{id}` | Accounting setup: View |
    | `GET /tags` | Accounting setup: View |
    | `POST /tags`, `PATCH /tags/{id}`, `DELETE /tags/{id}` | Accounting setup: Manage |
    | `POST /categories` | Accounting setup: Manage; some templates also need the Startup plan, and some only Finta adds |
    | `POST /rules` | Accounting setup: Manage; applying to existing transactions (`apply_to_existing_transactions`, default true) also requires Transactions: Manage |
    | `PATCH /rules/{id}` | Accounting setup: Manage; Transactions: Manage is not required |
    | `DELETE /rules/{id}` | Accounting setup: Manage and Transactions: Manage, including absent retries |
    | `GET /integrations` | Integrations: View |
    | `GET /tax_credits`, `GET /tax_credits/{id}` | Taxes: View |
    | `GET /reports/*`, `POST /reports/exports` | Reports: View |
    | `GET /accounts`, `GET /cards`, `GET /transactions`, `GET /transactions/{id}`, `GET /transactions/splits`, `GET /spreads` | Transactions: View |
    | `GET /forms/1099`, `GET /forms/1099/{id}` | Taxes: View |
    | `POST /transactions/bulk/update`, `GET /jobs/{id}` | Transactions: Manage |
    | `PATCH /transactions/{id}`, `POST /transactions/{id}/split`, `PATCH /transactions/{id}/split`, `DELETE /transactions/{id}/split`, `POST /accounts`, `POST /transaction_uploads`, `POST /transfers`, `DELETE /transfers/{id}` | Transactions: Manage |
    | `GET /journal_entries`, `GET /journal_entries/{id}`, `GET /aggregations/total`, `GET /aggregations/totals`, `GET /parties/merchants` | Reports: View |
    | `GET /invoices`, `GET /invoices/{id}` | Invoices: View |
    | `POST /invoices`, `POST /invoices/{id}/void` | Invoices: Manage |
    | `POST /invoices/{id}/match`, `POST /invoices/{id}/unmatch`, `POST /transactions/{id}/match_invoices` | Invoices: Manage and Transactions: Manage |
    | `GET /reconciliations`, `GET /reconciliations/{id}` | Reconciliation: View |
    | `POST /reconciliations/{id}/reconcile`, `POST /reconciliations/{id}/unreconcile` | Reconciliation: Manage |

    Permission changes apply to the next request. Removing API access permanently revokes
    the owner's active keys for that company; restoring access requires creating a new key.

    Authenticate by including your API key in the `Authorization` header:

    ```
    Authorization: Bearer finta_...
    ```

    ### Key prefix convention

    Every Finta credential is prefixed so it can be recognized at a glance in logs, error messages, and secret scanners. Today there is one credential type and its prefix is `finta_`. As additional credential or token types are introduced (for example restricted keys or webhook signing secrets), each will have its own distinct prefix that stacks on the brand prefix (e.g. `finta_<type>_...`). Treat the prefix as load-bearing: do not strip it before sending, and do not assume a missing or unknown prefix is still a Finta credential.

    ## Onboarding

    A company starts using Finta by onboarding: it answers a short questionnaire, Finta decides
    whether it can take the company on, the company connects the banks and cards it selected, and
    Finta syncs them for the first time. Until the company is onboarded, every endpoint returns
    `403 Forbidden` with `type: permission_error` and `code: onboarding_incomplete`, except these:

    | Endpoint | Why |
    |----------|-----|
    | `GET /onboarding`, `PATCH /onboarding` | Onboarding itself |
    | `GET /integrations` | To see the banks and cards to connect |
    | `POST /integrations/{key}/connect`, `GET /integrations/{key}/connect` | To connect them, only once Finta has approved the company (`onboarding_status` `connect_banks` or `syncing`) |
    | `POST /team/invitations` | To invite the teammate who can approve a connection, such as a Brex account admin |
    | `POST /feedback` | To write to Finta support |

    The error's `onboarding_status` says where the company is (the `status` of `GET /onboarding`):

    ```json
    {
      "error": {
        "type": "permission_error",
        "code": "onboarding_incomplete",
        "message": "This company hasn’t finished onboarding with Finta, so this endpoint isn’t available yet. Call GET /api/v1/onboarding and follow its next_step.",
        "doc_url": "https://www.finta.com/docs/api-reference/errors#onboarding_incomplete",
        "onboarding_status": "connect_banks"
      }
    }
    ```

    Call `GET /onboarding` and follow its `next_step` until its `status` is `complete`. A company
    Finta needs a call with (`call_required`) or can't onboard (`not_eligible`) stays here, as it
    does in the web app. Nothing counts toward the monthly limit until the company is onboarded.

    Connecting banks is part of onboarding, but only after Finta approves the company, as in the
    web app: a bank connected earlier would sync outside onboarding, and onboarding's first sync
    would never start. Before approval, both connect endpoints return `onboarding_incomplete`
    with a message to finish the questionnaire first. `GET /integrations` answers throughout.

    ## Rate Limits

    The API enforces two independent limits. Both surface as `429 Too Many Requests`, but they are distinct error codes with different retry guidance.

    ### Per-minute burst limit

    Authenticated requests are limited to **6,000 requests per 60 seconds** (100 per second) per API key. Unauthenticated requests (missing or invalid key) are limited to **120 requests per 60 seconds** per IP address.

    The window is **fixed**, not rolling. Each 60-second wall-clock interval is its own counter; all requests within that interval share it, and the counter resets to 0 in a single step at the next boundary. Available budget jumps from `0` back to the full bucket size (`6000` for authenticated API keys, `120` for unauthenticated IP buckets) instantaneously at the boundary, rather than aging out one slot at a time. Trust `X-RateLimit-Reset` for the exact reset timestamp.

    Successful responses and burst-limit rejections include the following headers.
    Do not assume they are present on other errors: early authentication, authorization,
    and monthly-limit rejections can omit them. This describes existing header behavior,
    not a new quota policy.

    | Header | Description |
    |--------|-------------|
    | `X-RateLimit-Limit` | Maximum requests per window (`6000` for authenticated requests, `120` for unauthenticated). |
    | `X-RateLimit-Remaining` | Requests remaining in the current window. |
    | `X-RateLimit-Reset` | Unix timestamp when the window resets. |

    On a 429 for this case, an additional header is included:

    | Header | Description |
    |--------|-------------|
    | `Retry-After` | Number of seconds until the next request is safe. |

    The error body is `type: rate_limit_error` / `code: rate_limit_exceeded`.

    **Retry guidance:** This case is retryable. Honor `Retry-After` as the minimum wait, then apply exponential backoff with jitter for any retries beyond the first (cap at a few minutes; abandon after a small number of attempts to avoid retry storms). Do not retry tighter than `Retry-After`.

    ### Monthly per-company limit

    Each company has a monthly cap on total API calls, independent of the per-minute burst:

    | Plan | Monthly cap |
    |------|-------------|
    | Formation | 10 |
    | Startup | 300 |
    | Growth | 30,000 |
    | Enterprise | Unlimited |

    Enterprise is enabled internally by Finta for contract-managed companies. It bypasses
    commercial monthly API/MCP quotas and ordinary subscription lockouts, even when the
    Stripe subscription is absent or canceled. Requests and MCP tool calls are still counted.
    Enterprise does not bypass valid credentials, company/user permissions, company closure,
    request validation, payload bounds, or per-minute abuse limits. Disabling Enterprise restores
    ordinary plan gates using the current billing state; it does not change Stripe billing.

    Nothing counts until the company finishes onboarding, and `GET /onboarding` and `PATCH /onboarding`
    never count.

    Counts reset at **midnight Pacific Time (US & Canada)** on the 1st of each month. This is the Finta application's configured time zone; the cap is computed in that zone, not in UTC. Consumers in other time zones should convert the local-midnight Pacific boundary to their own clock; note that the absolute UTC offset shifts by one hour twice a year for daylight saving (PST is UTC-8, PDT is UTC-7). Blocked requests are not counted toward the limit. The error body is `type: limit_error` / `code: monthly_limit_exceeded`, and the `message` field includes the reset date in human-readable form.

    **Retry guidance:** This case is **not retryable in the short term.** No `Retry-After` header is sent, deliberately, because the only meaningful remediation is to upgrade the plan (Settings -> Plans at app.finta.com) or wait until the 1st of the next month. Naive retry loops should branch on `error.code` and stop retrying when they see `monthly_limit_exceeded`. SDKs that auto-retry on 429 should look at `error.code` (or the absence of `Retry-After`) before re-issuing.

    A temporary company limit override, set by Finta support for the current month, may change
    the allowance and the wording of the monthly-limit message. It applies to every user and
    API key in the company. The error code and no-short-term-retry rule remain unchanged.

    ## Pagination

    List endpoints use cursor-based pagination. Pass `limit` to control page size
    (default 100, max 500). To fetch the next page, pass `starting_after` with the
    `next_cursor` value from the previous response. Responses include `object: "list"`,
    `url` (the canonical path of the collection, relative to the API host), `has_more`
    (whether more results exist), `next_cursor` (an opaque value to pass unchanged as `starting_after`),
    and `data` (the page of items).

    ## Reporting dates and freshness

    Reports and category totals use calendar dates in **America/Los_Angeles (Pacific Time)**,
    including defaults such as today and the current quarter. Periodic requests include both
    `start_date` and `end_date` exactly. Point-in-time requests include activity through the
    exact `date`. Response dates identify the boundaries actually used, not rounded months.

    Whole months read whole-month rollups. The current month's first day through today in
    Pacific Time reads month-to-date (MTD) rollups when fresh. Other partial-month boundaries,
    including a midmonth start, read raw journal entries for just those partial months and are
    slower; any whole months in the same request still use rollups. Stale or unfilled MTD
    rollups safely fall back to raw entries. Whole-month requests and month-aligned
    month-start-to-today or year-to-date requests stay on the fast rollup path when MTD is fresh.

    Fresh MTD must end at exactly the requested cutoff. If a refresh advances the rollup
    across Pacific midnight during a request, exact live entries preserve the original
    cutoff; later-day MTD is never included in that answer.

    `refreshed_at` is a Unix timestamp covering the entire report, total, or grouped page,
    including partial months. For rollup-only reads it is the maximum contributing rollup
    timestamp, including MTD. Any live raw-entry portion is current as of the call, even when
    no raw entries match. A fresh MTD row contributes its timestamp even when it contains zero
    activity through today; future-only merchants do not appear as empty merchant groups.
    A grouped page covers its returned groups; a later month's refresh does not advance an
    earlier monthly page's timestamp.
    It is null only when no contributing timestamp or live raw read is
    available. Rollup-backed activity may lag newly imported or edited entries.

    ## Amounts

    Financial resource fields follow these two rules. Generic spreadsheet cell arrays are
    a scoped exception, described below:

    1. **Integer cents.** Monetary values are integers, never floats and never formatted strings. `$499.00` is `49900`. `$1,500.00` is `150000`. Divide by 100 to get dollars. This avoids floating-point precision errors in financial calculations.
    2. **`_cents` suffix.** The field name always ends in `_cents` (e.g. `amount_cents`, `balance_cents`, `total_cents`, `net_income_cents`, `change_in_cash_cents`, `cash_cents`). If a field name does not end in `_cents`, it is not a monetary value.

    These rules apply to monetary resource fields (transactions, journal entries, income statement,
    balance sheet, cash flow). There is no "summary" or "display" sibling field that returns
    the same financial-resource amount in dollars or as a string.

    **Spreadsheet exception:** `/sheets` range `values` are generic spreadsheet cells, not
    financial-resource amount fields. They preserve the existing sheet engine's units: for
    example, a sheet amount of $20,000 is entered as `20000`, not `2000000`. Formatting does
    not trigger a cents conversion. Formula results use the existing spreadsheet evaluator
    and FINTA behavior. Do not apply the financial-resource cents convention to cell arrays.

    Sign conventions:

    - **Income statement**: revenue/income amounts are positive, expense amounts are negative. Summing every section's `total_cents` yields net income without sign-flipping.
    - **Balance sheet**: `balance_cents` carries the natural-sign cumulative balance for that account at the report date.
    - **Cash flow**: `amount_cents` is positive for cash inflows and negative for cash outflows.

    ## Errors

    Errors return a consistent JSON structure:

    ```json
    {
      "error": {
        "type": "invalid_request_error",
        "code": "resource_missing",
        "message": "The requested resource was not found.",
        "doc_url": "https://www.finta.com/docs/api-reference/errors#resource_missing"
      }
    }
    ```

    ### Status codes

    | Status | Meaning |
    |--------|---------|
    | `200 OK` | Request succeeded. |
    | `201 Created` | A new resource was created, such as an invitation, an account, or a transaction upload draft. |
    | `400 Bad Request` | Request was malformed or had invalid parameters. |
    | `401 Unauthorized` | The bearer credential is missing, malformed, or unrecognized. The fix is to obtain a valid API key. |
    | `403 Forbidden` | The API key is valid, but the caller does not have permission to access the resource. The fix is to regain the required company or product-area access. See the `Forbidden` response component for the specific codes. |
    | `404 Not Found` | The requested resource, or the endpoint itself, does not exist. Unknown or renamed paths (for example an old hyphenated report path like `/reports/income-statement` instead of `/reports/income_statement`) return this same envelope with `type: invalid_request_error` and `code: resource_missing`, as JSON, regardless of the `Accept` header. |
    | `409 Conflict` | The request conflicts with the resource's current state, for example changing an onboarding answer after Finta decided. |
    | `422 Unprocessable Entity` | Invitation data failed validation, for example an invalid email or a conflicting invitation or existing teammate. |
    | `429 Too Many Requests` | Either the per-minute burst rate limit (retryable, with `Retry-After`) or the monthly per-company API call limit (not retryable in the short term, no `Retry-After`). Branch on `error.code` to distinguish the two and avoid retry storms on monthly exhaustion. See the Rate Limits section for the full retry guidance. |
    | `5xx` | An unexpected error on Finta's side. Retry with exponential backoff. |

    The `error.type` and `error.code` fields are stable identifiers safe to switch on programmatically. The `error.message` is human-readable and may change without notice.

    ### Overdue billing invoices

    A company with an unpaid billing invoice explicitly linked by Finta loses access when
    that invoice's due date ends in America/Los_Angeles, independently of its subscription
    or plan. Every API endpoint then returns `403 permission_error` with code
    `invoice_overdue`, before running the endpoint or changing data. Pay all overdue linked
    invoices at app.finta.com or contact support@finta.com; replacing the API key or buying
    another plan does not resolve this refusal. There is no payment-processing grace period:
    payment must have settled and Stripe must mark each overdue invoice paid. A linked invoice only
    counts while its corresponding Enterprise product is enabled.

    Unpaid status can be cached for up to 60 seconds. Access returns automatically within
    a minute after every overdue linked invoice is paid. Paid status is final. Companies with no
    linked invoices do not perform this payment-status check.
    Amounts in the human-readable message reflect the unsettled balance after partial
    payments. Whole-dollar amounts omit decimal places; non-zero cents remain exact.
    Only invoice status paid clears an overdue invoice.
    The lock is not latched: when an overdue invoice is paid and the only remaining
    unpaid invoice is not due yet, access resumes until that later invoice misses its cutoff.

    If payment status cannot be verified after the due-date cutoff, every endpoint can
    instead return `503 api_error` with code `invoice_status_unavailable` and
    `Retry-After: 60`. Retry after that interval, then use bounded exponential backoff if
    the service remains unavailable. This response never treats an unknown payment as paid.
    A Stripe outage before the cutoff does not refuse access for this reason.

    MCP connections remain connected, but every tool call is refused for these same
    reasons, including tools that normally do not consume the monthly allowance. A refused
    tool result has `isError: true`, uses the API's message, and is not counted against the
    monthly allowance. Its transport remains HTTP 200; a temporary payment-status failure
    includes `Retry-After: 60` on that response.

    ### Early access endpoints

    Some features are in early access: turned on per company while they roll out. A feature can be
    whole endpoints, such as invoices, or inputs on endpoints everyone uses, such as a department
    filter or a department to set or clear. For a company that doesn't have the feature yet, every
    request to its endpoints, and every request that sends one of its inputs, returns
    `403 Forbidden` with `type: permission_error` and `code: feature_disabled`, and changes nothing:

    ```json
    {
      "error": {
        "type": "permission_error",
        "code": "feature_disabled",
        "message": "This feature isn’t turned on for this company yet. Contact Finta to request access.",
        "doc_url": "https://www.finta.com/docs/api-reference/errors#feature_disabled"
      }
    }
    ```

    It is not retryable: contact Finta to request access for the company, then retry. Once
    the feature is on for the company, the same requests work normally.

    If the plan doesn't include departments, a request that names one gets
    `plan_upgrade_required` instead. Department reports always get `feature_disabled`.
servers:
  - url: https://app.finta.com/api/v1
    description: Production

security:
  - bearerAuth: []

tags:
  - name: Onboarding
    description: Create a Finta account through an agent and verify the user's email
  - name: Sheets
    description: |
      Create, read, edit and delete spreadsheet documents, worksheets, cell ranges and embedded charts.
      Existing Sheets access includes tag-filtered financial formulas, including Other, without
      separate Dashboard, Transactions or Accounting settings access. API calls still require
      API access: Manage; reads require Forecast: View and mutations require Forecast: Manage,
      with existing company, subscription and financial authorization checks.
      Existing web link sharing requires Forecast: Manage and the existing sharing entitlement;
      the sharing owner's current access and revocation checks remain authoritative. Guests
      can evaluate authorized saved formulas, not arbitrary financial queries or the full tag
      catalog. This adds no public sharing or tag-management endpoint.
  - name: Onboarding
    description: Answer the onboarding questionnaire, follow Finta's decision, and connect the company's banks until it's onboarded
  - name: Company
    description: Retrieve company metadata
  - name: Plans & billing
    description: The plans Finta offers, what the company pays, and how much of each plan limit it has used.
  - name: Team
    description: Retrieve company members and manage invitations
  - name: Categories
    description: List your chart of accounts categories and add Finta's standard ones
  - name: Rules
    description: Company automation rule definitions.
  - name: Departments
    description: List the departments available to the authenticated company
  - name: Tags
    description: >-
      Company-defined transaction labels managed in Settings that break categories down further, such as Vercel and Webshare under Infrastructure.
      Each transaction has at most one tag; untagged amounts appear as Other. Tags do not change the books, taxes or reports.
  - name: Integrations
    description: List the authenticated company's integrations and their connection status
  - name: Inbox
    description: What Finta needs from the company, from its Finta inbox, and how each item gets done
  - name: Aggregations
    description: Compute totals for one category and its descendants, as a single figure or grouped by merchant or month
  - name: Transactions
    description: |
      Transactions are what your bank shows you: a single line like "AWS charged you $499."
      Most API consumers want transactions. For accounting-level detail, see Journal Entries.

      Amounts are in cents (e.g. `amount_cents: 49900` = $499.00).
  - name: Journal Entries
    description: |
      Journal entries are the accounting records that transactions create. A single $499 AWS
      charge becomes two journal entries: debit Software $499, credit Cash $499 (double-entry
      bookkeeping). Use journal entries when you need accounting-level detail.

      Amounts are in cents (e.g. `amount_cents: 49900` = $499.00).
  - name: Invoices
    description: |
      Invoices the company sent, and the bank deposits that paid them. Recording an invoice books
      its revenue; Finta doesn't send invoices to customers.

      Amounts are in cents (e.g. `amount_cents: 49900` = $499.00).
  - name: Reconciliation
    description: |
      Reconciling an account's period confirms Finta's balance matches the statement's ending
      balance, and locks the period's transactions. Each account has one reconciliation per
      period (the calendar month, or the bank statement's dates for statement-dated accounts),
      whether or not it's reconciled yet.

      Amounts are in cents in the statement's terms: a credit card's balance owed is positive.
  - name: Parties
    description: Discover observed counterparties for category-scoped financial queries.
  - name: Forms
    description: Discover vendor compliance filing records and yearly progress.
  - name: Reports
    description: |
      Financial statements: income statement, balance sheet, and cash flow. Each report
      returns the complete statement for a single period. You cannot filter to one category
      or request multiple periods in one call.
  - name: Feedback
    description: Send questions, feedback and bug reports to Finta support
  - name: Tax Credits
    description: Credit amounts and claim progress by type, jurisdiction, and tax year

  - name: Taxes
    description: Discover income-tax and Delaware franchise-tax filing records and available PDFs.
  - name: Files
    description: Upload the PDFs that tax uploads attach to the company's tax records.

paths:
  /signups:
    post:
      operationId: createSignup
      summary: Create signup
      security: []
      tags: [Onboarding]
      description: |
        First time using Finta: call this endpoint with their email and `app` if known.
        Before signup, use what you can already reach (chat, local files, `git config user.email`,
        connected tools) to gather context, within the permissions they've given you; propose the
        email with its source; ask only if none is found. Gather incorporation year,
        entity type, state, what they sell, foreign subsidiaries (now or soon), banks, credit cards.
        Send one message with the email and prefilled answers, their sources, conflicts and missing
        questions (start with year/entity/state if nothing is known). Confirm together; ask if first
        time only if not already known. Already uses Finta: skip signup and sign in.
        Signup is always available. No API key, company or OAuth grant is needed.
        Existing accounts get the same 202 and no email.
        New accounts receive a verification link; pending accounts receive a replacement link
        that invalidates the previous one. Links expire after one hour. Active, inactive and deleted
        accounts receive no email. Every account state returns the identical 202 response and
        consumes the same email allowance, so neither responses nor 429s reveal account existence.
        If no account exists and the email has a pending team invitation, resend that invitation
        instead of creating an owner account. This uses the same signup allowance and 202 response.
        `verification_sent` is an instruction, not confirmation that an email was delivered.
        Every admitted request queues the same background job. After an allowance is consumed,
        account creation, resend and mailer failures are reported internally and still
        return the same 202 response for every account state; the allowance is not refunded.
        Success confirms neither account creation nor email delivery. Email validation
        and rate-limit checks retain their documented error responses.

        Follow `next_step.message`: the user clicks Activate in Finta's email in their own
        browser; never fetch the link. Then connect Finta's MCP (`mcp_url`) the way your app
        supports; they sign in with one Allow. If your app must restart to load it, tell them
        exactly how to restart and come back to this chat, and call no Finta tool until then.
        E.g. Codex CLI: they quit (Ctrl+C twice) and run
        `codex mcp add finta --url <mcp_url> && codex resume --last` (don't run it yourself);
        Claude Code: run `claude mcp add --transport http finta <mcp_url>` if you haven't,
        then they run /mcp and pick finta. Once connected, call `get_onboarding` and follow `next_step.message`.
        No email? Existing accounts get none; just connect and sign in.
        Activating an agent-issued link replaces any pending-account password with a new random one, including a password chosen
        during a prior web signup. Requesting or opening the link does not change that password.
        There is no status or verify endpoint.

        This endpoint bypasses company permissions, onboarding, monthly limits and request logging.
        It always uses the unauthenticated 120 requests/minute per-IP limit, even with a credential.
        Signup verification allows 3 attempts per recipient per 30 minutes and 5 per 24 hours.
        Ordinary caller IPs share 10 auth emails per 10 minutes and 100 per 24 hours across auth
        email purposes. For signup verification emails only, IPs in Anthropic's or OpenAI's
        published connector egress ranges use one shared bucket per provider instead:
        100 attempts per 10 minutes and 1,000 per 24 hours across all matching IPs and recipients.
        Each provider has its own bucket. Recipient limits are unchanged, and other auth email
        purposes keep the ordinary per-IP limits, including on provider IPs. REST, MCP and web
        signup share these email allowances; User-Agent branding does not change them.
        The ranges are pinned in code from the published lists fetched on October 29, 2026,
        not fetched at runtime: [Anthropic outbound IPs](https://platform.claude.com/docs/en/api/ip-addresses)
        and [OpenAI connector IPs](https://openai.com/chatgpt-connectors.json).
        A 429 includes Retry-After in seconds; wait that long before retrying.
        Repeating a request renews a pending link and consumes an attempt.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SignupRequest"
            example:
              email: founder@acme.com
              app: codex
      responses:
        "202":
          description: Identical verification instructions for every account state; does not disclose delivery or account existence.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Signup"
              example:
                status: verification_sent
                email: founder@acme.com
                expires_in_seconds: 3600
                mcp_url: https://mcp.finta.com/mcp
                next_step:
                  action: open_verification_link
                  message: >-
                    Tell the user: "Click Activate in the email Finta just sent you at founder@acme.com."
                    They click it in their own browser; never fetch the link. Then connect Finta's MCP (https://mcp.finta.com/mcp) the way your app supports; they sign in with one Allow.
                    If your app must restart to load it, tell them exactly how to restart and come back to this chat, and call no Finta tool until then.
                    E.g. Codex CLI: they quit (Ctrl+C twice) and run `codex mcp add finta --url https://mcp.finta.com/mcp && codex resume --last` (don't run it yourself);
                    Claude Code: run `claude mcp add --transport http finta https://mcp.finta.com/mcp` if you haven't, then they run /mcp and pick finta.
                    Once connected, call get_onboarding and follow next_step.message. No email? Existing accounts get none; just connect and sign in.
        "400":
          description: The required email parameter is missing or blank (parameter_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  type: invalid_request_error
                  code: parameter_missing
                  message: "Missing required param: email."
                  param: email
                  doc_url: https://www.finta.com/docs/api-reference/errors#parameter_missing
        "422":
          description: Email does not pass Finta's email validation (invalid_email).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  type: invalid_request_error
                  code: invalid_email
                  message: Enter a valid email address.
                  param: email
                  doc_url: https://www.finta.com/docs/api-reference/errors#invalid_email
        "429":
          description: >-
            Per-IP request limit or signup verification email allowance exceeded. Every account
            state consumes the same allowance. Recipient limits are 3 attempts per 30 minutes
            and 5 per 24 hours. Ordinary IP limits are 10 auth emails per 10 minutes and 100 per
            24 hours across auth email purposes. For signup emails only, matching Anthropic or
            OpenAI connector IPs use one shared bucket per provider instead, at 100 attempts per
            10 minutes and 1,000 per 24 hours. Recipient limits and other email purposes are unchanged.
          headers:
            Retry-After:
              description: Seconds to wait before retrying, whether limited by request volume or an email allowance.
              schema:
                type: string
                pattern: '^[0-9]+$'
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  type: rate_limit_error
                  code: rate_limit_exceeded
                  message: Too many requests. Please retry after the Retry-After period.
                  doc_url: https://www.finta.com/docs/api-reference/errors#rate_limit_exceeded

  "/sheets":
    get:
      operationId: listSheets
      summary: List sheets
      tags:
      - Sheets
      description: 'Lists documents accessible to the authenticated company and user.
        Requires Forecast: View. Does not return cell data. Results are ordered by creation
        time descending, then ID descending.'
      parameters:
      - name: query
        in: query
        schema:
          type: string
          minLength: 1
          maxLength: 256
        description: Case-insensitive document-name search.
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 500
          default: 100
      - name: starting_after
        in: query
        schema:
          type: string
        description: Opaque next_cursor from the prior page; preserve all filters.
      responses:
        '200':
          description: A page of sheet documents.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SheetList"
        '400':
          description: Invalid input or a supported payload/grid limit was exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: The sheet or list cursor is unavailable in the current company.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    post:
      operationId: createSheet
      summary: Create sheet
      tags:
      - Sheets
      description: |
        Requires API access: Manage, Forecast: Manage and the existing sheet entitlement.
        Creates blank, template or custom worksheets atomically in the authenticated company.
        Blank tabs start at the UI default of 100 rows by 26 columns; templates retain their
        dimensions. Values, formats and chart anchors append blank grid space within company
        limits (normally 500 rows and 52 columns), preserving existing cells and formula references.
        The limits of 100 worksheets, 50 charts per worksheet and a 1 MiB normalized snapshot apply.
        One custom request expands to at most 500 native operations across all tabs and ranges:
        each non-null cell write and each formatted cell counts, as do tab setup, charts, grid
        growth and any formula-preservation writes. Null skips its cell; sparse rectangles may
        span the company grid even when their area exceeds 500 cells. Template seeding is outside
        this budget. Splitting ranges within the request does not reset the budget.
        Validation failure saves nothing. Optional return_ranges evaluates selected outputs and
        dependencies after saving; a calculation error does not undo a successful save.
        Creation is not idempotent. After an ambiguous network result, check whether the document
        exists before repeating the request.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SheetCreateRequest"
      responses:
        '201':
          description: Saved document with worksheet/chart IDs and optional calculated
            outputs.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SheetMutationResponse"
        '400':
          description: Invalid input or a supported payload/grid limit was exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: Sheet, worksheet, chart or cursor is unavailable in this company.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '409':
          description: Revision conflict. Reread the document and use error.current_revision;
            no operations in the conflicting batch were saved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
  "/sheets/{id}":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: string
      description: Document ID returned by list. Worksheet IDs are distinct.
    get:
      operationId: retrieveSheet
      summary: Retrieve sheet
      tags:
      - Sheets
      description: 'Requires Forecast: View. Without ranges, returns document and worksheet
        metadata. Grid reads use qualified A1 addresses; quote worksheet names and escape
        embedded apostrophes by doubling them. Evaluation is read-only and uses the
        existing formula engine and company financial data. Numeric spreadsheet cells
        preserve worksheet units. Metadata-only responses omit ranges and calculation.
        Ranges must be bounded, ordered and inside the existing grid. Unqualified ranges
        require worksheet_id unless the document has exactly one worksheet.'
      parameters:
      - name: ranges[]
        in: query
        style: form
        explode: true
        schema:
          type: array
          items:
            type: string
          minItems: 1
          maxItems: 100
        description: Use ranges[]=Plan!A1:B3 and repeat ranges[] for multiple selections.
          A single ranges string is also accepted. At most 100 ranges and 10,000 selected
          cells in total.
      - name: worksheet_id
        in: query
        schema:
          type: string
        description: Optional stable worksheet selector for unqualified ranges.
      - name: view
        in: query
        schema:
          type: string
          enum:
          - values
          - formulas
          - both
          default: values
        description: values returns calculated values; formulas returns saved formulas
          with null for non-formula cells and skips calculation; both returns both matrices.
      - name: include
        in: query
        schema:
          type: string
        description: Comma-separated charts,formats. Both are opt-in. charts returns
          embedded definitions rather than images; formats requires selected ranges.
      responses:
        '200':
          description: Document metadata and requested range content.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Sheet"
        '400':
          description: Invalid input or a supported payload/grid limit was exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: The sheet or list cursor is unavailable in the current company.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    patch:
      operationId: updateSheet
      summary: Update sheet
      tags:
      - Sheets
      description: 'Requires Forecast: Manage. Ordered atomic operations run against
        base_revision. Structural edits use the existing formula-reference adjustment
        behavior. Charts are embedded in worksheets, not returned as images. Copy/fill
        reuse existing relative-reference and sequence rules. At most 500 expanded native
        operations; no silent splitting. Calculation follows saving and its errors do
        not undo an otherwise successful mutation.'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SheetUpdateRequest"
      responses:
        '200':
          description: Saved revision, operation results and optional calculated outputs.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SheetMutationResponse"
        '400':
          description: Invalid input or a supported payload/grid limit was exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: Sheet is unavailable in this company. Invalid worksheet or chart selectors return 400.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '409':
          description: Revision conflict. Reread the document and use error.current_revision;
            no operations in the conflicting batch were saved. A revision_history_mismatch
            requires Finta support to reconcile legacy history before API edits; do not retry.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    delete:
      operationId: deleteSheet
      summary: Delete sheet
      tags:
      - Sheets
      description: 'Requires Forecast: Manage. Permanently deletes the document and
        its tabs using the current revision. Send base_revision in the query string
        and no HTTP request body. To delete only a worksheet, use delete_worksheet in
        PATCH; at least one worksheet must remain.'
      parameters:
      - name: base_revision
        in: query
        schema:
          type: integer
          minimum: 0
        required: true
        description: Revision observed by the caller.
      responses:
        '204':
          description: Document deleted. No response body.
        '400':
          description: Invalid input or a supported payload/grid limit was exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: The sheet or list cursor is unavailable in the current company.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '409':
          description: Revision conflict; no deletion occurred. Reread and reconsider
            deleting the changed document before retrying with its current revision.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
  "/sheets/{id}/cells":
    parameters:
    - name: id
      in: path
      required: true
      schema:
        type: string
      description: Document ID returned by list. Worksheet IDs are distinct.
    get:
      operationId: listCells
      summary: List cells
      tags:
      - Sheets
      description: 'Requires Forecast: View. Searches authored cell contents and formula
        text, not calculated financial results. Matches identify the worksheet and cell
        address. Cursor paging is tied to the document revision; reread/restart if the
        document changes. Matching is case-insensitive. Results contain authored string
        inputs in worksheet, row and column order, skipping blank cells. Cursors bind
        the search filters and revision; a wrong-search cursor returns 400, and a
        changed revision returns 409. Unknown query parameters return 400.'
      parameters:
      - name: query
        in: query
        schema:
          type: string
          minLength: 1
          maxLength: 1000
        required: true
      - name: match
        in: query
        schema:
          type: string
          enum:
          - contains
          - exact
          default: contains
        description: contains matches a substring; exact matches the whole authored input.
          Both are case-insensitive and do not search calculated results.
      - name: worksheet_id
        in: query
        schema:
          type: string
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 500
          default: 100
      - name: starting_after
        in: query
        schema:
          type: string
        description: Opaque next_cursor from the prior page; preserve all filters.
      responses:
        '200':
          description: A page of matching authored cells.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SheetCellMatchList"
        '400':
          description: Invalid input or a supported payload/grid limit was exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: The sheet or list cursor is unavailable in the current company.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '409':
          description: The document changed during pagination. Inspect the updated
            document and restart without starting_after; error.current_revision is current.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
  /taxes:
    get:
      operationId: listTaxes
      summary: List taxes
      tags: [Taxes]
      description: |
        Lists existing income-tax and Delaware franchise-tax filing records for the authenticated
        company. All filters are optional: call without filters to discover available records.
        Requires API access: Manage and Taxes: View. Staff-only questionnaires and other companies'
        records are excluded. A missing record does not establish that no tax obligation exists.

        summaries reports every tax year and type, including years with no filing record, so an
        empty data array still says which years are not_started and whether they are overdue.
        Without year, summaries cover the years shown on the Finta Taxes page plus any year that
        has a record. An explicit year has one summary per type unless it is before the
        incorporation year or after the current calendar year. Only year and type narrow
        summaries; jurisdiction, status and pagination do not, and summaries repeat on every page.

        One income record covers federal and its recorded state jurisdictions, with one shared
        filing workflow. Filtering by US-CA returns the income record that covers California;
        it does not manufacture an independent California filing status or tax amount.
        Jurisdictions are normalized to US / US-CA style codes. Unrecognized stored state names
        remain visible in unmapped_jurisdictions, rather than being assigned invented codes.

        Records are ordered by creation time descending, then public ID descending. Continue
        with next_cursor as starting_after, retaining the same filters. The cursor must belong
        to the same filtered company collection. Defaults: limit 100, maximum 500.
        This GET never generates filing data, assigns IDs, or enqueues tax work.
        PDFs use signed download URLs valid for five minutes; retrieve the record again to refresh.
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum: [income, franchise]
        - name: jurisdiction
          in: query
          description: US for federal, or a supported USPS state code prefixed with US-, such as US-CA. Matches any covered jurisdiction.
          schema:
            type: string
          example: US-CA
        - name: year
          in: query
          description: The recorded tax year, not the calendar year in which the filing was completed.
          schema:
            type: integer
            minimum: 1000
            maximum: 9999
        - name: status
          in: query
          schema:
            type: string
            enum: [onboarding, preparing, review_required, filing, filed]
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: starting_after
          in: query
          description: Public tax ID from the previous page's next_cursor; keep all filters unchanged.
          schema:
            type: string
      responses:
        '200':
          description: A page of existing tax records plus yearly summaries. An empty data array means no records match these filters; summaries still report each year's status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaxList'
        '400':
          description: Invalid filter or pagination input; invalid_request with param naming the field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Cursor is absent from this filtered company collection; resource_missing.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Public IDs have not been backfilled; api_error/taxes_not_ready. Retry with bounded backoff; persistent failures require operator attention. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /taxes/{id}:
    get:
      operationId: retrieveTax
      summary: Retrieve tax
      tags: [Taxes]
      description: |
        Returns the same tax representation as List taxes, including refreshed PDF download URLs.
        Requires API access: Manage and Taxes: View. Only public tax IDs are accepted;
        numeric database IDs and malformed IDs return 400. Unknown, staff-only, and other-company
        IDs all return the same resource_missing 404. Filing status is Finta's recorded workflow
        status, not confirmation of payment or acknowledgment from a tax authority.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: '^tax_(income|franchise)_[A-Za-z0-9]+$'
      responses:
        '200':
          description: The requested tax record.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tax'
        '400':
          description: Invalid public ID; invalid_request with param id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Tax record unavailable; resource_missing with param id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /taxes/income_uploads:
    post:
      operationId: createIncomeUpload
      summary: Create income upload
      tags: [Taxes]
      description: |
        Records an income tax return the company already filed outside Finta, with its PDF. This
        is the Taxes page's "already filed" upload: it runs the same code, with the same results.
        Requires API access: Manage and Taxes: Manage.

        Upload the PDF first with Create file or Create file upload, then pass its `file_id`. The
        PDF is checked before anything is recorded: a Delaware franchise tax report (a PDF whose
        text contains "Annual Franchise Tax Report") is refused with `wrong_document_type`; upload
        it with Create franchise upload instead.

        On success, a new income record is created for the year: it is marked filed now
        (`filed_at` is the upload time, not the date the return was filed), `filed_elsewhere` is
        true, and the PDF becomes its `filed_return` document. Its jurisdictions are US plus
        `states_filed`. A missing Delaware record is created only if Finta's status check already
        confirms it filed. Otherwise, its filing status remains to be confirmed on the Taxes page. Existing Delaware
        records and other tax years are unchanged; attach the Delaware report with Create franchise upload. For a company with no records for that year,
        upload the return first, then the Delaware report.

        A previously uploaded external Delaware filing with no questionnaire is linked to this
        year's questionnaire. Existing associations, documents, filing dates and Finta workflows
        are preserved.

        Only last year's return or the year before's can be uploaded, as on the Taxes page. Any
        other `tax_year` is refused rather than changed. Uploads must also be available on that year's
        Taxes page: it must be the year requiring onboarding, unlocked and within the company's
        incorporation years, with no storage restriction or existing income filing/submitted Finta
        workflow. Bookkeeping blockers do not prevent an income upload. Existing documents are
        never replaced. Ineligible uploads return `409 tax_upload_not_allowed` without changes.
        A completed income return satisfies that year's filing requirement even if staff select
        another questionnaire; explicit prior-year confirmation requests still keep later years locked.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/IncomeUploadCreate'
            example:
              tax_year: 2025
              tax_firm: Fondo
              states_filed: [CA]
              file_id: file_8dJ2kq9XbT4mNa
      responses:
        '200':
          description: The year's income tax record, marked filed, with the PDF as its filed_return document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tax'
        '400':
          description: |
            Invalid JSON, an unknown or missing field, a `tax_year` other than last year or the year before,
            an unknown state code (`invalid_request`, `parameter_missing`); a file with no content or that is not a PDF
            (`invalid_file`); or a Delaware franchise tax report (`wrong_document_type`). `param` names the field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The file does not exist, belongs to another company, or expired unattached; resource_missing with param file_id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The file is already attached (file_already_attached), or the Taxes page does not allow the upload, a document already exists, or a Finta workflow is protected (tax_upload_not_allowed). Nothing changes; do not retry with a fresh file to replace an existing document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: The return could not be recorded (tax_upload_failed); nothing was changed and the file is still unattached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /taxes/franchise_uploads:
    post:
      operationId: createFranchiseUpload
      summary: Create franchise upload
      tags: [Taxes]
      description: |
        Records a Delaware franchise tax and annual report the company already filed outside Finta,
        with its PDF. This is the Taxes page's "I've already filed" Delaware upload: it runs the same
        code, with the same results. Requires API access: Manage and Taxes: Manage.

        Upload the PDF first with Create file or Create file upload, then pass its `file_id`. The
        year's Delaware franchise record (the one on that year's Taxes page, created if missing) is
        marked filed, if it is not already, with `filed_elsewhere` true. The PDF becomes its
        `filing_confirmation` document, only when no confirmation exists, and is renamed
        `[<tax_year>] <company name> Delaware Confirmation.pdf`.

        `tax_year` is the year the report covers (2025 for the report filed in early 2026). Any
        four-digit year must be available and unlocked on the Taxes page; future years and years
        before incorporation are unavailable. The page's Delaware blockers apply when declaring
        a report externally filed. A known externally filed report may receive its missing PDF
        without resetting its filing date, even with bookkeeping blockers. Existing confirmations,
        archived filings, and Finta onboarding, preparation, authorized payment and filing workflows
        cannot be changed. Ineligible uploads return `409 tax_upload_not_allowed`. Uploading a
        report changes only that year's Delaware record.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FranchiseUploadCreate'
            example:
              tax_year: 2025
              file_id: file_Q7rLm2ZcV9pWx3
      responses:
        '200':
          description: The year's Delaware franchise tax record, marked filed, with the PDF as its filing_confirmation document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Tax'
        '400':
          description: Invalid JSON, an unknown, missing or invalid field (`invalid_request`, `parameter_missing`), or a file with no content or that is not a PDF (`invalid_file`). `param` names the field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: The file does not exist, belongs to another company, or expired unattached; resource_missing with param file_id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The file is already attached (file_already_attached), or the Taxes page does not allow the upload, a document already exists, or a Finta workflow is protected (tax_upload_not_allowed). Nothing changes; do not retry with a fresh file to replace an existing document.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: The report could not be recorded (tax_upload_failed); nothing was changed and the file is still unattached.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /files:
    post:
      operationId: createFile
      summary: Create file
      tags: [Files]
      description: |
        Uploads a PDF for Create income upload or Create franchise upload to attach, and returns its
        `file_` ID. Send it as `multipart/form-data` in a field named `file`. Only PDFs are accepted;
        the content is checked, not the file name or declared type. Requires API access: Manage and
        Taxes: Manage.

        A file belongs to the company of the key that uploaded it, attaches to one tax record once,
        and is deleted if nothing attaches it within 24 hours (`expires_at`). There is no size limit
        beyond the request limits of the network path.

        To upload without multipart (an AI agent connected through the MCP, for example), use
        Create file upload.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The PDF. Its file name is kept and shown on the Taxes page.
      responses:
        '201':
          description: The file was stored and can be attached until expires_at.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/File'
        '400':
          description: The body is not multipart/form-data, `file` is missing or not a file part (`invalid_request`, `parameter_missing`), or the file is not a PDF (`invalid_file`). `param` is file.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /file_uploads:
    post:
      operationId: createFileUpload
      summary: Create file upload
      tags: [Files]
      description: |
        Starts a direct upload of a PDF: returns a URL to PUT the PDF to, straight to Finta's file
        storage, and the `file_id` the PDF becomes. Use it where a multipart request isn't
        practical, such as an AI agent connected through the MCP that runs curl from its terminal.
        Requires API access: Manage and Taxes: Manage.

        Send the file's `filename`, its `size` in bytes, and its MD5 `checksum` in base64. Then PUT
        the file's bytes to `url` with every header in `headers`, before `expires_at` (10 minutes):

        ```
        curl --fail -X PUT --upload-file return.pdf \
          -H "Content-Type: application/pdf" \
          -H "Content-MD5: <checksum>" \
          -H "Content-Disposition: <Content-Disposition from headers>" \
          "<url>"
        ```

        Storage accepts only bytes that match `size` and `checksum`, so a URL can't be used to store a
        different file, and each call returns a new URL and file. The PDF check happens when a tax
        upload attaches the file: a file with no content yet, or one that is not a PDF, is refused
        then with `invalid_file`. Like a file from Create file, it attaches once and is deleted if
        nothing attaches it within 24 hours of this call.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/FileUploadCreate'
            example:
              filename: 2025 Form 1120.pdf
              size: 482113
              checksum: r8Hq2wJ5bNf0uYk3cX7d1A==
      responses:
        '201':
          description: The upload URL and the file it becomes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FileUpload'
        '400':
          description: Invalid JSON, or an unknown, missing or invalid field (`invalid_request`, `parameter_missing`). `param` names the field.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /company:
    get:
      operationId: retrieveCompany
      summary: Retrieve company
      description: Returns the authenticated company's metadata including legal details and address.
      tags:
        - Company
      responses:
        "200":
          description: The company object
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Company"
              example:
                id: comp_a1b2c3d4e5f6g7
                object: company
                name: Acme Corp
                legal_name: Acme Corp Inc.
                entity_type: c_corp
                federal_ein_last4: "6789"
                delaware_file_number: "1234567"
                incorporation:
                  date: "2022-10-21"
                  state: DE
                legal_address:
                  line_1: 548 Market Street
                  line_2: PMB 39381
                  city: San Francisco
                  state: CA
                  postal_code: "94104"
                  country: US
                created: 1688053841
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /onboarding:
    get:
      operationId: retrieveOnboarding
      summary: Retrieve onboarding
      description: |
        Returns where the company is in onboarding and what to do next. A new company answers a short
        questionnaire, Finta decides whether it can onboard the company, the company connects the
        banks and cards it selected, and Finta imports their transactions and sets up the books. Until that's complete,
        every other endpoint except `GET /integrations`, `POST /team/invitations` and `POST /feedback`
        returns `403` `onboarding_incomplete`. `POST /integrations/{key}/connect` and
        `GET /integrations/{key}/connect` work once Finta has approved the company (`status`
        `connect_banks` or `syncing`), and return `onboarding_incomplete` before then.

        Read onboarding when setting up Finta or after an endpoint returns `onboarding_incomplete`,
        then follow the returned `next_step`. Do not poll or read it once setup is complete.
        Re-check only after the user reports an action (such as approving a bank or choosing See dashboard),
        asks about status or comes back later. `PATCH /onboarding` returns the full onboarding object,
        so do not read it again immediately after an update.

        For a new setup, open with: "Welcome to Finta! A few company questions, then connect your banks and we'll set up your books."
        Follow `next_step.message` for how to ask and `next_step.action` for what to do:

        | Action | What to do |
        |--------|------------|
        | `answer_questions` | Follow `next_step.message`: save what you can already answer from sources you may use, without asking or guessing, and ask only what's still missing. For questions with options, use the app's multiple-choice question tool if available; otherwise ask in plain text without numbered lists, showing options only if asked. Map answers to options and save answered keys with `PATCH /onboarding`. |
        | `confirm_and_submit` | Recap every answer, saying where any found answer came from, then stop and wait for the user's reply. Send `submit: true` only in a later turn after the user explicitly says yes to that recap (a yes picked in the app's multiple-choice question tool counts); earlier confirmations do not count, and never recap and submit in the same turn. |
        | `book_call` | Share `next_step.url` so the user can book a call. |
        | `contact_support` | Explain `next_step.message` (ineligibility or a failed import with an active connection) and share `next_step.email`. |
        | `connect_banks` | Follow `next_step.message`: use `POST /integrations/{key}/connect` for OAuth and Plaid banks, including Chase and SVB, and let the user approve on the returned provider or Plaid Hosted Link page. Poll the connect request afterwards; Plaid reconnects still require the web app. |
        | `start_import` | Every selected bank is connected, but importing has not started. Send `PATCH /onboarding` with `answers: {}`, then follow the returned `next_step`. |
        | `wait_for_sync` | Do not wait or poll. Say importing transactions and setting up the books can take a while; offer the ready email if not discussed and save `notify_when_synced` with `PATCH /onboarding`. Re-check only if the user asks or comes back. |
        | `finish_in_web` | The import started in the web app and has finished. Ask the user to switch to the API company in Finta first, then open `next_step.url` and choose See dashboard there; check onboarding again afterwards. |
        | `none` | Onboarding is complete and every endpoint is available. |

        Onboarding needs the Company: Manage and Integrations: Manage permissions, as it does in the
        Finta web app; with less, this returns `403` `insufficient_permission`. This endpoint is free:
        it doesn't count toward the monthly API limit and keeps answering after that limit is reached.
      tags:
        - Onboarding
      responses:
        "200":
          description: The company's onboarding
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Onboarding"
              example:
                object: onboarding
                status: connect_banks
                answers:
                  incorporation_year: 2024
                  legal_entity_type: c_corp
                  legal_entity_state: DE
                  industry: software
                  international_subsidiaries: false
                  banks:
                    - mercury
                    - chase
                  other_bank: null
                  credit_cards:
                    - ramp
                  other_credit_card: null
                  accounting_software: null
                  files_taxes_with: null
                  goals: null
                  subsidiary_countries: []
                questions: []
                decision:
                  outcome: approved
                  reason: null
                  decided: 1792540579
                integrations:
                  - key: mercury
                    name: Mercury
                    status: connected
                  - key: ramp
                    name: Ramp
                    status: connected
                  - key: chase
                    name: Chase
                    status: not_connected
                sync: null
                notify_when_synced: false
                next_step:
                  action: connect_banks
                  message: "Help them connect Chase with POST /integrations/{key}/connect, one call each. Once connected, Finta will import transactions and set up their books."
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
    patch:
      operationId: updateOnboarding
      summary: Update onboarding
      description: |
        Saves answers to the onboarding questions, asks Finta to decide, and sets whether Finta
        emails the user when their books are ready. Returns the same object as
        `GET /onboarding`.

        - Only the answers you send change. Use what you can already reach (chat, local files,
          connected tools), within the permissions they've given you, to prefill sourced answers;
          name each source and confirm them together. Ask only
          what's missing, two or three related questions at a time; save quietly. Never guess
          without a source; flag conflicts and ask.
        - One invalid answer, or an unknown field, rejects the whole request and saves nothing
          (`400` `invalid_request`, with `param` naming it).
        - `submit: true` asks Finta to decide, which it does only once no required question is
          unanswered. If any is, the valid answers sent with the request are still saved and the
          response is `400` `answers_incomplete`, with the missing answer keys in `missing`.
        - Finta's decision is final. Afterwards the eligibility answers can't change
          (`409` `onboarding_locked`); while Finta needs a call, the call follow-ups
          (`incorporation_year`, `accounting_software`, `files_taxes_with`, `goals` and
          `subsidiary_countries`) still can. Sending an answer that matches what's saved isn't a
          change, so repeating a request that already went through, submit included, returns
          `200` with the current state and changes nothing.
        - `notify_when_synced: true` is the web app's "Email me when it's ready": Finta emails the
          user who sent it when the first sync finishes, or right away if it already has.
          `false` withdraws it. It can change until onboarding is complete, once at least one
          question is answered. Offer it at the import wait step once banks are connected, not
          during the questionnaire; honor a preference the user brings up at any time.
        - Once the company is onboarded, every request returns `409` `onboarding_complete`.

        Needs Company: Manage and Integrations: Manage. This endpoint is free: it doesn't count
        toward the monthly API limit and keeps answering after that limit is reached.
      tags:
        - Onboarding
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OnboardingUpdate"
            example:
              answers:
                incorporation_year: 2024
                legal_entity_type: c_corp
                legal_entity_state: DE
                industry: software
                international_subsidiaries: false
                banks:
                  - mercury
                  - chase
                credit_cards:
                  - ramp
              submit: true
              notify_when_synced: true
      responses:
        "200":
          description: The company's onboarding after the change
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Onboarding"
        "400":
          description: >-
            An answer or field was invalid and nothing was saved (`invalid_request`), or `submit`
            was sent while required questions are unanswered (`answers_incomplete`), in which case
            the valid answers sent were saved.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalid_request:
                  summary: An invalid answer
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: "legal_entity_type must be one of: c_corp, pbc, llc, s_corp, other."
                      doc_url: https://www.finta.com/docs/api-reference/errors#invalid_request
                      param: legal_entity_type
                answers_incomplete:
                  summary: Submitted with unanswered questions
                  value:
                    error:
                      type: invalid_request_error
                      code: answers_incomplete
                      message: Answer industry, international_subsidiaries, and banks before submitting. Any answers sent with this request were saved.
                      doc_url: https://www.finta.com/docs/api-reference/errors#answers_incomplete
                      missing:
                        - industry
                        - international_subsidiaries
                        - banks
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          description: >-
            An answer can't change after Finta's decision (`onboarding_locked`, with `param`), or
            the company is already onboarded (`onboarding_complete`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                onboarding_locked:
                  summary: Changing an answer after Finta decided
                  value:
                    error:
                      type: invalid_request_error
                      code: onboarding_locked
                      message: legal_entity_type can’t change after Finta’s eligibility decision.
                      doc_url: https://www.finta.com/docs/api-reference/errors#onboarding_locked
                      param: legal_entity_type
                onboarding_complete:
                  summary: The company is already onboarded
                  value:
                    error:
                      type: invalid_request_error
                      code: onboarding_complete
                      message: Onboarding is already complete, so its answers can’t change.
                      doc_url: https://www.finta.com/docs/api-reference/errors#onboarding_complete
        "429":
          $ref: "#/components/responses/RateLimited"

  /plans:
    get:
      operationId: listPlans
      summary: List plans
      description: |
        Returns the plans finta.com advertises, cheapest first, at current list prices. The list is
        the same for every company, so it needs only API access. It doesn't count toward the monthly
        API limit and keeps answering after that limit is reached. Enterprise is priced and limited by
        contract, so its price and limits are null; its description links to book a call.
      tags:
        - Plans & billing
      responses:
        "200":
          description: A list of plans
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    enum:
                      - /api/v1/plans
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Plan"
              example:
                object: list
                url: /api/v1/plans
                data:
                  - id: startup
                    object: plan
                    name: Startup
                    description: For funded startups needing automation, investor updates, or financial visibility.
                    price_cents: 9900
                    currency: USD
                    interval: month
                    limits:
                      categorizations_per_month: 3000
                      transactions: 100000
                      api_calls_per_month: 300
                      mcp_calls_per_month: 300
                      ai_credits_per_month: 30
                    features:
                      - Everything in Formation
                      - 3,000 categorizations / mo.
                      - 100,000 transactions
                      - Slack support
                      - International subsidiaries
                      - Slack notifications
                      - API
                  - id: enterprise
                    object: plan
                    name: Enterprise
                    description: For companies with custom requirements. Book a call at https://cal.com/finta/intro.
                    price_cents: null
                    currency: USD
                    interval: null
                    limits:
                      categorizations_per_month: null
                      transactions: null
                      api_calls_per_month: null
                      mcp_calls_per_month: null
                      ai_credits_per_month: null
                    features:
                      - Everything in Growth
                      - Custom usage limits
                      - Custom onboarding and migration
                      - Custom integrations
                      - Dedicated account manager
                      - Guaranteed response times
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /team/invitations:
    post:
      operationId: createTeamInvitation
      summary: Create invitation
      tags: [Team]
      description: |
        Creates an invitation in the API key's company as the key owner. Requires API access
        Manage and the Admin role in the company. Team & access Manage alone does not
        allow invitations, even when the requested access is within the caller's permissions.
        A non-Admin caller receives 403 insufficient_permission; upgrading the plan does not
        grant invitation access. The same Admin requirement applies to exact replays.
        Requires JSON with nonblank first_name, last_name, email, and access_template.
        Unknown fields are rejected. Query parameters do not supply invitation fields.

        Choose admin or read_only without permissions, or custom with every permission area
        and a supported level. Extra permission areas are rejected. API access supports only
        none or manage. Custom permissions matching a preset are labeled as that preset in responses.
        A permissions field is forbidden with a named preset even when its value is null.

        Read-only and non-Admin permission sets require an active or trialing Growth subscription
        or the internal Enterprise entitlement.
        Other plans may invite Admin only, subject to the caller's grant permissions. When RBAC
        is disabled, only access_template admin is supported; other selections are rejected,
        never silently promoted. Existing API subscription and usage checks also apply.
        The plan rule compares actual permissions: custom matching Admin satisfies the
        plan rule when RBAC is enabled. Growth and Enterprise do not bypass the caller's grant limits.
        Once feature availability is validated, creation preserves the submitted permissions;
        another feature-flag evaluation cannot silently replace them with Admin.

        Returns 201 with changed=false for a new record, or 200 for an exact replay of a pending invitation with
        the same inviter, first and last names, case-insensitive email, and permissions.
        Same email alone is not a match. A replay may renew an expired invitation or retry pending
        delivery; changed is true when that work is requested, and false for an unchanged replay.
        Plan and permission checks still apply to replays. Success does not guarantee email arrival.
        No separate idempotency key is needed. Use the same body to retry an uncertain result.

        The invitation is saved state with the same fields as GET /team, including the opaque
        version revision. Treat version as opaque; resend uses the current revision internally
        and does not accept it as input. It is not an acceptance credential. Public IDs may be null.
        A null ID means the public identifier is unavailable, not that the record is missing;
        a later backfill may populate it. Treat unknown future enum values as unsupported,
        never as additional access. Allow for new response fields when updating clients.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTeamInvitation'
            example:
              first_name: Sam
              last_name: Rivera
              email: sam@example.com
              access_template: read_only
      responses:
        '201':
          description: A new invitation was saved; changed is false.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitationOperation'
        '200':
          description: An existing matching invitation was returned, possibly renewed or retried.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitationOperation'
        '400':
          description: Missing fields (parameter_missing), malformed JSON, unknown fields, or invalid template/permissions selection (invalid_request).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Existing API access checks failed, permissions exceed the caller's access (insufficient_permission), Growth is required (plan_upgrade_required), or RBAC is disabled for the requested selection (feature_disabled).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: The team or invitation is unavailable to this caller (resource_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Invitation validation failed (invitation_invalid); correct the indicated field. A conflicting pending invitation or an existing teammate is not updated by this endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: The operation failed (invitation_create_failed). Retry with the same body and exponential backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /team/invitations/{id}:
    delete:
      operationId: revokeTeamInvitation
      summary: Revoke invitation
      tags: [Team]
      description: |
        Revokes a pending invitation through the existing company-scoped service. Requires
        API access Manage and Team & access Manage, using the API key's person and company.
        Existing account, subscription, staging and usage checks apply; no additional Growth
        or RBAC gate. Target-management authorization remains in effect.

        Supply only a public ID matching ^invite_[A-Za-z0-9]+$. Numeric IDs, UUIDs and
        acceptance tokens return invalid_request. Send no body or query parameters, including
        version, empty JSON objects or company overrides. No Content-Type is required.
        Unexpected input returns 400 invalid_request.

        Returns 204 with no response body, including repeated deletion and already-absent IDs
        after authorization. Foreign-company IDs behave as absent, return the same empty 204,
        and leave foreign records untouched. No global lookup exposes existence. Accepted
        invitations return 404 resource_missing; this operation never removes teammates or
        mutates memberships. Both expired and unexpired pending invitations can be revoked.
        A target above the caller's access returns insufficient_permission.

        Retry the same DELETE after a timeout or invitation_revoke_failed using bounded
        backoff; absence succeeds after authorization on every call. No version or
        Idempotency-Key is required. Fix 400 input, 401 credentials, or 403 access before
        retrying; do not retry 404 to remove an accepted member. For rate_limit_exceeded,
        wait Retry-After seconds; monthly_limit_exceeded has no Retry-After and requires
        waiting for the stated monthly reset or changing the applicable limit.
        Server operation failures use invitation_revoke_failed with a safe message.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: '^invite_[A-Za-z0-9]+$'
      responses:
        '204':
          description: Revoked or already absent in the API key's company after authorization. Empty response body.
        '400':
          description: Invalid public ID or unexpected body/query parameters (invalid_request).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: API/team access or existing account checks failed, or target access exceeds the caller.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Team or invitation unavailable (resource_missing), including accepted invitations.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Invitation could not be revoked (invitation_revoke_failed). Retry the same DELETE with bounded backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    patch:
      operationId: updateTeamInvitation
      summary: Update invitation
      tags: [Team]
      description: |
        Updates only the permissions of a pending invitation in the API key's company.
        Requires API access Manage, Team & access Manage, RBAC enabled, and an active or
        trialing Growth subscription or the internal Enterprise entitlement for every edit,
        including Admin and unchanged requests.
        The person must be allowed both to manage the invitation's existing access and to
        grant the requested access. Accepted invitations and unavailable or foreign-company
        invitations return resource_missing without exposing their data.

        Only access_template and custom permissions are accepted in the JSON body. Names,
        email, version, and other fields are rejected; query parameters do not supply body fields.
        Presets admin/read_only reject permissions even null. Custom requires every catalog
        area with none/view/manage; api_access supports only none/manage. Extra areas are rejected.
        Responses label custom permissions matching a preset as that preset.

        Only public IDs matching ^invite_[A-Za-z0-9]+$ are accepted. Numeric IDs, UUIDs,
        and acceptance tokens are invalid_request. No public-ID fallback or backfill occurs.
        Response IDs retain their nullable contract; never substitute a private identifier.
        Returns the saved invitation and changed=true when permissions differ, false otherwise.
        Does not send email, renew expiration, or change identity or original attribution.
        Expired pending invitations can be edited. Version remains an opaque saved revision;
        do not interpret it as a token or manufacture it. Allow new response fields and treat
        unknown enum values as unsupported, never as additional access.
        An uncertain result can be retried with the same body; authorization and plan checks
        still apply. Access is replaced as a complete permission set, not merged as a partial
        update. There is no version precondition or compare-and-swap: a later authorized edit
        can overwrite earlier permissions. Fetch current state when needed.
        Resend, revoke, and accepted-member management are separate operations.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: '^invite_[A-Za-z0-9]+$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/UpdateTeamInvitation'
            example:
              access_template: read_only
      responses:
        '200':
          description: Saved invitation; changed indicates whether permissions differed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitationOperation'
        '400':
          description: Missing access_template (parameter_missing), invalid public ID, invalid JSON/content type, unknown fields, or invalid permissions (invalid_request).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: API access denied, grant or target limits exceeded (insufficient_permission), RBAC disabled (feature_disabled), or Growth required (plan_upgrade_required).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Invitation or team unavailable, including accepted or foreign-company invitations (resource_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Stored invitation validation failed (invitation_invalid). The indicated field may be existing identity data such as first_name, which cannot be repaired through this access-only PATCH. Resolve the stored-record condition through another supported workflow; incomplete Custom input instead returns 400 invalid_request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Access update failed (permissions_update_failed). Retry the same body with exponential backoff.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /team/invitations/{id}/resend:
    post:
      operationId: resendTeamInvitation
      summary: Resend invitation
      tags: [Team]
      description: |
        Resends a pending invitation using only its public ID. Send no request body or query
        parameters; no Content-Type header is required for the bodyless POST. Bodies (including
        an empty JSON object or the former version field) and query parameters are rejected
        with invalid_request. The backend loads the current revision from the API key's company
        and supplies it internally to the existing resend service. Clients do not fetch or send
        version, and this endpoint adds no idempotency-key support.

        Requires API access Manage and the Admin role in the company. Team & access Manage
        alone does not allow resending, even for a weaker invitation. A non-Admin caller
        receives 403 insufficient_permission; upgrading the plan does not grant invitation access.
        Existing account, subscription, staging and usage checks apply. No additional Growth
        or RBAC feature gate. Only IDs matching ^invite_[A-Za-z0-9]+$ are accepted; numeric IDs,
        UUIDs and acceptance tokens are invalid_request. Missing, accepted and foreign-company
        invitations are unavailable (resource_missing). No global cross-company lookup occurs.

        For the current saved revision, the existing service renews the acceptance link and
        expiration and requests delivery. Both expired and unexpired pending invites can be
        resent. Original identity, permissions, created and invited_by remain unchanged.
        Each call reads the current state and is a fresh resend request. A repeat after a timeout
        can renew again and request another email, even when an earlier delivery is pending.
        The newer acceptance link replaces the earlier link. Do not automatically retry an
        uncertain result as if it were duplicate-safe; retry only when another resend is acceptable.

        Returns 200 with the saved invitation and changed. Normally changed=true; if the revision
        changes between lookup and the service's locked check, existing stale-version behavior
        may return changed=false. Existing matching pending-delivery retry behavior is preserved.
        No forced retry loop is added. Neither 200 nor changed=true guarantees email arrival.
        Enqueue-registration failure rolls back renewal; delivery may also fail after commit.
        For invitation_resend_failed, use bounded backoff only when another resend is acceptable.

        Response version remains an informational opaque saved revision for compatibility, not
        an input requirement or acceptance credential. Public IDs retain their nullable contract;
        no numeric fallback, request-time backfill, acceptance token or internal delivery token
        is exposed. Accept new response fields and handle unknown future enums without inferring
        additional access. An internal revision failure is invitation_resend_failed with no
        client version parameter to correct.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: '^invite_[A-Za-z0-9]+$'
      responses:
        '200':
          description: Saved invitation; changed is normally true for renewal/delivery work and may be false if the revision changes concurrently. Email arrival is not guaranteed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InvitationOperation'
        '400':
          description: Request body or query parameters supplied, or invalid public ID (invalid_request).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Existing API access/account checks failed or target access exceeds the caller (insufficient_permission). Resend adds no Growth or RBAC gate.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: The team or invitation is unavailable, including missing, accepted and foreign-company invitations (resource_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Stored invitation validation failed (invitation_invalid). Resolve the record condition through another supported workflow; resend accepts no body and cannot repair identity or permissions.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '500':
          description: Resend failed (invitation_resend_failed). Retry with bounded backoff only if another resend is acceptable; repeated calls may renew again and request another email.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /team:
    get:
      operationId: retrieveTeam
      summary: Retrieve team
      description: |
        Returns the company's team as one document: active and inactive members plus pending
        invitations, in the same order as the Settings team page. Deleted memberships and
        non-pending invitations are excluded. Finta support staff with a membership appear as
        ordinary members; there is no staff marker. Active members precede inactive members;
        no alphabetical ordering is promised. No filters or pagination. Empty collections
        are empty arrays. Requires API access Manage and Team & access View for the
        authenticated company. Missing or invalid public IDs are null; records are still
        returned, and this request does not backfill or modify team records.
      tags:
        - Team
      responses:
        "200":
          description: The team document
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Team"
              example:
                object: team
                members:
                  - id: usr_a1b2c3d4e5f6g7
                    object: member
                    email: alex@example.com
                    name: Alex Chen
                    state: active
                    access_template: admin
                    permissions:
                      dashboard: manage
                      transactions: manage
                      accounting_setup: manage
                      reconciliation: manage
                      invoices: manage
                      reports: manage
                      forecast: manage
                      taxes: manage
                      integrations: manage
                      company: manage
                      team_access: manage
                      billing: manage
                      ask_finta: manage
                      api_access: manage
                    created: 1751371200
                invitations:
                  - id: invite_h8i9j0k1l2m3n4
                    version: '2026-09-09T17:20:00.000000Z'
                    object: invitation
                    invited_by: usr_a1b2c3d4e5f6g7
                    expires: 1791568800
                    email: sam@example.com
                    name: Sam Rivera
                    access_template: read_only
                    permissions:
                      dashboard: view
                      transactions: view
                      accounting_setup: view
                      reconciliation: view
                      invoices: view
                      reports: view
                      forecast: view
                      taxes: view
                      integrations: view
                      company: view
                      team_access: view
                      billing: view
                      ask_finta: view
                      api_access: none
                    created: 1788976800
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /categories:
    get:
      operationId: listCategories
      summary: List categories
      description: Returns all categories (tiers 1-3) for the authenticated company, ordered by tier, position, and name.
      tags:
        - Categories
      responses:
        "200":
          description: A list of categories
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryList"
              example:
                object: list
                url: /api/v1/categories
                data:
                  - id: cat_a1b2c3d4e5f6g7
                    object: category
                    name: People Expenses
                    description: ""
                    account_type: expense
                    point_in_time: false
                    tier: 1
                    position: 2
                    parent_id: null
                    accounting_treatment: null
                    created: 1688053841
                  - id: cat_h8i9j0k1l2m3n4
                    object: category
                    name: Payroll
                    description: Salaries, wages, and payroll taxes paid through a payroll provider such as Gusto or Rippling.
                    account_type: expense
                    point_in_time: false
                    tier: 1
                    position: 1
                    parent_id: cat_a1b2c3d4e5f6g7
                    accounting_treatment: null
                    created: 1688053841
                  - id: cat_o5p6q7r8s9t0u1
                    object: category
                    name: Software
                    description: ""
                    account_type: expense
                    point_in_time: false
                    tier: 1
                    position: 3
                    parent_id: null
                    accounting_treatment: null
                    created: 1688053841
                  - id: cat_v2w3x4y5z6a7b8
                    object: category
                    name: Hosting
                    description: Cloud infrastructure and hosting, such as AWS, Google Cloud, or Vercel.
                    account_type: expense
                    point_in_time: false
                    tier: 1
                    position: 1
                    parent_id: cat_o5p6q7r8s9t0u1
                    accounting_treatment: null
                    created: 1688053841
                  - id: cat_c9d0e1f2g3h4i5
                    object: category
                    name: Equipment
                    description: Computers, furniture, and other physical equipment expected to last more than a year.
                    account_type: asset
                    point_in_time: true
                    tier: 1
                    position: 4
                    parent_id: null
                    accounting_treatment: "Depreciated straight-line over 5 years: the purchase is recorded as an asset and expensed monthly as depreciation."
                    created: 1688053841
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    post:
      operationId: createCategory
      summary: Create category
      description: |
        Adds one of Finta's standard categories to the company's chart of accounts: the same
        templates people add themselves under Settings -> Categories in the Finta web app. You
        name a template, never a category name or parent, and each template always goes under the
        same parent, so nothing outside this list can be created. Requires API access and
        Accounting setup: Manage.

        | `template` | Adds | Under |
        |------------|------|-------|
        | `fulfillment` | Fulfillment | Cost of Revenue |
        | `cost_of_services` | Cost of Services | Cost of Revenue |
        | `rd_expenses` | R&D Expenses, with Consumables, Biologicals, Chemicals and R&D services beneath it. Needs the Startup plan or higher. | Expenses |
        | `equipment` | Equipment and Accumulated Depreciation. Transactions categorized as Equipment are depreciated over 5 years. | Assets |
        | `inventory` | Inventory | Assets |
        | `intercompany_expenses` | Intercompany Expenses. Only Finta adds it. | Expenses |
        | `investments` | Investments. Only Finta adds it. | Assets |
        | `shareholder_dividends` | Shareholder Dividends. Only Finta adds it. | Equity |

        Returns every category of the template in the `GET /categories` shape, so you can use the
        IDs right away: `201` when this call added any of them or repaired one, `200` when they
        all already existed. Repairing means an existing category with the template's name that
        wasn't yet linked to the template becomes the template's category (for Equipment, that
        turns on depreciation). Retrying is safe without an idempotency key; a template's categories are never
        created twice. Check `GET /categories` first.

        A template only Finta adds is never created here. It returns `403` `request_required`:
        ask for it from Settings -> Categories in the web app, and Finta adds it. Once Finta has
        added it, this returns it with `200`. On a plan below Startup, `rd_expenses` and the
        templates only Finta adds return `403` `plan_upgrade_required` instead.
      tags:
        - Categories
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CategoryCreate"
            example:
              template: equipment
      responses:
        "201":
          description: This call added some or all of the template's categories, or repaired an existing one. Returns all of them.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryList"
              example:
                object: list
                url: /api/v1/categories
                data:
                  - id: cat_x4c9Qm2LpR7tNw
                    object: category
                    name: Equipment
                    description: Computers, furniture, and other physical equipment expected to last more than a year.
                    account_type: asset
                    point_in_time: true
                    tier: 2
                    position: 9
                    parent_id: cat_a8Kd3Vn6Zs1yHe
                    accounting_treatment: "Depreciated straight-line over 5 years: the purchase is recorded as an asset and expensed monthly as depreciation."
                    created: 1768505200
                  - id: cat_m5Tg8Wq0Bf2cJu
                    object: category
                    name: Accumulated Depreciation
                    description: ""
                    account_type: asset
                    point_in_time: true
                    tier: 2
                    position: 10
                    parent_id: cat_a8Kd3Vn6Zs1yHe
                    accounting_treatment: null
                    created: 1768505200
        "200":
          description: The template's categories all already existed. Returns them unchanged.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CategoryList"
        "400":
          description: >-
            Invalid JSON or an unknown field (invalid_request), no `template` (parameter_missing),
            or a `template` that isn't one of the values above or isn't available to this company
            (invalid_request). `param` names the field; it's absent for invalid JSON, the wrong
            content type, or a body that isn't a JSON object.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  type: invalid_request_error
                  code: invalid_request
                  message: "Unknown template: 'software'. It must be one of fulfillment, cost_of_services, rd_expenses, intercompany_expenses, equipment, inventory, investments, or shareholder_dividends; no other categories can be added."
                  param: template
                  doc_url: https://www.finta.com/docs/api-reference/errors#invalid_request
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            Any of the `Forbidden` codes, including `insufficient_permission` when the key owner
            lacks Accounting setup: Manage or the key is read-only. Also `plan_upgrade_required`
            when the company's plan doesn't include the template (upgrade under Settings -> Plans,
            then retry), and `request_required` for a template only Finta adds that the company
            doesn't have yet. Nothing is created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                plan_upgrade_required:
                  summary: The template needs a higher plan
                  value:
                    error:
                      type: permission_error
                      code: plan_upgrade_required
                      message: R&D Expenses requires the Startup plan. Upgrade under Settings -> Plans in Finta, then retry.
                      doc_url: https://www.finta.com/docs/api-reference/errors#plan_upgrade_required
                request_required:
                  summary: Only Finta adds this template
                  value:
                    error:
                      type: permission_error
                      code: request_required
                      message: Only Finta adds Investments, so it can't be added directly. Request it from Settings -> Categories in the Finta web app.
                      doc_url: https://www.finta.com/docs/api-reference/errors#request_required
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: The categories could not be saved (category_create_failed). Nothing was saved, so retrying is safe.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /rules:
    post:
      operationId: createRule
      summary: Create rule
      tags: [Rules]
      description: >-
        Saves a company automation rule and returns its complete definition. Requires API access: Manage
        and Accounting setup: Manage. Historical application additionally requires Transactions: Manage,
        rechecked at execution. apply_to_existing_transactions is a strict boolean defaulting to true;
        false saves a future-only rule. A 201 confirms saving, not completion of historical application.
        Requested work is saved durably and recovered after enqueue failures. No public job, polling
        endpoint, or Idempotency-Key is required. The application flag is never returned as saved state.
        Historical eligibility is approved uncategorized transactions; department actions additionally
        consider missing-department transactions and never replace assigned departments. Existing priority,
        automation limits and rollback behavior apply. Accounting-date offsets apply only on import;
        mixed rules can apply other actions historically. No bulk transaction cap applies; tag actions skip split lines.
        A pending run is cancelled by a successful edit or delete before it starts. Running work is
        serialized against edits/deletes; callers can retry a busy mutation. Failed historical application
        does not delete the saved rule. Tag actions fill missing tags on matching past transactions regardless of category or approval, excluding deleted transactions and split lines; other actions keep their existing eligibility.
        Exact duplicates in the same company return 409 rule_already_exists with the accessible existing
        rule ID in the message, without another run. Condition and action order and case-insensitive text
        condition casing do not distinguish definitions; the application flag does not distinguish them.
        After an uncertain response, an identical retry may return that conflict: retrieve the existing
        rule instead of requesting another run. Different actions are not exact duplicates.
        Unknown keys, legacy write fields, null actions, wrong types, reversed ranges and unsupported
        combinations return 400. Unavailable references return 404 without revealing foreign resources.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RuleCreate'
            example:
              conditions:
                - {field: merchant, operator: contains, value: Cursor}
              actions: {merchant: Cursor AI}
              apply_to_existing_transactions: true
      responses:
        '201':
          description: Saved rule; historical application may still be queued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Rule'
        '400':
          description: Invalid input.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Unauthenticated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Missing operation permission or API access.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: Unavailable reference.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Exact duplicate; message includes the existing rule ID.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: An existing duplicate has not received a public ID yet. Retry after the ID rollout completes; no extra rule or historical request is created. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: Existing API rate or usage limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    get:
      operationId: listRules
      summary: List rules
      tags: [Rules]
      description: >-
        Returns complete company automation rules, including rules created in the UI, newest first
        with a descending internal-ID tie-breaker. Requires Accounting setup: View and API access: Manage.
        Global, internal/system, category-owned, and department-owned rules are excluded.
        Conditions are a flat AND list; all must match. Only configured actions are returned.
        Legacy account and card_name text conditions retain their original case-insensitive matching;
        they are never guessed or converted to account_id/card_id. No filters or search are supported.
        This read does not assign IDs, run rules, or modify transactions. During an incomplete ID rollout
        or if a stored definition cannot be represented faithfully, returns 503 rules_not_ready rather
        than hiding a rule or changing its meaning. Retry later with backoff; persistent failures require
        operator attention. No public job or polling endpoint is involved.
      parameters:
        - name: limit
          in: query
          description: Page size; omitted or empty defaults to 100. Nonintegers and values outside 1–500 return 400 invalid_request.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: starting_after
          in: query
          description: >-
            Pass next_cursor unchanged from the previous page. Omit for the first page.
            An unknown, deleted, invalid, or inaccessible cursor returns 404 resource_missing.
            Pagination is live, not a snapshot; rules inserted ahead of the cursor appear on a new traversal.
          schema:
            type: string
      responses:
        '200':
          description: A page of complete rules. Empty results have data [], has_more false, and next_cursor null.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [object, url, has_more, next_cursor, data]
                properties:
                  object:
                    type: string
                    enum: [list]
                  url:
                    type: string
                    enum: [/api/v1/rules]
                  has_more:
                    type: boolean
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass unchanged as starting_after; null on the last page.
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/Rule'
              example:
                object: list
                url: /api/v1/rules
                has_more: false
                next_cursor: null
                data:
                  - id: rule_a1b2c3d4e5f6g7
                    object: rule
                    conditions:
                      - field: merchant
                        operator: contains
                        value: Cursor
                    actions:
                      merchant: Cursor AI
                      category_id: cat_a1b2c3d4e5f6g7
        '400':
          description: Invalid limit; error.param is limit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Unavailable cursor; error.param is starting_after. Does not reveal cross-company rules.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Rules cannot yet be returned faithfully; api_error with code rules_not_ready. Retry later with exponential backoff. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /rules/{id}:
    delete:
      operationId: deleteRule
      summary: Delete rule
      tags: [Rules]
      description: >-
        Deletes a company automation rule. Accepts no request body, including an empty JSON object.
        Requires Accounting setup: Manage, Transactions: Manage and API access: Manage on every request,
        including retries for absent IDs. Permission checks precede body and ID validation, so callers
        missing Transactions: Manage receive 403 even for malformed IDs or requests with a body.
        Successful deletion and already-absent rules return 204 with
        no body. Foreign-company, global, internal/system, owner-specific and mixed-owner records are
        indistinguishable from absence and are not changed. Malformed rule_ IDs return 400 invalid_request.
        Transactions directly linked through rule_id can have their categories reset to defaults;
        matched transfer legs keep their categories and are detached from the rule. This is not a general
        undo of every rule action and does not promise that historical transactions remain unchanged.
        Retries after success do not repeat these effects. No Idempotency-Key is required.
        Queued API historical application is cancelled atomically with deletion. Already-running work
        returns retryable 503 rule_busy without changes; retry with exponential backoff. Legacy unassociated
        UI jobs do not gain these cancellation guarantees. Missing-resource associated jobs safely cancel
        without recreating the rule. Deletion does not validate or serialize the stored definition, so stale
        references or definitions not representable by the API do not prevent deletion.
        A failed deletion rolls back category changes, rule deletion and history cancellation and returns
        500 api_error with code rule_deletion_failed, never 204. Unexpected server errors use existing server
        handling. Retry failed or interrupted requests with exponential backoff; seek operator help
        for persistent failures. No new history, migration, backfill, feature flag or public job API is added.
      parameters:
        - name: id
          in: path
          required: true
          description: Public rule_ ID with a nonempty alphanumeric suffix of any length.
          schema:
            type: string
            pattern: '^rule_[A-Za-z0-9]+$'
          example: rule_a1b2c3d4e5f6g7
      responses:
        '204':
          description: Rule deleted or absent in the authenticated scope. No response body.
        '400':
          description: Invalid ID format or nonempty request body; invalid_request_error with code invalid_request.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: Deletion failed; api_error with code rule_deletion_failed. No deletion effects committed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: Rule is busy applying history; invalid_request_error with code rule_busy. Retry with exponential backoff. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    patch:
      operationId: updateRule
      summary: Update rule
      tags: [Rules]
      description: >-
        Updates future matching only; never runs history or repairs past transactions.
        Requires Accounting setup: Manage and API access: Manage; Transactions: Manage is not required.
        Supplied action fields merge into existing actions. Omitted fields stay unchanged and null removes
        an action from the rule, not from past transactions. Supplied conditions replace the entire flat
        AND list; omission preserves it, including legacy account/card_name text predicates. Legacy fields
        cannot be submitted as new conditions. Exact identity references and inclusive signed amount
        boundaries retain their Create semantics. The complete resulting definition is validated atomically
        and must contain at least one condition and action, including the Asset/nonzero-offset restriction.
        Explicitly supplied category/department IDs require current selectability/assignability, even if
        they equal the saved value. Omitted actions retain their existing saved associations within the
        authorized company/system scope, including system departments available through the company
        department collection, even if no longer selectable/assignable for a new rule.
        This does not exempt preserved actions from whole-definition validation. Invalid updates leave the rule and queued history unchanged.
        Rejects empty bodies, only empty actions, unknown fields, and apply_to_existing_transactions.
        Valid no-ops return 200 without changing the definition or cancelling queued history.
        A real edit cancels pending API-created historical work. Already-running history returns retryable
        503 rule_busy without changes; retry with exponential backoff. Legacy unassociated UI jobs do not
        gain these cancellation guarantees. Reads and merges serialize against other definition writes.
        Exact normalized duplicates excluding this rule return 409 rule_already_exists with the accessible
        existing rule ID in the message; unavailable duplicate IDs return 503 rules_not_ready.
        Unknown, foreign-company, global, internal/system, owner-specific and mixed-owner rules all return
        the same 404 resource_missing. Malformed rule_ IDs return 400 invalid_request. Unsupported stored
        definitions return 503 rules_not_ready. No new migration or public job API is introduced.
      parameters:
        - name: id
          in: path
          required: true
          description: Public rule_ ID with a nonempty alphanumeric suffix of any length.
          schema:
            type: string
            pattern: '^rule_[A-Za-z0-9]+$'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/RuleUpdate'
            example:
              actions:
                merchant: Cursor
                department_id: null
      responses:
        '200':
          description: The complete saved rule, with removed actions omitted.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Rule'
        '400':
          description: Invalid input or resulting definition; invalid_request_error with code invalid_request and useful input path.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Rule or referenced resource unavailable; resource_missing. Missing rules have message Rule not found and param id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Exact duplicate; rule_already_exists with accessible existing rule ID in the message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Retry with exponential backoff. rule_busy (invalid_request_error) indicates running history; rules_not_ready (api_error) indicates an unrepresentable definition or missing duplicate ID. Seek operator attention for persistent rules_not_ready. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
    get:
      operationId: retrieveRule
      summary: Retrieve rule
      tags: [Rules]
      description: >-
        Returns one complete company automation rule using exactly the same representation as List rules,
        including UI-created rules. Requires Accounting setup: View and API access: Manage.
        Unknown, deleted, foreign-company, global, internal/system, category-owned, and department-owned
        rules return the same 404 resource_missing response without revealing existence.
        Only public rule_ IDs are accepted; numeric IDs, UUIDs and malformed IDs return 400 invalid_request.
        Conditions are a flat AND list and only configured actions are returned. Legacy account/card_name
        text predicates, exact account_id/card_id identities, and signed inclusive amount bounds retain
        their List semantics. This GET does not assign IDs, run rules, enqueue historical work, or change
        transactions. An unrepresentable requested definition returns 503 api_error/rules_not_ready;
        retry with exponential backoff, and seek operator attention for persistent failures.
        Missing IDs or unsupported definitions on other rules do not block retrieval of this rule.
      parameters:
        - name: id
          in: path
          required: true
          description: Public rule_ ID returned by List rules, with a nonempty alphanumeric suffix of any length.
          schema:
            type: string
            pattern: '^rule_[A-Za-z0-9]+$'
          example: rule_a1b2c3d4e5f6g7
      responses:
        '200':
          description: The complete rule object, identical to its entry in List rules.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Rule'
              example:
                id: rule_a1b2c3d4e5f6g7
                object: rule
                conditions:
                  - field: merchant
                    operator: contains
                    value: Cursor
                actions:
                  merchant: Cursor AI
                  category_id: cat_a1b2c3d4e5f6g7
        '400':
          description: Invalid ID format; invalid_request_error with code invalid_request and param id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Rule unavailable; invalid_request_error with code resource_missing, message Rule not found, and param id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          description: Requested definition cannot be returned faithfully; api_error with code rules_not_ready. Retry later with exponential backoff. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /tags:
    get:
      operationId: listTags
      summary: List tags
      description: List all tags in name order, with counts and the top five vendor names. Counts and vendor_names are null without Transactions View.
      tags: [Tags]
      responses:
        "200":
          description: All tags
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TagList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    post:
      operationId: createTag
      summary: Create tag
      description: Create a tag as in Settings. Names are unique ignoring case; Other is reserved. Formation allows 2 tags, Startup 10, Growth 100, and Enterprise is unlimited. A company-specific override can replace the plan limit. Paid limits require an active or trialing subscription; cancelled or unpaid subscriptions use Formation. At or above the effective limit, creation returns 403 plan_upgrade_required without creating a tag. Reuse an existing tag or ask the user to upgrade; renaming, deleting and assigning existing tags are unaffected. Under the limit, a repeated create returns 409 tag_already_exists; reuse the existing tag.
      tags: [Tags]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TagInput"
      responses:
        "201":
          description: The created tag with counts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tag"
        "400":
          description: Invalid name or request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: tag_already_exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: API access denied, Accounting setup Manage required (insufficient_permission), or the company has reached its effective tag limit (plan_upgrade_required).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                plan_upgrade_required:
                  value:
                    error:
                      type: permission_error
                      code: plan_upgrade_required
                      message: You have reached the tag limit for your plan.
                      doc_url: https://www.finta.com/docs/api-reference/errors#plan_upgrade_required
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
  /tags/{id}:
    parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: '^tag_'
    patch:
      operationId: updateTag
      summary: Update tag
      description: Rename a tag, preserving its assignments. Returns current counts.
      tags: [Tags]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TagInput"
      responses:
        "200":
          description: The renamed tag with counts
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Tag"
        "400":
          description: Invalid name or request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: resource_missing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: tag_already_exists
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    delete:
      operationId: deleteTag
      summary: Delete tag
      description: Delete a tag and remove it from every transaction that carries it. Deletes rules whose only action is that tag; rules with other actions keep those actions and lose only the tag action. Categories are preserved. A repeated delete returns 404 resource_missing. On tag_busy retry the same request.
      tags: [Tags]
      responses:
        "204":
          description: Tag deleted
        "404":
          description: resource_missing
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "503":
          description: tag_busy; retry the same request Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "500":
          description: tag_deletion_failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /departments:
    get:
      operationId: listDepartments
      summary: List departments
      description: |
        Returns the authenticated company's department hierarchy in display order.
        Only leaf departments with `assignable: true` can be assigned to transactions.
      tags:
        - Departments
      responses:
        "200":
          description: A list of departments
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    enum:
                      - /api/v1/departments
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Department"
              example:
                object: list
                url: /api/v1/departments
                data:
                  - id: dept_a1b2c3d4e5f6g7
                    object: department
                    name: Product
                    tier: 1
                    parent_id: null
                    assignable: false
                  - id: dept_h8i9j0k1l2m3n4
                    object: department
                    name: Engineering
                    tier: 2
                    parent_id: dept_a1b2c3d4e5f6g7
                    assignable: true
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /usage:
    get:
      operationId: retrieveUsage
      summary: Retrieve usage
      description: |
        Returns how much of each limit the authenticated company has used. These are the counts and
        limits Finta enforces, including any limit Finta raised for the company, not the plan's
        advertised ones. `stopped` is true when Finta has stopped because of the limit, as its banners
        show. Monthly counts reset at midnight Pacific on `resets_on`; the transaction limit is a
        stored total and never resets. A null `limit` means there is none, as on Enterprise. Counts can
        lag by a few minutes. This endpoint doesn't count toward the monthly API limit and keeps
        answering after that limit is reached, so `api_calls.stopped` can be read at the limit.
      tags:
        - Plans & billing
      responses:
        "200":
          description: The company's usage
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Usage"
              example:
                object: usage
                categorizations:
                  used: 2712
                  limit: 3000
                  held: 0
                  stopped: false
                  resets_on: "2026-11-01"
                transactions:
                  used: 41380
                  limit: 100000
                  held: 0
                  stopped: false
                  resets_on: null
                ai_credits:
                  used: 22
                  limit: 30
                  remaining: 8
                  stopped: false
                  resets_on: "2026-11-01"
                api_calls:
                  used: 12
                  limit: 300
                  custom_limit: false
                  stopped: false
                  resets_on: "2026-11-01"
                mcp_calls:
                  used: 214
                  limit: 300
                  custom_limit: false
                  stopped: false
                  resets_on: "2026-11-01"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /integrations:
    get:
      operationId: listIntegrations
      summary: List integrations
      description: |
        Returns every integration available to the authenticated company with its connection
        status and `connection_methods`: each way it can be connected, where that stands, and
        how to start it. A method that is `requested` or `requires_reconnect` is something
        Finta needs from the company.
      tags:
        - Integrations
      responses:
        "200":
          description: A list of integrations
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    enum:
                      - /api/v1/integrations
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Integration"
              example:
                object: list
                url: /api/v1/integrations
                data:
                  - key: mercury
                    object: integration
                    name: Mercury
                    type: banking
                    status: connected
                    connection_methods:
                      - type: oauth
                        status: connected
                        connect_via: api
                      - type: grant_access
                        status: requested
                        connect_via: web_app
                    quality: great
                    signup_referral: null
                  - key: ramp
                    object: integration
                    name: Ramp
                    type: banking
                    status: not_connected
                    connection_methods:
                      - type: oauth
                        status: not_connected
                        connect_via: api
                    quality: great
                    signup_referral:
                      reward: $500 sign up bonus
                      link: https://ramp.com/partners/finta
                  - key: carta
                    object: integration
                    name: Carta
                    type: cap_table
                    status: not_connected
                    connection_methods:
                      - type: grant_access
                        status: not_connected
                        connect_via: web_app
                    quality: good
                    signup_referral: null
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /integrations/{key}/connect:
    get:
      operationId: retrieveIntegrationConnectRequest
      summary: Retrieve connect request
      description: |
        Returns the latest connection request you started for this integration with
        `POST /integrations/{key}/connect`. Poll it to follow the connection; it never changes
        anything.

        Poll every 10 seconds while `status` is `pending` or `authorizing`, and stop once it is
        `complete`, `failed`, or `expired`. A request that failed or expired stays here until
        you start a new one, so you can read how it ended. If the user wants to try again, call
        `POST /integrations/{key}/connect` for a new link.

        While the company is onboarding, this works only once Finta has approved it (see
        "Onboarding"); before then it returns `403` `onboarding_incomplete`.
      tags:
        - Integrations
      parameters:
        - $ref: "#/components/parameters/IntegrationKey"
      responses:
        "200":
          description: The latest connection request for this integration.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectRequest"
        "400":
          description: The integration exists but cannot be connected through the API yet (integration_not_connectable).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            Existing API access checks failed, or the key owner lacks the Integrations: Manage
            and Transactions: Manage permissions (insufficient_permission).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: You have not started a connection for this integration, or no integration with this key is available to the company (resource_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    post:
      operationId: connectIntegration
      summary: Connect integration
      description: |
        Returns a link that a person opens to connect this integration to the company. The API
        cannot connect a provider by itself: the provider requires the account holder to sign
        in and approve access in a browser. This endpoint starts that flow and hands you the
        link. Only integrations with a `connection_methods` entry whose `connect_via` is `api` in
        `GET /integrations` are supported; any other returns `400` `integration_not_connectable`.

        ## What to do with the link

        1. Show `url` to the user, or open it in their browser with their permission.
        2. They must open it in a browser where they are signed in to Finta as the user who
           started the request. The connection is rejected if a different Finta user completes it.
           Plaid banks are the exception; see below.
        3. They approve access on the provider's page. Do not fill in, click through, or
           otherwise automate that page. The approval has to come from the person.
        4. Poll `GET /integrations/{key}/connect` until its `status` is `complete`, `failed`,
           or `expired`.

        ## Plaid banks

        Banks Finta connects through Plaid (`chase`, `svb`, `arc`, `citi`, `boa` and
        `american_express`) return a Plaid Hosted Link page as `url`, valid for 30 minutes.
        The person picks their bank and signs in to it on Plaid's page; no Finta sign-in is
        needed, so anyone with the link can connect a bank to this company until it expires.
        Only share it with the user. When they finish, Plaid shows a Finta page asking them to
        return to you at `/integrations/plaid/connected`. This public, read-only completion
        page appears on exit too and does not confirm success; check the connect request.
        Plaid tells Finta how the session ended, usually within seconds. Temporary provider
        or database errors retry automatically while the request remains `authorizing`;
        keep checking that request rather than starting another connection. A real refusal
        (such as choosing a different bank) ends it as `failed`. Retried completion resumes
        the saved bank connection and queues its initial import once, including onboarding.
        Web and API completions for the same bank are serialized to prevent duplicates.
        `intent` is always `connect`: a Plaid bank that is already connected returns `409`
        `integration_already_connected`, even when its status is `requires_reconnect`, because
        reconnecting it is only available in the Finta web app.

        ## One request at a time

        A company has at most one open request per integration. Calling this endpoint again
        returns it with `200`, so retries are safe. After it fails or expires, the next call
        starts a new one with `201`. While another user holds the open request, this returns
        `409` `connect_link_in_progress`.

        ## During onboarding

        A company still onboarding can connect only the banks it selected once Finta has approved
        it (`GET /onboarding` `status` `connect_banks` or `syncing`). Before approval, while the
        questionnaire is unfinished, a call is required, or the company isn't eligible, this
        returns `403` `onboarding_incomplete`: finish onboarding first with `GET /onboarding` and
        `PATCH /onboarding`. A bank connected before approval would sync outside onboarding, and
        onboarding's first sync would never start.
      tags:
        - Integrations
      parameters:
        - $ref: "#/components/parameters/IntegrationKey"
      responses:
        "201":
          description: A new connection request was created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectRequest"
        "200":
          description: The company's open connection request for this integration was returned again.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ConnectRequest"
        "400":
          description: The integration exists but cannot be connected through the API yet (integration_not_connectable). Connect it in the Finta web app.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            Existing API access checks failed, or the key owner lacks the Integrations: Manage
            and Transactions: Manage permissions (insufficient_permission).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: No integration with this key is available to the company (resource_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            The integration is already connected (integration_already_connected), or another
            user holds the live link (connect_link_in_progress). For a Plaid bank,
            integration_already_connected also covers a connection that requires reconnecting.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: The link could not be created (connect_link_failed). Retry with exponential backoff.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /inbox:
    get:
      operationId: listInbox
      summary: List inbox
      description: |
        Returns what the company's Finta inbox is asking for: every item the web inbox shows as
        open, across integrations, transactions and taxes. Snoozed items stay out until their
        reminder date, like in the web inbox. You only see items whose area (Integrations,
        Transactions or Taxes) you can view; Dashboard access isn't needed. Items about something
        since deleted, and a setup categorize item with nothing left to categorize, are left out too.

        Each item's `instructions` say what to do. When a Finta skill covers the item, they name
        the skill to run. Otherwise the user does it in the Finta web app, at `url`.

        Items close on their own once Finta sees the work done, except `grant_access`, which
        Finta can't detect: mark it complete with `POST /inbox/{id}/complete` once the invite
        is sent. Not paginated; oldest first.
      tags:
        - Inbox
      responses:
        "200":
          description: The open inbox items
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    enum:
                      - /api/v1/inbox
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/InboxItem"
              example:
                object: list
                url: /api/v1/inbox
                data:
                  - id: inb_3f2a9c1e7b4d4e0f9a6c2b8d1e5f7a90
                    object: inbox_item
                    type: grant_access
                    status: open
                    title: Grant access for Mercury Treasury
                    instructions: Get the grant-access-mercury skill and run it, then mark this item complete.
                    url: https://app.finta.com/companies/comp_a1b2c3d4e5f6g7/inbox
                    created: 1792100000
                  - id: inb_8c1d2e3f4a5b4c6d8e9f0a1b2c3d4e5f
                    object: inbox_item
                    type: reconnect_integration
                    status: open
                    title: Reconnect Chase
                    instructions: There's an issue with your Chase connection, please reconnect. The user needs to do this in the Finta web app.
                    url: https://app.finta.com/companies/comp_a1b2c3d4e5f6g7/inbox
                    created: 1792300000
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /inbox/{id}/complete:
    post:
      operationId: completeInboxItem
      summary: Complete inbox item
      description: |
        Marks a `grant_access` item done once the user has invited Finta as a user in the
        provider, as the web app's "I've invited Finta" button does. Finta can't see the invite,
        so it relies on this. Calling it again on a completed item returns the same item.

        Every other type closes on its own once Finta sees the work done, so completing it
        returns `400` `inbox_item_not_completable`, with a message saying what closes it.
      tags:
        - Inbox
      parameters:
        - $ref: "#/components/parameters/InboxItemId"
      responses:
        "200":
          description: The completed item.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/InboxItem"
        "400":
          description: Finta closes this item on its own; it can't be completed (inbox_item_not_completable).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: >-
            Existing API access checks failed, or the caller lacks Manage on the item's area, such as
            Integrations: Manage for a grant_access item (insufficient_permission).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: >-
            No inbox item with this id that you can see: an unknown id, another company's item, one in an
            area you can't view, a dismissed one, or one snoozed until a later reminder date (resource_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          description: The item could not be completed (inbox_item_complete_failed). Retry with exponential backoff.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /aggregations/total:
    get:
      operationId: retrieveTotal
      summary: Retrieve total
      description: |
        Calculates one signed total for the requested category and its descendants.

        Check the category's `point_in_time` field from `GET /categories` before choosing date
        parameters. Periodic categories (`point_in_time: false`), such as income-statement and
        cash-flow categories, require `start_date` and `end_date`. Point-in-time categories
        (`point_in_time: true`), such as balance-sheet categories, require `date` and reject
        `start_date` and `end_date`.

        Existing integrations may continue using the deprecated month contract. Periodic
        categories accept `start_month` and `end_month`; point-in-time categories accept only
        `end_month`. Month parameters use `YYYY-MM`, retain whole-month behavior, and cannot be
        combined with full-date parameters. Responses include both representations so clients
        can migrate independently.

        Full dates use `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 are cumulative through the exact `date`. **Behavioral breaking change:** full-date
        category totals previously ignored the day and selected whole months. To keep whole-month
        totals, use the first and last calendar days or the deprecated month parameters.
        Assets use debit minus credit. All other categories use credit minus debit.

        Whole months use whole-month rollups; the current month's first day through today in
        Pacific Time uses fresh MTD rollups. Other partial months, including a midmonth start,
        use slower raw-entry reads for only those months. Stale or unfilled MTD falls back to raw
        entries. Month-aligned month-to-date and year-to-date requests remain fast when MTD is fresh.

        `merchant` is an exact, case-sensitive match. A blank value means all merchants.

        Response dates are the exact boundaries used. `refreshed_at` covers the entire result:
        the maximum contributing rollup timestamp, including MTD, or current as of the call when
        any portion reads live raw entries, even with no matching raw entries. It is null only
        when no contributing timestamp or live raw read is available. Rollups may lag new activity.

        The API rejects `period` and `department_id`. It ignores other unknown query parameters.
      tags:
        - Aggregations
      parameters:
        - name: category_id
          in: query
          required: true
          description: A `cat_` ID returned by `GET /categories`.
          schema:
            type: string
            pattern: "^cat_[A-Za-z0-9]+$"
          example: cat_o5p6q7r8s9t0u1
        - name: start_month
          in: query
          required: false
          deprecated: true
          description: Deprecated first whole month for a periodic category, as `YYYY-MM`. Includes its first calendar day in Pacific Time. Use with `end_month`, not with full-date parameters.
          schema:
            type: string
            pattern: "^\\d{4}-(0[1-9]|1[0-2])$"
          example: "2026-01"
        - name: end_month
          in: query
          required: false
          deprecated: true
          description: Deprecated last whole month for a periodic category or cutoff month for a point-in-time category, as `YYYY-MM`. Includes its last calendar day in Pacific Time, even for the current month. Do not combine with full-date parameters.
          schema:
            type: string
            pattern: "^\\d{4}-(0[1-9]|1[0-2])$"
          example: "2026-03"
        - name: start_date
          in: query
          required: false
          description: Exact first included calendar date for a periodic category, as `YYYY-MM-DD` in Pacific Time. Required when `point_in_time` is false and rejected when it is true. A midmonth start uses a slower raw-entry read for that partial month.
          schema:
            type: string
            format: date
          example: "2026-01-15"
        - name: end_date
          in: query
          required: false
          description: Exact last included calendar date for a periodic category, as `YYYY-MM-DD` in Pacific Time. Required when `point_in_time` is false and rejected when it is true. Month-start through today uses fresh MTD rollups; other partial-month cutoffs use slower raw-entry reads.
          schema:
            type: string
            format: date
          example: "2026-03-20"
        - name: date
          in: query
          required: false
          description: Exact inclusive cutoff calendar date for a point-in-time category, as `YYYY-MM-DD` in Pacific Time. Required when `point_in_time` is true and rejected when it is false. Today uses fresh MTD rollups; other non-month-end cutoffs use a slower raw-entry read for the cutoff month.
          schema:
            type: string
            format: date
          example: "2026-03-20"
        - name: merchant
          in: query
          required: false
          description: Exact, case-sensitive merchant or counterparty name. Blank means all merchants.
          schema:
            type: string
          example: AWS
      responses:
        "200":
          description: The requested total
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Total"
              example:
                object: total
                category_id: cat_o5p6q7r8s9t0u1
                merchant: AWS
                start_month: "2026-01"
                end_month: "2026-03"
                start_date: "2026-01-15"
                end_date: "2026-03-20"
                date: null
                point_in_time: false
                total_cents: -182340
                currency: USD
                debit_cents: 182340
                credit_cents: 0
                refreshed_at: 1786992120
        "400":
          description: A required parameter is missing, a date is invalid, or the request uses parameters for the wrong category type
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The category ID is malformed, unknown, unavailable through the API, or belongs to another company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /aggregations/totals:
    get:
      operationId: listTotals
      summary: List totals
      description: |
        Splits one category's total into rows by merchant or by month, in one call. Merchant rows
        across all pages, and periodic month rows, add up to `GET /aggregations/total` for the same
        parameters. Point-in-time month rows are cumulative closing balances, so don't add them;
        the last calendar month's row matches `GET /aggregations/total` with `date=end_date`.

        - `group_by=merchant`: one row per merchant or counterparty, `order=desc` by default. Dates
          follow the category's rules: `start_date` and `end_date`, or `date` for point-in-time
          categories.
        - `group_by=month`: one row per month, at most 120, in calendar order unless `order` is
          given. Always takes `start_date` and `end_date`. Periodic first and last month rows
          are clipped to those exact inclusive dates. Point-in-time rows include all activity
          through each month's closing date, with the last calendar month's cutoff at `end_date`;
          `start_date` selects the first displayed month, not the start of cumulative activity.

        Dates are calendar dates in America/Los_Angeles (Pacific Time). Whole months use
        whole-month rollups, and the current month's first day through today uses fresh MTD
        rollups. Other partial months, including a midmonth periodic start, read raw entries for
        only those months and are slower. Stale or unfilled MTD falls back to raw entries.
        Whole-month and month-aligned month-to-date/year-to-date requests remain fast when MTD
        is fresh. Full-date totals previously ignored the day; honoring it is a behavioral
        breaking change shared with `GET /aggregations/total`.

        `order` ranks by amount in the category's normal direction, so `desc` lists the vendors you
        spent the most with first under an expense category, and the customers who paid you the
        most first under an income category. `limit` returns the first rows in whichever order
        applies; page with `next_cursor`.

        Merchant names are exact, so spelling variants are separate rows; activity without a name
        is one row with `merchant: null`. Response dates are the boundaries actually used.
        `refreshed_at` covers the entire returned page, including MTD and any live raw portion,
        not just its whole months. A live raw read is current as of the call even when no raw
        entries match. Signs, freshness timestamp selection, and the rejected `period` and
        `department_id` parameters work as in `GET /aggregations/total`.
      tags:
        - Aggregations
      parameters:
        - name: category_id
          in: query
          required: true
          description: A `cat_` ID returned by `GET /categories`.
          schema:
            type: string
            pattern: "^cat_[A-Za-z0-9]+$"
          example: cat_o5p6q7r8s9t0u1
        - name: group_by
          in: query
          required: true
          description: "`merchant` to rank merchants, or `month` for one row per calendar month."
          schema:
            type: string
            enum:
              - merchant
              - month
          example: merchant
        - name: start_date
          in: query
          required: false
          description: Calendar date in Pacific Time, as `YYYY-MM-DD`. Exact inclusive start for periodic categories; selects the first displayed month for point-in-time month groups without excluding earlier cumulative activity. Required for periodic categories and any `group_by=month`. Legacy month parameters are not supported here.
          schema:
            type: string
            format: date
          example: "2026-01-01"
        - name: end_date
          in: query
          required: false
          description: Exact inclusive final calendar date in Pacific Time, as `YYYY-MM-DD`. Required for periodic categories and any `group_by=month`. The last calendar month's row stops at this date, including for point-in-time categories.
          schema:
            type: string
            format: date
          example: "2026-09-30"
        - name: date
          in: query
          required: false
          description: Exact inclusive cutoff calendar date in Pacific Time for a point-in-time category with `group_by=merchant`, as `YYYY-MM-DD`. Rejected for periodic categories and with `group_by=month`.
          schema:
            type: string
            format: date
          example: "2026-09-30"
        - name: merchant
          in: query
          required: false
          description: Exact, case-sensitive merchant or counterparty name. Blank means all merchants.
          schema:
            type: string
          example: AWS
        - name: order
          in: query
          required: false
          description: "`desc` lists the largest amounts first and `asc` the smallest first, measured in the category's normal direction (so `desc` is the biggest spend first under an expense category). Defaults to `desc` for merchants. Months without `order` are in calendar order. Ties are listed by merchant name, or by month oldest first. A merchant whose refunds exceeded its charges under an expense category comes last with `desc`."
          schema:
            type: string
            enum:
              - asc
              - desc
        - name: limit
          in: query
          required: false
          description: Number of rows to return, from 1 to 500, taken from the start of whichever order applies. Defaults to 100 for merchants; months return every month unless it is given.
          schema:
            type: integer
            minimum: 1
            maximum: 500
        - name: starting_after
          in: query
          required: false
          description: The `next_cursor` value from the previous response, unchanged. Use it with the same request parameters; a cursor from a different request is rejected. Pages are not a frozen snapshot, so rows can shift if data changes between requests.
          schema:
            type: string
      responses:
        "200":
          description: The category total, grouped
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/TotalList"
              examples:
                merchants:
                  summary: Top merchants by spend
                  value:
                    object: list
                    url: /api/v1/aggregations/totals
                    group_by: merchant
                    order: desc
                    category_id: cat_o5p6q7r8s9t0u1
                    merchant: null
                    start_date: "2026-01-01"
                    end_date: "2026-09-30"
                    date: null
                    point_in_time: false
                    currency: USD
                    refreshed_at: 1786992120
                    has_more: true
                    next_cursor: WyJtZXJjaGFudCIsImRlc2MiLDE4MjM0MCwiQVdTIl0
                    data:
                      - object: merchant_total
                        merchant: Gusto
                        total_cents: -4825000
                        debit_cents: 4825000
                        credit_cents: 0
                      - object: merchant_total
                        merchant: AWS
                        total_cents: -182340
                        debit_cents: 190340
                        credit_cents: 8000
                months:
                  summary: Monthly totals
                  value:
                    object: list
                    url: /api/v1/aggregations/totals
                    group_by: month
                    order: null
                    category_id: cat_o5p6q7r8s9t0u1
                    merchant: AWS
                    start_date: "2026-01-01"
                    end_date: "2026-03-31"
                    date: null
                    point_in_time: false
                    currency: USD
                    refreshed_at: 1786992120
                    has_more: false
                    next_cursor: null
                    data:
                      - object: month_total
                        month: "2026-01"
                        total_cents: -60780
                        debit_cents: 60780
                        credit_cents: 0
                      - object: month_total
                        month: "2026-02"
                        total_cents: 0
                        debit_cents: 0
                        credit_cents: 0
                      - object: month_total
                        month: "2026-03"
                        total_cents: -121560
                        debit_cents: 121560
                        credit_cents: 0
        "400":
          description: >-
            A required parameter is missing, a date is invalid, the request uses parameters for the
            wrong category type or grouping, the month range is longer than 120 months, or the cursor
            is invalid or came from a different request
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The category ID is malformed, unknown, unavailable through the API, or belongs to another company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  "/accounts":
    get:
      operationId: listAccounts
      summary: List accounts
      tags:
      - Transactions
      description: |
        Lists the authenticated company's locally known transaction-source accounts using stable,
        opaque acct_ IDs, including accounts without transactions and retained accounts whose
        provider records were deleted. Includes bank, credit, and synthetic sources; these are not
        chart-of-accounts categories. Inventory does not imply current connectivity or complete
        remote-provider coverage. Requires API access and Transactions: View.

        Results are ordered by identity creation order (oldest first), independent of labels.
        Pagination is not a frozen snapshot. An empty inventory returns data: [], has_more: false,
        and next_cursor: null. There are no account filters or request body.

        Labels and source types use synchronized metadata; retained accounts expose their last
        known values. GET never backfills identities or changes bookkeeping. Standard API usage
        logging, subscription checks, rate limits, and usage limits still apply. Missing identities
        require operational backfill/readiness before discovery is enabled.
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 500
          default: 100
      - name: starting_after
        in: query
        schema:
          type: string
        description: Pass the previous next_cursor unchanged and URL-encode it. Do not pass an account ID. Cursors are bound to the authenticated company and account collection. Malformed and foreign cursors return the same generic 400 invalid_request error.
      responses:
        '200':
          description: Account inventory.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceAccountList"
        '400':
          description: Invalid input or cursor.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '429':
          description: Rate or usage limit exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    post:
      operationId: createAccount
      summary: Create account
      tags:
      - Transactions
      description: |
        Creates a manual bank or credit account that transactions can be uploaded to. Returns
        `201` with the new account, or `200` with an existing manual account of the same `type`
        whose `vendor` and `account_name` match (ignoring case and whitespace), so retrying is
        safe. Requires API access and Transactions: Manage.

        Accounts that sync from an integration are never matched: posting `vendor: "Mercury"`
        creates a manual account even if Mercury is connected. Check `GET /accounts` first.
        Creating an account adds no category or transactions.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/SourceAccountCreate"
            example:
              account_name: Chase Checking 4821
              vendor: chase
              type: bank
      responses:
        '200':
          description: A matching manual account already existed and was returned unchanged.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceAccount"
        '201':
          description: The account was created.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceAccount"
        '400':
          description: Invalid JSON, an unknown field, or a missing or invalid field. `param` names the field.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '429':
          description: Rate or usage limit exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '500':
          description: The account could not be saved (`account_create_failed`). Retrying is safe.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  "/transaction_uploads":
    post:
      operationId: submitTransactionUpload
      summary: Submit transaction upload
      tags:
      - Transactions
      description: |
        Adds bank or credit card transactions, parsed by the caller from a file, to one account.
        Requires API access and Transactions: Manage.

        The first call opens a hidden draft; pass its `id` as `upload_id` on later calls. Send the
        rows in chunks of up to 500 (2,000 per upload), including rows you leave out with `exclude`.
        Each call returns validation for every row received so far. `commit: true` imports the
        upload once `ready_to_commit` is true, waiting up to about 20 seconds for categorization;
        otherwise nothing is imported. Exact copies of existing transactions are skipped. Posting
        only `upload_id` returns the current state. Only the person who started an upload can
        continue it.

        Uploading doesn't match transfers. After committing, pair a transfer to or from another account
        in Finta with `POST /transfers`, unless its transactions already have a `transfer_id`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/TransactionUploadRequest"
            example:
              account_id: acct_2Fk8Qz1LmN0pRs
              file_totals:
                row_count: 3
                net_amount_cents: -6499
              columns: [row, date, description, amount]
              rows:
              - [1, "08/13/2026", "BLUE BOTTLE COFFEE", "-45.00"]
              - [2, "08/14/2026", "FIGMA", "-19.99"]
              - [3, "08/20/2026", "STRIPE TRANSFER", "0.00"]
              exclude:
              - {row: 3, reason: not_a_transaction}
              commit: true
      responses:
        '201':
          description: This call opened the upload.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TransactionUpload"
        '200':
          description: The upload after this call.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/TransactionUpload"
        '400':
          description: Malformed request. Nothing is saved.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: Unknown `account_id`, or no upload for `upload_id` started by this key's owner.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '409':
          description: The upload is already committed.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '503':
          description: The import could not start. Nothing was imported and the draft is kept; retry the commit with the `upload_id` named in the message, which a first call that commits has not otherwise returned. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"

  "/forms/1099":
    get:
      operationId: list1099s
      summary: List 1099s
      tags: [Forms]
      description: |
        Lists the authenticated company's locally tracked vendor 1099 filing records, including
        records awaiting confirmation and records marked not required. Each record exists before
        a PDF is prepared. Requires API access: Manage and Taxes: View. Standard authentication,
        live membership permissions, rate limits, monthly limits, and usage logging apply.

        Without year, returns a page across all available years and summaries for every existing
        annual report, newest year first. With year, returns only that year's records and one
        summary. Existing reports with zero required records have summary filing_status
        not_required, matching the UI's tracked-record count; this is not an independent legal
        assessment or guarantee that all transactions have been imported. A requested year without
        a report has summary filing_status unknown, zero tracked counts, and data []. Without
        year and without reports, both summaries and data are []. No year defaults implicitly.

        Summaries depend only on year, not vendor, filing_status, pending, filed_elsewhere, limit,
        or starting_after. They cover the entire selected annual report(s), never just the current
        page. Other filters affect data only. All filters combine with AND, except comma-separated
        filing_status values combine with OR. No default hides not_required records.

        Records are ordered by year descending, then stable record creation ID descending.
        Paging is not a frozen snapshot. Cursors are bound to the authenticated company and
        normalized record filters; use the same filters on each page. A changed limit is allowed.
        Invalid filters or malformed, foreign, or differently scoped cursors return 400
        invalid_request. Empty results have has_more false and next_cursor null.

        Vendor names are display names, not unique identities. The current workflow tracks
        merchant/category groups, so the same vendor name can appear in multiple records for
        the same year. Counts describe tracked filing records, not deduplicated legal recipients.
        Individual records can be retrieved directly by their f1099_ IDs without a report ID.

        GET never generates reports, refreshes vendors, backfills IDs, files forms, or sends
        W-9 requests. Historical public IDs must be backfilled before release. A selected annual
        report with a missing or out-of-range reporting year (outside 1000–9999), missing
        identities, or unsupported workflow states returns 503 forms_not_ready, even when
        the report has no vendor records;
        retry later. Available files have signed download links valid for five minutes; retrieve
        the record again to refresh expired links. Filing status does not guarantee PDF availability.
      parameters:
      - name: year
        in: query
        description: Exact four-digit reporting year, not the year of submission. Omit to discover all available years.
        schema: { type: integer, minimum: 1000, maximum: 9999 }
      - name: filing_status
        in: query
        description: One or more public record statuses separated by commas. Values are review_required, w9_required, w9_requested, ready_to_file, filing, filed, and not_required. Values combine with OR. pending is not an individual status; use the pending filter instead.
        schema: { type: string }
        example: w9_required,w9_requested
      - name: pending
        in: query
        description: Strict true or false. True includes w9_required, w9_requested, ready_to_file, and filing. False includes all other statuses, including unconfirmed and not-required records.
        schema: { type: boolean }
      - name: vendor_name
        in: query
        description: Case-insensitive exact vendor display-name match. Surrounding whitespace is ignored on both the query and stored name, so a returned vendor_name can be used directly as a filter. Returned display names are not rewritten. Blank names are invalid; names are not unique. No substring or fuzzy matching.
        schema: { type: string, minLength: 1 }
      - name: filed_elsewhere
        in: query
        description: Strict true or false. True selects filed records reported as completed outside Finta. False includes all other records, including pending records.
        schema: { type: boolean }
      - name: limit
        in: query
        schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
      - name: starting_after
        in: query
        description: Opaque next_cursor from the previous response, passed unchanged and URL-encoded. Do not pass a form ID. Keep the same normalized filters on subsequent pages.
        schema: { type: string }
      responses:
        '200':
          description: A page of tracked 1099 records with complete yearly summaries.
          content:
            application/json:
              schema: { "$ref": "#/components/schemas/Form1099List" }
        '400':
          description: Invalid filter or cursor. error.param identifies the invalid parameter.
          content:
            application/json:
              schema: { "$ref": "#/components/schemas/Error" }
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '429':
          "$ref": "#/components/responses/RateLimited"
        '503':
          description: forms_not_ready. Selected reports require year/workflow data repair or record identity backfill, including empty reports with invalid years. GET does not repair them. Retry later. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema: { "$ref": "#/components/schemas/Error" }

  "/forms/1099/{id}":
    get:
      operationId: retrieve1099
      summary: Retrieve 1099
      tags: [Forms]
      description: |
        Retrieves one tracked vendor 1099 filing record, with the same fields as List 1099s
        and fresh five-minute signed document links. Requires API access: Manage and Taxes:
        View; permissions are checked before ID validation. Unknown and foreign-company IDs
        both return 404 resource_missing. Numeric database IDs and malformed public IDs return
        400 invalid_request. GET does not generate, refresh, file, or otherwise change records.
        A record may exist before preparation and without any PDF; use filing_status for progress.
        See List 1099s for workflow and vendor-grouping semantics. Standard authentication,
        rate limits, monthly limits, and usage logging apply.
      parameters:
      - name: id
        in: path
        required: true
        description: Stable public record ID from List 1099s. No report ID or year is needed.
        schema: { type: string, pattern: '^f1099_[A-Za-z0-9]+$' }
      responses:
        '200':
          description: The tracked filing record.
          content:
            application/json:
              schema: { "$ref": "#/components/schemas/Form1099" }
        '400':
          description: Invalid ID format, with error.param id.
          content:
            application/json:
              schema: { "$ref": "#/components/schemas/Error" }
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: resource_missing. The ID is absent or belongs to another company.
          content:
            application/json:
              schema: { "$ref": "#/components/schemas/Error" }
        '429':
          "$ref": "#/components/responses/RateLimited"
        '503':
          description: forms_not_ready. The record's workflow state cannot be represented. Retry later. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema: { "$ref": "#/components/schemas/Error" }

  "/cards":
    get:
      operationId: listCards
      summary: List cards
      tags:
      - Transactions
      description: |
        Lists the authenticated company's locally known individual cards using stable, opaque
        card_ IDs. Includes inactive cards, cards without booked transactions, and retained
        identities after provider deletion. Dedicated inventory covers Brex, Ramp, Mercury,
        and Slash. Rho cards are known only from stored provider transaction metadata; there
        is no provider-wide Rho card inventory fetch. Plaid credit sources appear as accounts,
        not individual cards. Inventory is not a connectivity or remote-completeness guarantee.

        Requires API access and Transactions: View. Standard authentication, current membership,
        subscription restrictions, rate limits, usage limits, and usage logging apply.
        GET never creates or backfills identities or changes bookkeeping; production identity
        backfill/readiness is a separate release prerequisite.

        Results use identity creation order (oldest first), independent of labels, with account
        filtering before pagination. Paging is not a frozen snapshot. An empty result returns
        data: [], has_more: false, and next_cursor: null. There is no request body.
      parameters:
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 500
          default: 100
      - name: starting_after
        in: query
        schema:
          type: string
        description: Pass the previous next_cursor unchanged and URL-encode it; do not pass a card ID. Cursors are bound to company, card collection, and the exact account_id filter. Keep the same filter on every page. Malformed, foreign, or differently scoped cursors return 400 invalid_request.
      - name: account_id
        in: query
        schema:
          type: string
          pattern: "^acct_"
        description: Filter by a stable account ID from List accounts. Only proven provider relationships match; names and vendors do not establish relationships. Unknown, malformed, blank, and foreign account IDs return the same 404 resource_missing error with param account_id. Known accounts without linked cards return an empty list.
      responses:
        '200':
          description: Card inventory.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/SourceCardList"
        '400':
          description: Invalid input or cursor.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: Resource unavailable.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Rate or usage limit exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  "/transactions/bulk/update":
    post:
      operationId: bulkUpdateTransactions
      summary: Bulk update transactions
      tags:
      - Transactions
      description: |-
        Accepts one asynchronous all-or-nothing operation for at most 5,000 transactions. Supply exactly one non-empty selector and a non-empty updates object. Filters combine with AND and select pre-update values. Split parents and all split-derived records are excluded from filters; explicit split IDs fail the job. Transfer eligibility follows existing per-field rules. No matches succeeds with zero updates. Targets freeze at processing start; relevant later edits fail the job. accounting_shift moves each effective accounting date by calendar months, clamping once to the destination month. Omission leaves a field unchanged; null clears department/spread or restores the original accounting date. Requires current API access: Manage and Transactions: Manage, an active actor and company membership, and an eligible company subscription/lifecycle. Those permissions are checked again during execution. No identities are created by submission.

        Idempotency keys are scoped to company and submitting user. Identical normalized bodies replay the original queued 202 response; a changed body returns 409. Object key order is ignored; array order is preserved; null and omission differ. Key strings are case-sensitive and are not trimmed or normalized. Whitespace-only keys are rejected. The operation, company and actor define the key scope; a different API key for the same actor/company shares that scope. Keys and results remain for 30 days after terminal completion; active jobs do not expire. After expiry, a new submission can apply another shift. Retrieve the known job to learn its current outcome. The 202 body always contains the stored queued acceptance (matched_count null, updated_count 0, empty errors), even if execution already completed before the response or replay. No response does not imply rejection: retry with the same key and identical body while retained. After expiry the same key creates new work and can apply a relative shift again.

        The only public filters are merchant, category_id, account_id, card_id, start_date, end_date, status, transaction_type, categorized, no_department and department_id. Unknown fields, empty selectors, invalid IDs/dates/enums, duplicate IDs, malformed types, inverted date ranges, and simultaneous selectors are rejected with 400 invalid_request. Explicit selection permits 1–5,000 unique txn_ IDs; 5,001 are rejected before acceptance. Filter selection is asynchronous, unpaginated and fails atomically above 5,000 matches. Unknown or inaccessible account/card/department references receive the same 404 resource_missing, except that a company without departments gets 403 feature_disabled or plan_upgrade_required for any department filter or update, including a null department update (see "Early access endpoints" under Errors); an incompatible known account/card pair is 400 invalid_request. Missing/foreign transaction IDs and business eligibility errors are job failures discovered during execution.

        Success and replay consume API requests, as does polling. Existing per-minute and monthly limits apply. On 429 rate_limit_exceeded, wait Retry-After then retry the identical body/key with backoff. On monthly_limit_exceeded, stop short-term retries; no Retry-After is sent. Fix 400, 401 or 403 before retrying. A 409 idempotency_key_conflict means the key already identifies a different body; poll the original job rather than changing the key to guess whether work ran.
      parameters:
      - name: Idempotency-Key
        in: header
        required: true
        description: Nonblank string of 1–255 characters. Whitespace-only and oversized keys return 400 invalid_request with param Idempotency-Key.
        schema:
          type: string
          minLength: 1
          maxLength: 255
          pattern: '\S'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/BulkTransactionRequest"
            example:
              filters:
                merchant: Cursor
              updates:
                merchant: Cursor AI
      responses:
        '202':
          description: Original queued acceptance, including on replay after success or failure. Retrieve Location for the current outcome.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BulkJob"
          headers:
            Location:
              schema:
                type: string
              description: Job retrieval path.
        '409':
          description: idempotency_key_conflict; this company/actor/operation key was already accepted with a different normalized body.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '400':
          description: invalid_request; non-application/json Content-Type, malformed JSON, missing/blank/oversized Idempotency-Key or invalid request shape, selector, filter or update. Malformed JSON uses the same JSON Error envelope. No job is accepted.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: Resource unavailable.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
  "/jobs/{id}":
    get:
      operationId: retrieveJob
      summary: Retrieve job
      tags:
      - Transactions
      description: 'Returns whole-job status and counts with a page of stable error
        details. updated_count counts distinct transactions with actual committed changes.
        A failed job returns HTTP 200 with zero updates and actionable errors. Success
        means edits and durable refresh scheduling committed; derived reports may refresh
        afterward. Requires current Transactions: Manage access to the company. Results
        and every error page expire 30 days after terminal completion. Unknown, inaccessible
        and expired jobs all return 404. Cursors are job-scoped; invalid or foreign
        cursors return 400. Any currently authorized manager in the company can retrieve
        a job; it is not limited to the submitting actor. Active jobs do not expire.
        Polling consumes API calls and uses existing rate and monthly limits. Back off
        between polls and respect Retry-After on rate-limit responses. Submit work with
        POST /transactions/bulk/update and use the returned Location to retrieve its current outcome.'
      parameters:
      - name: id
        in: path
        required: true
        schema:
          type: string
          pattern: "^job_"
      - name: limit
        in: query
        schema:
          type: integer
          minimum: 1
          maximum: 500
          default: 100
      - name: starting_after
        in: query
        schema:
          type: string
        description: Pass the previous next_cursor unchanged for this same job.
      responses:
        '200':
          description: Current job outcome with paginated errors.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/BulkJob"
        '400':
          description: Invalid input or cursor.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '404':
          description: Resource unavailable.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '429':
          description: Rate or usage limit exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /transactions:
    get:
      operationId: listTransactions
      summary: List transactions
      description: |
        Returns a paginated list of transactions for the authenticated company, ordered
        by date descending.

        Transactions are raw bank/credit card activity. For accounting-level detail
        (double-entry debits and credits), see journal entries instead.

        ## Keeping a local copy in sync

        If you need to mirror this company's transactions and pick up changes over time,
        use the `updated_after` parameter. It returns only transactions modified since your
        last sync, including deletions, so you don't have to refetch everything. See that
        parameter for how to start and continue a sync.

        For one-off questions ("what did we spend on AWS last month?"), leave out
        `updated_after` and use the regular filters.
      tags:
        - Transactions
      parameters:
        - name: limit
          in: query
          description: Maximum number of results to return (1-500). Defaults to 100.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: starting_after
          in: query
          description: A cursor for pagination. Pass the `next_cursor` value from a previous response to fetch the next page.
          schema:
            type: string
        - name: status
          in: query
          description: Filter by approval status. If omitted, returns both approved and pending transactions.
          schema:
            type: string
            enum:
              - approved
              - pending
        - name: category_id
          in: query
          description: Filter by assigned category prefixed ID.
          schema:
            type: string
            pattern: "^cat_"
        - name: transaction_type
          in: query
          description: Filter by public transaction type.
          schema:
            type: string
            enum:
              - standard
              - transfer
              - split
        - name: categorized
          in: query
          description: Filter by whether the transaction has been categorized.
          schema:
            type: boolean
        - name: accounting_date_overridden
          in: query
          description: >-
            `true` returns only transactions whose `accounting_date` differs from their `date`; `false`
            returns only those where the two match or `accounting_date` is `null`, which this filter treats
            as equal to `date`. An accounting date moves off the transaction date when a user changes it,
            a rule offsets it, or an invoice match sets it to the invoice date. `start_date` and `end_date`
            filter on `date`, not `accounting_date`.
          schema:
            type: boolean
        - name: no_department
          in: query
          description: >-
            `true` returns only transactions that need a department but don't have one, matching the No
            Department transactions page: approved expenses whose `accounting_date` is on or after the
            company's department cutoff. Revenue, transfers, taxes, gain or loss, hosting and compute costs,
            and transactions split by department never need one and are not returned. Only `true` is
            accepted. Totals won't equal the income statement's No Department group, which also includes
            journal entries and amounts before the cutoff. A company without departments gets 403
            `feature_disabled` or `plan_upgrade_required`. See "Early access endpoints" under Errors.
          schema:
            type: boolean
            enum: [true]
        - name: department_id
          in: query
          description: >-
            Filter by a department returned by `GET /departments`. Matches that department only, not its
            sub-departments, as on `GET /journal_entries`. An unknown department returns 404 `resource_missing`.
            A company without departments gets 403 `feature_disabled` or `plan_upgrade_required` instead.
            See "Early access endpoints" under Errors.
          schema:
            type: string
            pattern: '^dept_[A-Za-z0-9]+$'
          example: dept_b2c3d4e5f6g7h8
        - name: tag_id
          in: query
          description: Exact tag_ ID from GET /tags. Unknown or inaccessible tags return 404 resource_missing.
          schema:
            type: string
            pattern: '^tag_'
        - name: merchant
          in: query
          description: Case-insensitive text search on merchant name.
          schema:
            type: string
        - name: source_vendor
          in: query
          description: Case-insensitive exact match on the integration or vendor name.
          schema:
            type: string
        - name: source_type
          in: query
          description: Case-insensitive exact match on the source account type.
          schema:
            type: string
            enum:
              - bank
              - credit
              - reimbursement
        - name: amount_cents
          in: query
          description: >-
            Only transactions of exactly this amount, in signed integer cents like `amount_cents` in the
            response: negative for money out, positive for money in. A $35.27 charge is `-3527`; a $4,500.00
            deposit is `450000`. Must be at most 100000000000000 in absolute value.
          schema:
            type: integer
            example: -3527
        - name: start_date
          in: query
          description: Return transactions on or after this date (YYYY-MM-DD). If omitted, no lower bound.
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          description: Return transactions on or before this date (YYYY-MM-DD). If omitted, no upper bound.
          schema:
            type: string
            format: date
        - name: updated_after
          in: query
          description: >
            Return only transactions modified after this Unix timestamp (seconds, not milliseconds), for keeping a local
            copy in sync without refetching everything. Supplying it changes the response in three ways:
            results are ordered oldest-modified first instead of by date, `next_cursor` becomes an opaque
            token rather than a transaction id, and deleted transactions are included with `deleted: true`
            so you can remove them from your copy. The response also carries a `watermark` — pass that
            value back as `updated_after` on your next sync rather than using your own clock. Page through
            until `has_more` is false, then store the watermark. Note this filters on when a transaction was
            last modified, which is unrelated to `start_date`/`end_date`, which filter on its date.
            Renaming a category, department, or tag does not modify its transactions; store their IDs and refresh names from the list endpoints.
            Start a new mirror with `updated_after=0`, which matches every transaction: the initial load and
            every later sync then share one ordering and one cursor, and the watermark it returns joins them
            with no gap. Do not seed a mirror from the list without `updated_after` — it returns no watermark,
            leaving you to invent a starting point from your own clock, which is the drift this parameter
            exists to avoid. A sync from `0` also returns transactions deleted before you started, carrying
            `deleted: true`; you have no copy to remove, so skip them.
            Filters may be combined with `updated_after` and are applied normally, but know what you are
            getting. Filters match a transaction's *current* state, so a transaction that changes such that
            it no longer matches simply stops appearing — with no `deleted` tombstone, because it was not
            deleted. `deleted: true` is reported only for genuinely deleted transactions. If you are keeping
            a persistent mirror, that means your copy can only grow: rows enter correctly and never leave,
            and the drift is silent. Sync with `updated_after` alone and filter your own side. If you are
            answering a question rather than maintaining a copy, or filtering on something that cannot
            change (`source_vendor`, `source_type`), the combination is safe.
          schema:
            type: integer
            example: 1771183600
      responses:
        "200":
          description: A paginated list of transactions
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    description: The canonical path of the collection, relative to the API host. Useful for logging, debugging, and generic pagination utilities that do not need to be aware of the specific endpoint.
                    example: /api/v1/transactions
                  has_more:
                    type: boolean
                    description: Whether there are more results beyond this page.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass this value as `starting_after` to fetch the next page. Null when `has_more` is false.
                  watermark:
                    type: integer
                    description: Only present when `updated_after` was supplied. The server-generated Unix timestamp to pass as `updated_after` on your next sync. It is identical on every page of one sync; store it once you receive a page with `has_more` false.
                    example: 1771183600
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Transaction"
              example:
                object: list
                url: /api/v1/transactions
                has_more: true
                next_cursor: txn_h8i9j0k1l2m3n4
                data:
                  - id: txn_a1b2c3d4e5f6g7
                    object: transaction
                    date: "2026-02-15"
                    accounting_date: "2026-02-15"
                    amount_cents: -49900
                    currency: USD
                    merchant: AWS
                    description: null
                    categorized: true
                    category_id: cat_v5e6f7g8h9i0j1
                    category_name: Software
                    department_id: dept_d4e5f6g7h8i9j0
                    department_name: Engineering
                    transaction_type: standard
                    source:
                      vendor: mercury
                      type: bank
                      account_name: Mercury Checking
                    status: approved
                    spread: null
                    transfer_id: null
                    created: 1771183600
                  - id: txn_h8i9j0k1l2m3n4
                    object: transaction
                    date: "2026-02-14"
                    accounting_date: "2026-02-14"
                    amount_cents: -85000
                    currency: USD
                    merchant: WeWork
                    description: null
                    categorized: true
                    category_id: cat_c9d0e1f2g3h4i5
                    category_name: Office
                    department_id: null
                    department_name: null
                    transaction_type: standard
                    source:
                      vendor: mercury
                      type: bank
                      account_name: Mercury Checking
                    status: approved
                    spread: null
                    transfer_id: null
                    created: 1771027200
        "400":
          description: A supplied transaction filter is invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >-
            The pagination cursor was not found, or a `department_id` or `tag_id` filter value is unknown or inaccessible. Returns `resource_missing`. A company without departments gets 403 for any department filter instead.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /transactions/splits:
    get:
      operationId: listSplits
      summary: List splits
      description: |
        Returns the lines of split transactions, newest transaction date first.

        A split divides one transaction's amount into lines, each with its own amount,
        category, department and spread. `GET /transactions` lists the split transaction
        once, at its full amount, with `transaction_type: split`; its lines are listed here.
        Each line's `transaction_id` is that split transaction, and a split's lines add up
        to its amount. Pass `transaction_id` to list one split's lines.

        Only splits by category have lines. A department split or a payroll bulk split
        divides a transaction differently and is not listed.

        Requires API access and Transactions: View.
      tags:
        - Transactions
      parameters:
        - name: limit
          in: query
          description: Maximum number of results to return (1-500). Defaults to 100.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: starting_after
          in: query
          description: Pass the `next_cursor` from the previous page, unchanged, with the same filters. Rows in this list have no ID, so the cursor is opaque. A cursor this list didn't produce returns `400 invalid_request`.
          schema:
            type: string
        - name: start_date
          in: query
          description: Only rows whose transaction `date` is on or after this date, YYYY-MM-DD.
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          description: Only rows whose transaction `date` is on or before this date, YYYY-MM-DD.
          schema:
            type: string
            format: date
        - name: transaction_id
          in: query
          description: Only the lines of this split transaction. A transaction that isn't split has no lines, so it returns an empty list. An unknown ID returns `404 resource_missing`.
          schema:
            type: string
            pattern: "^txn_"
        - name: spread
          in: query
          description: "`true` returns only lines with a spread, `false` only lines without one."
          schema:
            type: boolean
      responses:
        "200":
          description: A paginated list
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - next_cursor
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    example: /api/v1/transactions/splits
                  has_more:
                    type: boolean
                    description: Whether there are more results beyond this page.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass this value as `starting_after` to fetch the next page. Null when `has_more` is false.
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TransactionSplit"
              example:
                object: list
                url: /api/v1/transactions/splits
                has_more: false
                next_cursor: null
                data:
                  - object: transaction_split
                    transaction_id: txn_a1b2c3d4e5f6g7
                    date: "2025-05-28"
                    accounting_date: "2025-05-28"
                    amount_cents: -120
                    currency: USD
                    merchant: Figma
                    category_id: cat_v5e6f7g8h9i0j1
                    category_name: Software
                    department_id: null
                    department_name: null
                    spread: 12
                  - object: transaction_split
                    transaction_id: txn_a1b2c3d4e5f6g7
                    date: "2025-05-28"
                    accounting_date: "2025-05-28"
                    amount_cents: -80
                    currency: USD
                    merchant: AssuredPartners
                    category_id: cat_k1l2m3n4o5p6q7
                    category_name: Insurance
                    department_id: null
                    department_name: null
                    spread: null
        "400":
          description: A filter, date, or cursor is invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The transaction in `transaction_id` was not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /transactions/{id}:
    get:
      operationId: retrieveTransaction
      summary: Retrieve transaction
      description: Returns one transaction available to the authenticated company.
      tags:
        - Transactions
      parameters:
        - $ref: "#/components/parameters/TransactionId"
      responses:
        "200":
          description: The requested transaction
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Transaction"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The transaction ID is malformed, unknown, unavailable through the API, or belongs to another company
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    patch:
      operationId: updateTransaction
      summary: Update transaction
      description: |
        Updates one or more dashboard-editable fields. Send any non-empty subset of
        `merchant`, `category_id`, `department_id`, `tag_id`, `accounting_date`, and `spread`.

        All requested fields are applied atomically in a stable server-defined order;
        JSON object key order has no effect. If any change fails, none are applied.

        Set `department_id`, `tag_id`, `accounting_date`, or `spread` to null to remove the
        department/tag assignment, accounting-date override, or spread. Accounting-date removal
        restores the transaction date. A resulting accounting date cannot precede
        the company's incorporation date. When that date is unavailable, the lower
        bound is two years before the server's current date. The date must also be
        earlier than three years after the server's current date. Transaction
        eligibility rules still apply.

        A missing or inaccessible tag, or a split part's ID, returns 404 resource_missing.
        A tag conflict returns 503 tag_busy; retry the same request.
      tags:
        - Transactions
      parameters:
        - $ref: "#/components/parameters/TransactionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              additionalProperties: false
              properties:
                merchant:
                  type: string
                  minLength: 1
                  maxLength: 40
                  description: New merchant or payee name.
                  example: Amazon Web Services
                category_id:
                  type: string
                  description: Selectable category ID with `cat_` prefix.
                  pattern: "^cat_"
                  example: cat_v5e6f7g8h9i0j1
                department_id:
                  type: string
                  nullable: true
                  description: Assignable department ID with `dept_` prefix, or null to remove the department. A company without departments gets 403 for either. See "Early access endpoints" under Errors.
                  pattern: "^dept_"
                  example: dept_d4e5f6g7h8i9j0
                tag_id:
                  type: string
                  nullable: true
                  pattern: '^tag_'
                  description: A tag_ ID from GET /tags, or null to remove the tag.
                accounting_date:
                  type: string
                  format: date
                  nullable: true
                  description: |
                    Accounting date override, or null to restore the transaction date. The resulting
                    date must be on or after the company's incorporation date, falling back to two years
                    before the server's current date when incorporation date is unavailable. It must be
                    earlier than three years after the server's current date.
                  example: "2026-02-28"
                spread:
                  type: integer
                  minimum: 1
                  nullable: true
                  description: Number of months to spread the transaction over, or null to remove its spread.
                  example: 12
            example:
              merchant: Amazon Web Services
              accounting_date: "2026-02-28"
      responses:
        "200":
          description: The updated transaction
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Transaction"
              example:
                id: txn_a1b2c3d4e5f6g7
                object: transaction
                date: "2026-02-15"
                accounting_date: "2026-02-15"
                amount_cents: -49900
                currency: USD
                merchant: AWS
                description: null
                categorized: true
                category_id: cat_v5e6f7g8h9i0j1
                category_name: Software
                department_id: dept_d4e5f6g7h8i9j0
                department_name: Engineering
                transaction_type: standard
                source:
                  vendor: mercury
                  type: bank
                  account_name: Mercury Checking
                status: approved
                spread: null
                transfer_id: null
                created: 1771183600
        "400":
          description: The request contains no supported fields, an unknown field, or an invalid field value
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  type: invalid_request_error
                  code: parameter_missing
                  message: "Provide at least one of: merchant, category id, department id, accounting date, and spread."
                  doc_url: https://www.finta.com/docs/api-reference/errors#parameter_missing
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >-
            The transaction, category, department, or tag was not found, was unavailable through the API, or had the
            wrong ID prefix. Split part IDs return resource_missing with param id.
            The Matched Transfer category can't be set here; the message points to `POST /transfers`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalid_transaction_id:
                  summary: Transaction ID has the wrong prefix
                  value:
                    error:
                      type: invalid_request_error
                      code: resource_missing
                      message: id must be a transaction ID starting with txn_.
                      doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing
                      param: id
                transaction_missing:
                  summary: Transaction not found
                  value:
                    error:
                      type: invalid_request_error
                      code: resource_missing
                      message: No transaction found for id.
                      doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing
                      param: id
                invalid_category_id:
                  summary: Category ID has the wrong prefix
                  value:
                    error:
                      type: invalid_request_error
                      code: resource_missing
                      message: category_id must be a category ID starting with cat_.
                      doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing
                      param: category_id
                category_missing:
                  summary: Category is missing or not available to the company
                  value:
                    error:
                      type: invalid_request_error
                      code: resource_missing
                      message: No category found for category_id.
                      doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing
                      param: category_id
                invalid_department_id:
                  summary: Department ID has the wrong prefix
                  value:
                    error:
                      type: invalid_request_error
                      code: resource_missing
                      message: department_id must be a department ID starting with dept_.
                      doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing
                      param: department_id
                unsupported_category_change:
                  summary: The transaction cannot be categorized through this endpoint
                  value:
                    error:
                      type: invalid_request_error
                      code: resource_missing
                      message: The requested transaction cannot be categorized through this endpoint.
                      doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing
                      param: id
        "503":
          description: tag_busy; no fields changed. Retry the same request. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: A requested change was rejected by bookkeeping eligibility or validation rules; no fields were updated
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                ineligible_transaction:
                  summary: The transaction does not support accounting-date edits
                  value:
                    error:
                      type: invalid_request_error
                      code: transaction_update_failed
                      message: Accounting date can't be changed for transfer transactions.
                      doc_url: https://www.finta.com/docs/api-reference/errors#transaction_update_failed
                      param: accounting_date
                accounting_date_out_of_bounds:
                  summary: The accounting date precedes the company's incorporation date
                  value:
                    error:
                      type: invalid_request_error
                      code: transaction_update_failed
                      message: Accounting date can't be before the company's incorporation date (03/15/2024)
                      doc_url: https://www.finta.com/docs/api-reference/errors#transaction_update_failed
                      param: accounting_date
        "429":
          $ref: "#/components/responses/RateLimited"

  /transactions/{id}/split:
    post:
      operationId: splitTransaction
      summary: Split transaction
      description: |
        Splits one transaction into lines, the same as "Split transaction" in the Finta app. Each
        line has its own amount, category, merchant, department and spread, and the lines' signed
        `amount_cents` must add up exactly to the transaction's. The transaction then has
        `transaction_type: split`, the Split category and `status: approved`, and
        `GET /transactions/splits` lists its lines.

        A line's `merchant` defaults to the transaction's. Leave out a line's `department_id` to give
        it the transaction's department, or send null for none. Only lines in a category that can
        have a department, such as an expense, take one: an inherited department is dropped from any
        other line, and sending `department_id` for one is refused. Income and expense lines can be
        spread and can have their own `accounting_date`, which defaults to the transaction's accounting
        date; other lines are always booked on the transaction's `date`.

        Only a transaction in USD that isn't split already can be split. A transaction that paid an
        invoice, a matched transfer, a spread transaction (spread its lines instead), a capitalized
        purchase, and one in a department split or a payroll split are refused, because splitting
        would double count or undo them. Requires Transactions: Manage.
      tags:
        - Transactions
      parameters:
        - $ref: "#/components/parameters/TransactionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TransactionSplitRequest"
            example:
              lines:
                - amount_cents: -60000
                  category_id: cat_v5e6f7g8h9i0j1
                  merchant: Figma
                  spread: 12
                - amount_cents: -40000
                  category_id: cat_k1l2m3n4o5p6q7
                  department_id: null
      responses:
        "201":
          description: >-
            The split's lines, in the order they were sent: the same list `GET /transactions/splits`
            returns for this transaction.
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - next_cursor
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    example: /api/v1/transactions/splits
                  has_more:
                    type: boolean
                    description: Always false. Every line is returned.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Always null.
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TransactionSplit"
              example:
                object: list
                url: /api/v1/transactions/splits
                has_more: false
                next_cursor: null
                data:
                  - object: transaction_split
                    transaction_id: txn_a1b2c3d4e5f6g7
                    date: "2026-02-15"
                    accounting_date: "2026-02-15"
                    amount_cents: -60000
                    currency: USD
                    merchant: Figma
                    category_id: cat_v5e6f7g8h9i0j1
                    category_name: Software
                    department_id: dept_d4e5f6g7h8i9j0
                    department_name: Engineering
                    spread: 12
                  - object: transaction_split
                    transaction_id: txn_a1b2c3d4e5f6g7
                    date: "2026-02-15"
                    accounting_date: "2026-02-15"
                    amount_cents: -40000
                    currency: USD
                    merchant: AWS
                    category_id: cat_k1l2m3n4o5p6q7
                    category_name: Hosting
                    department_id: null
                    department_name: null
                    spread: null
        "400":
          description: >-
            The body isn't a JSON object with 2 to 100 valid lines (invalid_request, parameter_missing), a line
            can't have its spread or department, such as a spread on a balance sheet category or a department
            with sub-departments, or the lines don't add up to the transaction's amount (invalid_request).
            `param` names the field, such as `lines[1].spread`, or `lines` for the total.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >-
            No transaction found for id, or no category or department found for a line (resource_missing);
            `param` says which. Split lines and deleted transactions are not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            The transaction is already split (transaction_already_split); it can't be split, such as one that
            paid an invoice or isn't in USD (transaction_not_splittable, with the reason in the message); or
            the split would break a bookkeeping rule, such as a closed reconciliation period
            (split_operation_failed, with the reason in the message). Nothing changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    patch:
      operationId: updateSplit
      summary: Update split
      description: |
        Replaces every line of a split, the same as editing a split in the Finta app. The body is the
        same as splitting a transaction: send all the lines the split should have, including ones that
        don't change, since lines have no IDs to update one at a time. Their signed `amount_cents` must
        add up exactly to the transaction's. The old lines are deleted and the new ones saved in one
        step, so a refused update changes nothing.

        Each line is set only from what it's sent with. A split transaction has no department of its
        own, so a line without `department_id` has none; send each line's department again. A line
        without `merchant` takes the transaction's, and a line without `spread` isn't spread. An income or
        expense line without `accounting_date` takes the one the split's income and expense lines had. If
        they had different ones, send each income or expense line's `accounting_date`, or the update is
        refused (invalid_request).

        Only a split by category can be updated; department splits and payroll splits are changed in
        the Finta app. Requires Transactions: Manage.
      tags:
        - Transactions
      parameters:
        - $ref: "#/components/parameters/TransactionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TransactionSplitRequest"
            example:
              lines:
                - amount_cents: -70000
                  category_id: cat_v5e6f7g8h9i0j1
                  merchant: Figma
                  department_id: dept_d4e5f6g7h8i9j0
                  spread: 12
                - amount_cents: -30000
                  category_id: cat_k1l2m3n4o5p6q7
      responses:
        "200":
          description: >-
            The split's new lines, in the order they were sent: the same list `GET /transactions/splits`
            returns for this transaction.
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - next_cursor
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    example: /api/v1/transactions/splits
                  has_more:
                    type: boolean
                    description: Always false. Every line is returned.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Always null.
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/TransactionSplit"
        "400":
          description: >-
            The body isn't a JSON object with 2 to 100 valid lines (invalid_request, parameter_missing), a line
            can't have its spread or department, or the lines don't add up to the transaction's amount
            (invalid_request). `param` names the field, such as `lines[1].spread`, or `lines` for the total.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >-
            No transaction found for id, or no category or department found for a line (resource_missing);
            `param` says which. Split lines and deleted transactions are not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            The transaction isn't split by category (transaction_not_split); it can't be split, such as one
            that isn't in USD (transaction_not_splittable, with the reason in the message); or the update would
            break a bookkeeping rule, such as a closed reconciliation period (split_operation_failed, with the
            reason in the message). Nothing changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    delete:
      operationId: removeSplit
      summary: Remove split
      description: >-
        Removes a split, the same as "Remove split" in the Finta app. The split's lines are deleted,
        along with any spread on them, and the transaction returns to Uncategorized Expenses or
        Uncategorized Income with `transaction_type: standard`. It doesn't get back the category,
        department or accounting date it had before it was split. Only a split by category, with lines in
        `GET /transactions/splits`, can be removed; department splits and payroll splits are removed in the
        Finta app. Accepts no request body. Requires Transactions: Manage.
      tags:
        - Transactions
      parameters:
        - $ref: "#/components/parameters/TransactionId"
      responses:
        "204":
          description: Split removed. No response body.
        "400":
          description: The request has a body (invalid_request).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: No transaction found for id (resource_missing). Split lines and deleted transactions are not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            The transaction isn't split by category (transaction_not_split), including one whose split was
            already removed; or the removal would break a bookkeeping rule, such as a closed reconciliation
            period (split_operation_failed, with the reason in the message). Nothing changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /transfers:
    post:
      operationId: matchTransfer
      summary: Match transfer
      description: |
        Records money moving between two of the company's own accounts by pairing the outgoing
        transaction with the incoming one, the same as "Match transfer" in the Finta app. Both
        transactions move to the Matched Transfer category and are approved, so neither counts as an
        expense or income, and their `transfer_id` is set. Matching also removes any split, spread, department split or
        changed accounting date on either transaction.

        Finta matches some transfers between connected accounts automatically, but uploading with
        `POST /transaction_uploads` doesn't match anything, so pair those transfers with this endpoint.
        Check first that neither transaction already has a `transfer_id`. To find the other side, call
        `GET /transactions` with the opposite `amount_cents`, `transaction_type=standard` and a date range.
        The pair must be for opposite amounts, dated within 14 days of each other, in different accounts,
        with at least one of them uncategorized or pending. A transaction that paid an invoice can't be
        matched until its invoice payment is unmatched. Requires Transactions: Manage.
      tags:
        - Transactions
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/TransferCreate"
      responses:
        "201":
          description: The matched transfer
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Transfer"
        "400":
          description: >-
            The body is not a JSON object with only outgoing_transaction_id and incoming_transaction_id
            (invalid_request, parameter_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: A transaction was not found (resource_missing); `param` says which.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            A transaction is already matched to a transfer (transaction_already_matched); the pair can't be
            a transfer, such as amounts that aren't opposite, dates too far apart, the outgoing transaction
            not being money out, or a transaction that paid an invoice (transfer_not_eligible, with the reason in the message and `param` naming
            the transaction when it's one side); or the match would break a
            bookkeeping rule (transfer_operation_failed, with the reason in the message). Nothing changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /transfers/{id}:
    delete:
      operationId: unmatchTransfer
      summary: Unmatch transfer
      description: >-
        Reverses a match, the same as "Unmatch" in the Finta app: the transfer is removed and both
        transactions return to Uncategorized Expenses or Uncategorized Income, not to any category they
        had before. Splits, spreads and accounting dates the match removed are not restored. Works on
        any transfer, including ones Finta matched automatically. Accepts no request body. Requires
        Transactions: Manage.
      tags:
        - Transactions
      parameters:
        - $ref: "#/components/parameters/TransferId"
      responses:
        "204":
          description: Transfer unmatched. No response body.
        "400":
          description: The request has a body (invalid_request).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >-
            No transfer found for id (resource_missing), including one already unmatched; a new match
            of the same transactions has a new ID.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The unmatch would break a bookkeeping rule (transfer_operation_failed, with the reason in the message). Nothing changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /reconciliations:
    get:
      operationId: listReconciliations
      summary: List reconciliations
      tags: [Reconciliation]
      description: |
        Lists every period of the company's bank and card accounts, reconciled or not, newest
        period first and then by account name. A period appears once it has ended: the calendar
        month, or for statement-dated accounts (Ramp, Brex and Mercury credit, Stripe) the bank
        statement's dates. Pass `reconciled=false` for the periods that still need reconciling.
        Accounts that can't be reconciled are not listed. Requires Reconciliation: View.

        `finta_balance_cents` is what Finta's books add up to through `period_end`: the previous
        reconciliation's balance plus every unreconciled transaction up to the statement's cutoff,
        less those posted after it. `statement_balance_cents` is the balance the period is checked
        against, null until someone provides it or a bank feed supplies it. A period reconciled as
        part of a later one shows `reconciled: true` with null balances and that reconciliation's
        audit fields.
      parameters:
        - name: account_id
          in: query
          schema:
            type: string
          description: An acct_ ID from `GET /accounts`, to list one account's periods.
        - name: reconciled
          in: query
          schema:
            type: boolean
          description: "`false` for periods that still need reconciling, `true` for reconciled ones. Omit for both."
        - name: limit
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
        - name: starting_after
          in: query
          schema:
            type: string
          description: Pass the previous next_cursor unchanged, with the same filters.
      responses:
        '200':
          description: The periods.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReconciliationList'
        '400':
          description: Invalid limit, reconciled filter or cursor.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No account found for account_id (resource_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /reconciliations/{id}:
    get:
      operationId: retrieveReconciliation
      summary: Retrieve reconciliation
      tags: [Reconciliation]
      description: >-
        Returns one period of an account, reconciled or not. Its ID is derived from the account, its
        ledger category and the period's end date, so it stays the same when the period is
        reconciled or undone.
        Requires Reconciliation: View.
      parameters:
        - $ref: '#/components/parameters/ReconciliationId'
      responses:
        '200':
          description: The period.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Reconciliation'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No reconciliation found for id (resource_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'

  /reconciliations/{id}/reconcile:
    post:
      operationId: reconcileReconciliation
      summary: Reconcile reconciliation
      tags: [Reconciliation]
      description: |
        Reconciles the period against the statement's ending balance, the same as the Reconcile
        button in Finta. Finta's balance must match it to the cent, or nothing is saved and the
        error carries both balances. The statement balance is required; a PDF isn't. Reconciling
        locks the period: its transactions' amounts, dates and deletions are blocked until it's
        unreconciled, though categories can still change. It also reconciles any earlier
        unreconciled periods. Reconciling an already reconciled period is refused
        (already_reconciled), so a retry can't reconcile twice. Requires Reconciliation: Manage.
      parameters:
        - $ref: '#/components/parameters/ReconciliationId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReconcileRequest'
      responses:
        '200':
          description: The period, now reconciled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Reconciliation'
        '400':
          description: Invalid body (invalid_request, parameter_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No reconciliation found for id, or no transaction found for an excluded ID (resource_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The period is already reconciled (already_reconciled), the account is closed (account_closed), or its transactions changed while reconciling (concurrent_change; retry).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '422':
          description: Finta's balance doesn't match the statement balance (balance_mismatch). The error has finta_balance_cents, statement_balance_cents and difference_cents.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: The reconciliation could not be saved (reconciliation_failed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /reconciliations/{id}/unreconcile:
    post:
      operationId: unreconcileReconciliation
      summary: Unreconcile reconciliation
      tags: [Reconciliation]
      description: >-
        Undoes the account's latest reconciliation, the same as the Unreconcile button in Finta, so
        the period's transactions can change again. Only the latest reconciled period can be undone;
        for an earlier one the error names the latest (not_latest_reconciliation with
        last_reconciliation_id). Send no body. Accounts with synced bank statements are reconciled
        again overnight if they still balance. Requires Reconciliation: Manage.
      parameters:
        - $ref: '#/components/parameters/ReconciliationId'
      responses:
        '200':
          description: The period, no longer reconciled.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Reconciliation'
        '400':
          description: A request body was sent (invalid_request).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: No reconciliation found for id (resource_missing).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: The period isn't reconciled (not_reconciled), or isn't the account's latest reconciliation (not_latest_reconciliation).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '500':
          description: The reconciliation could not be undone (unreconcile_failed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'

  /spreads:
    get:
      operationId: listSpreads
      summary: List spreads
      description: |
        Returns approved spreads, newest transaction date first: the spreads the Spreads
        page in the Finta app lists.

        A spread recognizes an amount evenly over several months instead of all at once.
        It sits either on a whole transaction or on one line of a split transaction. Since
        `GET /transactions` never lists split lines, this is the list that includes both.
        A spread on a split line has `split: true`, and its `transaction_id` is the split
        transaction; its amount, category and dates are the line's, which
        `GET /transactions/splits` also lists.

        A spread still awaiting approval, or being removed, is not listed, though the
        transaction's `spread` field may already show its months.

        Requires API access and Transactions: View.
      tags:
        - Transactions
      parameters:
        - name: limit
          in: query
          description: Maximum number of results to return (1-500). Defaults to 100.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: starting_after
          in: query
          description: Pass the `next_cursor` from the previous page, unchanged, with the same filters. Rows in this list have no ID, so the cursor is opaque. A cursor this list didn't produce returns `400 invalid_request`.
          schema:
            type: string
        - name: start_date
          in: query
          description: Only rows whose transaction `date` is on or after this date, YYYY-MM-DD.
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          description: Only rows whose transaction `date` is on or before this date, YYYY-MM-DD.
          schema:
            type: string
            format: date
        - name: transaction_id
          in: query
          description: Only the spreads on this transaction, or on its lines if it is split. An unknown ID returns `404 resource_missing`.
          schema:
            type: string
            pattern: "^txn_"
      responses:
        "200":
          description: A paginated list
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - next_cursor
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    example: /api/v1/spreads
                  has_more:
                    type: boolean
                    description: Whether there are more results beyond this page.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass this value as `starting_after` to fetch the next page. Null when `has_more` is false.
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Spread"
              example:
                object: list
                url: /api/v1/spreads
                has_more: false
                next_cursor: null
                data:
                  - object: spread
                    transaction_id: txn_h8i9j0k1l2m3n4
                    split: false
                    months: 12
                    date: "2026-01-15"
                    accounting_date: "2026-01-15"
                    amount_cents: -1200000
                    currency: USD
                    merchant: Figma
                    category_id: cat_v5e6f7g8h9i0j1
                    category_name: Software
                    department_id: null
                    department_name: null
                  - object: spread
                    transaction_id: txn_a1b2c3d4e5f6g7
                    split: true
                    months: 12
                    date: "2025-05-28"
                    accounting_date: "2025-05-28"
                    amount_cents: -120
                    currency: USD
                    merchant: Figma
                    category_id: cat_v5e6f7g8h9i0j1
                    category_name: Software
                    department_id: null
                    department_name: null
        "400":
          description: A filter, date, or cursor is invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The transaction in `transaction_id` was not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /journal_entries:
    get:
      operationId: listJournalEntries
      summary: List journal entries
      description: Returns a paginated list of journal entries for the authenticated company, ordered by date descending.
      tags:
        - Journal Entries
      parameters:
        - name: limit
          in: query
          description: Maximum number of results to return (1-500). Defaults to 100.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: starting_after
          in: query
          description: A cursor for pagination. Pass the `next_cursor` value from a previous response to fetch the next page.
          schema:
            type: string
        - name: start_date
          in: query
          description: Return entries on or after this date (YYYY-MM-DD). If omitted, no lower bound.
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          description: Return entries on or before this date (YYYY-MM-DD). If omitted, no upper bound.
          schema:
            type: string
            format: date
        - name: category_id
          in: query
          description: Filter by category prefixed ID.
          schema:
            type: string
          example: cat_o5p6q7r8s9t0u1
        - name: merchant
          in: query
          description: Filter by an exact, case-sensitive merchant or counterparty name.
          schema:
            type: string
          example: AWS
        - name: department_id
          in: query
          description: Filter by a department returned by `GET /departments`.
          schema:
            type: string
            pattern: '^dept_[A-Za-z0-9]+$'
          example: dept_b2c3d4e5f6g7h8
        - name: transaction_id
          in: query
          description: Filter by entries created directly or indirectly by a transaction. This includes its spread or capitalization entries.
          schema:
            type: string
            pattern: '^txn_[A-Za-z0-9]+$'
          example: txn_a1b2c3d4e5f6g7
        - name: source_type
          in: query
          description: "Filter by what created the entry: `transaction`, `spread`, `invoice`, `csv_upload`, `manual_batch`, `capitalization`, `split`, or `intercompany`."
          schema:
            type: string
            enum:
              - transaction
              - spread
              - invoice
              - csv_upload
              - manual_batch
              - capitalization
              - split
              - intercompany
        - name: type
          in: query
          description: "Filter by entry type: `debit` or `credit`."
          schema:
            type: string
            enum:
              - debit
              - credit
      responses:
        "200":
          description: A paginated list of journal entries
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    description: The canonical path of the collection, relative to the API host. Useful for logging, debugging, and generic pagination utilities that do not need to be aware of the specific endpoint.
                    example: /api/v1/journal_entries
                  has_more:
                    type: boolean
                    description: Whether there are more results beyond this page.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass this value as `starting_after` to fetch the next page. Null when `has_more` is false.
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/JournalEntry"
              example:
                object: list
                url: /api/v1/journal_entries
                has_more: true
                next_cursor: je_h8i9j0k1l2m3n4
                data:
                  - id: je_a1b2c3d4e5f6g7
                    object: journal_entry
                    merchant: AWS
                    name: AWS
                    date: "2026-02-15"
                    amount_cents: 49900
                    type: debit
                    currency: USD
                    category_id: cat_o5p6q7r8s9t0u1
                    category_name: Software
                    transaction_id: txn_a1b2c3d4e5f6g7
                    department_id: dept_b2c3d4e5f6g7h8
                    source_type: transaction
                    created: 1771113600
                  - id: je_h8i9j0k1l2m3n4
                    object: journal_entry
                    merchant: AWS
                    name: AWS
                    date: "2026-02-15"
                    amount_cents: 49900
                    type: credit
                    currency: USD
                    category_id: cat_j6k7l8m9n0o1p2
                    category_name: Cash
                    transaction_id: txn_a1b2c3d4e5f6g7
                    department_id: dept_b2c3d4e5f6g7h8
                    source_type: transaction
                    created: 1771113600
        "400":
          description: A supplied journal-entry filter is invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The category, department, transaction, or pagination cursor was not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /journal_entries/{id}:
    get:
      operationId: retrieveJournalEntry
      summary: Retrieve journal entry
      description: Returns one journal entry for the authenticated company. The response has the same shape as each item returned by `GET /journal_entries`.
      tags:
        - Journal Entries
      parameters:
        - $ref: "#/components/parameters/JournalEntryId"
      responses:
        "200":
          description: The requested journal entry
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JournalEntry"
              example:
                id: je_a1b2c3d4e5f6g7
                object: journal_entry
                merchant: AWS
                name: AWS
                date: "2026-02-15"
                amount_cents: 49900
                type: debit
                currency: USD
                category_id: cat_o5p6q7r8s9t0u1
                category_name: Software
                transaction_id: txn_a1b2c3d4e5f6g7
                department_id: dept_b2c3d4e5f6g7h8
                source_type: transaction
                created: 1771113600
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: >-
            The journal entry ID is malformed, unknown, or belongs to another company. All three
            return the same `resource_missing` error so callers cannot probe for IDs in other
            companies. Journal entries are deleted and recreated when their source is re-booked,
            so a previously valid ID can later return this error; re-list to find the current entry.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error:
                  type: invalid_request_error
                  code: resource_missing
                  message: No journal entry found for id.
                  param: id
                  doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /invoices:
    get:
      operationId: listInvoices
      summary: List invoices
      description: |
        Lists the company's invoices, newest invoice date first, each with its line items,
        adjustments and matched payment. To find the invoice a deposit paid, filter by
        `paid_transaction_id`; to find open invoices a deposit could pay, use `status=open` and its
        `amount_cents`. Requires Invoices: View.
      tags:
        - Invoices
      parameters:
        - name: limit
          in: query
          description: Maximum number of results to return (1-500). Defaults to 100.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: starting_after
          in: query
          description: A cursor for pagination. Pass the `next_cursor` value from a previous response to fetch the next page.
          schema:
            type: string
        - name: status
          in: query
          description: Only invoices with this status. If omitted, returns invoices of every status.
          schema:
            type: string
            enum:
              - open
              - paid
              - voided
        - name: customer
          in: query
          description: Case-insensitive text search on the customer name.
          schema:
            type: string
        - name: amount_cents
          in: query
          description: Only invoices whose total `amount_cents` is exactly this value.
          schema:
            type: integer
        - name: start_date
          in: query
          description: Only invoices dated on or after this date (YYYY-MM-DD).
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          description: Only invoices dated on or before this date (YYYY-MM-DD).
          schema:
            type: string
            format: date
        - name: paid_transaction_id
          in: query
          description: Only invoices paid by this transaction, including every invoice in a payment group. An unknown transaction returns an empty list.
          schema:
            type: string
            pattern: "^txn_"
      responses:
        "200":
          description: A paginated list of invoices
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - next_cursor
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    example: /api/v1/invoices
                  has_more:
                    type: boolean
                    description: Whether there are more results beyond this page.
                  next_cursor:
                    type: string
                    nullable: true
                    description: Pass this value as `starting_after` to fetch the next page. Null when `has_more` is false.
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Invoice"
        "400":
          description: >-
            A supplied filter is invalid (invalid_request): repeated, a date not written as YYYY-MM-DD,
            or an amount that isn't an integer of at most 100000000000000 in absolute value.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The pagination cursor was not found (resource_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
    post:
      operationId: createInvoice
      summary: Create invoice
      description: |
        Records an invoice the company already sent and books its revenue: Accounts Receivable for
        the total and revenue per line item, spread over `spread_months` when set. It starts `open`
        and becomes `paid` when a deposit is matched to it. Tax is booked as a liability, not
        revenue. The total must be more than 0, and dates in a tax year the company has finished with
        Finta are refused (`closed_books`).

        Retrying is safe without an Idempotency-Key: a non-voided invoice with the same customer name
        (ignoring case and surrounding spaces), date and total returns `409 invoice_already_exists`
        naming it, and creates nothing. Requires Invoices: Manage.
      tags:
        - Invoices
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoiceCreate"
            example:
              customer_name: Acme Corp
              invoice_date: "2026-09-01"
              line_items:
                - description: Annual license
                  amount_cents: 1200000
                  category_id: cat_r1a2b3c4d5e6f7
                  spread_months: 12
              adjustments:
                - kind: discount
                  amount_cents: 100000
                  description: Launch discount
      responses:
        "201":
          description: The invoice was recorded
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400":
          description: >-
            The body is not valid JSON, has an unknown or invalid field (invalid_request), is missing a
            required field (parameter_missing), uses a category that is not a revenue category
            (invalid_request), or dates the invoice in a finished tax year (closed_books).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: A line item's category_id was not found (resource_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: The same invoice already exists (invoice_already_exists); its ID is in the message.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /invoices/{id}:
    get:
      operationId: retrieveInvoice
      summary: Retrieve invoice
      description: "Returns one invoice, the same object `GET /invoices` lists. Requires Invoices: View."
      tags:
        - Invoices
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: The requested invoice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/InvoiceNotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /invoices/{id}/void:
    post:
      operationId: voidInvoice
      summary: Void invoice
      description: |
        Voids an open invoice and removes what it booked; it can't be undone. Send no body. Unmatch a
        paid invoice's payment before voiding it. Voiding again returns the invoice unchanged.
        Requires Invoices: Manage.
      tags:
        - Invoices
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      responses:
        "200":
          description: The voided invoice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400":
          description: A request body was sent (invalid_request).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/InvoiceNotFound"
        "409":
          description: The invoice is paid (invoice_not_open), or voiding would break a bookkeeping rule (invoice_operation_failed, with the reason in the message).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /transactions/{id}/match_invoices:
    post:
      operationId: matchInvoices
      summary: Match invoices
      description: |
        Records one bank deposit as the payment for 2–100 distinct open invoices, all in this company
        and the deposit's currency. Their totals must add up exactly to the deposit. The deposit must
        be a bank deposit, not a transfer, not split, and not already matched to a different invoice set.
        All invoices become paid together, with the deposit's date as paid_date. Each invoice keeps
        its own revenue categories and recognition dates, including spreads and adjustments. The
        deposit's accounting date becomes the earliest invoice date and its category becomes that
        invoice's category (date ties use a stable invoice order). One cash entry settles the deposit
        total on its bank date. Matching the same set again, in any order, returns it unchanged;
        a different set conflicts. Any refusal leaves every invoice and the deposit unchanged.
        Every invoice date must satisfy the existing accounting-date bounds: on or after the
        company's incorporation date (or two years ago if unset), and before three years from today.
        An out-of-bounds date returns 409 invoice_operation_failed with the accounting-date reason.
        Unmatching any invoice reopens every invoice on the payment and returns the deposit to
        Uncategorized Income on its own date, rather than restoring its previous category.
        Find candidates with GET /invoices using customer and status=open, then check their totals.
        Confirm with the user before combining different customers or replacing a categorized deposit.
        In the app, a grouped deposit's spread can't be changed; resolving one grouped invoice's
        Stripe date review rebuilds the payment's entries for every invoice on it.
        Requires the invoices feature, Invoices: Manage and Transactions: Manage.
      tags:
        - Invoices
      parameters:
        - $ref: "#/components/parameters/TransactionId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              additionalProperties: false
              required:
                - invoice_ids
              properties:
                invoice_ids:
                  type: array
                  minItems: 2
                  maxItems: 100
                  uniqueItems: true
                  items:
                    type: string
                    pattern: "^inv_"
      responses:
        "200":
          description: Every invoice on the payment, in stable order, with the same fields as GET /invoices, including stripe_url when available. This list is not paginated.
          content:
            application/json:
              schema:
                type: object
                required: [object, url, has_more, next_cursor, data]
                properties:
                  object:
                    type: string
                    enum: [list]
                  url:
                    type: string
                    example: /api/v1/transactions/txn_a1b2c3d4e5f6g7/match_invoices
                  has_more:
                    type: boolean
                    enum: [false]
                  next_cursor:
                    type: string
                    nullable: true
                  data:
                    type: array
                    items:
                      $ref: "#/components/schemas/Invoice"
        "400":
          description: The body must contain only invoice_ids, an array of 2–100 distinct inv_ IDs (invalid_request).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The deposit or an invoice was not found in this company (resource_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            An invoice is not open (invoice_not_open), the deposit is matched to a different set
            (payment_already_matched), the deposit is ineligible or the totals or currency differ
            (transaction_not_matchable), or a bookkeeping rule prevents the match (invoice_operation_failed).
            Nothing changed. Matching adds no closed-books or reconciliation refusal beyond the single-invoice operation.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

  /invoices/{id}/match:
    post:
      operationId: matchInvoice
      summary: Match invoice
      description: |
        Records a bank deposit as an open invoice's payment, marking it `paid` with the deposit's date
        as `paid_date`. The deposit takes the invoice's revenue category and the invoice date as its
        accounting date; unmatching later doesn't restore its previous category. The deposit must be a
        bank deposit for exactly the invoice's amount and currency, not a transfer, not split, and
        not matched to another invoice. Matching the same pair again returns the invoice unchanged.
        Requires Invoices: Manage and Transactions: Manage.
      tags:
        - Invoices
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoicePayment"
      responses:
        "200":
          description: The paid invoice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400":
          description: The body is not a JSON object with only transaction_id (invalid_request, parameter_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The invoice or the transaction was not found (resource_missing); `param` says which.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            The invoice is not open (invoice_not_open), the deposit is matched to another invoice
            (payment_already_matched), the transaction can't pay this invoice (transaction_not_matchable),
            or the match would break a bookkeeping rule, such as an invoice dated before the company was
            incorporated (invoice_operation_failed, with the reason in the message). Nothing changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /invoices/{id}/unmatch:
    post:
      operationId: unmatchInvoice
      summary: Unmatch invoice
      description: |
        Reverses a match: every invoice on the payment returns to `open` with its Accounts Receivable
        booked again, atomically, and the deposit returns to Uncategorized Income on its own date. A deposit deleted in Finta after
        the match stays deleted and off the books. Send the transaction it's matched to; an
        already-unmatched pair returns the requested invoice unchanged. The response is still only the
        requested invoice; use GET /invoices to retrieve the others. Requires Invoices: Manage and
        Transactions: Manage.
      tags:
        - Invoices
      parameters:
        - $ref: "#/components/parameters/InvoiceId"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/InvoicePayment"
      responses:
        "200":
          description: The open invoice
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Invoice"
        "400":
          description: The body is not a JSON object with only transaction_id (invalid_request, parameter_missing).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The invoice or the transaction was not found (resource_missing); `param` says which.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "409":
          description: >-
            The invoice is not matched to that transaction (invoice_not_matched), or the unmatch would
            break a bookkeeping rule (invoice_operation_failed, with the reason in the message). Nothing changed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /parties/merchants:
    get:
      operationId: listMerchants
      summary: List merchants
      description: |
        Lists the distinct merchant and counterparty names recorded for the requested category and
        its descendants.

        The search covers all available months and excludes held entries. Newly imported names may
        not appear immediately.

        The API returns each name exactly as recorded. Results sort by case-insensitive name, then exact
        spelling. These names are free text and do not have stable IDs.
      tags:
        - Parties
      parameters:
        - name: category_id
          in: query
          required: true
          description: A `cat_` ID returned by `GET /categories`.
          schema:
            type: string
            pattern: '^cat_[A-Za-z0-9]+$'
          example: cat_o5p6q7r8s9t0u1
        - name: limit
          in: query
          description: Number of results to return, from 1 to 500. Defaults to 100.
          schema:
            type: integer
            default: 100
            minimum: 1
            maximum: 500
        - name: starting_after
          in: query
          description: The `next_cursor` value from the previous response. Pass it unchanged.
          schema:
            type: string
      responses:
        "200":
          description: A paginated list of observed merchant names
          content:
            application/json:
              schema:
                type: object
                required:
                  - object
                  - url
                  - has_more
                  - next_cursor
                  - data
                properties:
                  object:
                    type: string
                    enum:
                      - list
                  url:
                    type: string
                    enum:
                      - /api/v1/parties/merchants
                  has_more:
                    type: boolean
                  next_cursor:
                    type: string
                    nullable: true
                    description: Cursor for the next page. Null when `has_more` is false.
                  data:
                    type: array
                    items:
                      type: object
                      required:
                        - object
                        - name
                      properties:
                        object:
                          type: string
                          enum:
                            - merchant
                        name:
                          type: string
                          description: Merchant or counterparty name, exactly as recorded.
              example:
                object: list
                url: /api/v1/parties/merchants
                has_more: false
                next_cursor: null
                data:
                  - object: merchant
                    name: AWS
                  - object: merchant
                    name: Datadog
        "400":
          description: A required parameter is missing or invalid
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: The category or pagination cursor was not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /reports/exports:
    post:
      operationId: createExport
      summary: Create export
      tags: [Reports]
      description: |
        Asynchronously export an income statement, balance sheet, or cash flow as PDF, CSV, or XLSX.
        Requires API access: Manage and Reports: View. Does not edit company books; read-only OAuth
        connections may request exports. Internal read-only keys cannot POST.
        All report types require start_date and end_date. Balance sheet exports compare cumulative
        balances across periods in that range; start_date selects displayed periods, not an opening
        cutoff for historical balances. A single date field is not accepted by the export endpoint.
        Ranges may cover at most 120 calendar months, counting both boundary months, regardless of
        grouping or format. Longer ranges return 400 invalid_date_range before creating or queuing a job.
        PDF exports additionally support at most 13 displayed periods, using the company's calendar/fiscal
        report settings. Larger PDFs return 400 invalid_date_range with param end_date; use a shorter range,
        coarser grouping, CSV, or XLSX instead.
        group_by is an object: period controls time columns (month, quarter, or year; default month),
        and rows controls row grouping (category or department; default category).
        Department rows are supported only for income statements, export the GAAP department statement,
        and require that feature. Omitted group_by or either member uses the corresponding default.
        Company, actor, reporting-date settings, and filename are inferred by Finta. Period columns
        follow the company's existing calendar/fiscal report settings; this endpoint accepts no date_type.
        The worker reads the books when it generates the file, not from a frozen snapshot at submission.
        Completed files are retained results; use a new Idempotency-Key for a new export of updated books.
        Email delivery is not supported. Export records and their files are retained; there is no
        automatic age-based cleanup for exports in the application.
        Reports::GenerationRequestWorker runs on the existing critical queue.
        A company may have at most five unfinished API exports across all submitting users.
        Additional new exports return 429 report_export_limit_exceeded without creating or enqueueing
        a job. Existing-key replays and retrieval remain available. A completed or failed export releases
        its slot; retry rejected submissions after an export finishes.

        Idempotency-Key is required and scoped to company, submitting user, and this operation.
        Identical normalized inputs return the existing export with its current status; changed inputs
        return 409 idempotency_key_conflict. The key remains reserved for the lifetime of the export.
        A pending replay without a recorded enqueue repairs an interrupted dispatch; worker
        executions are serialized and completed exports are not generated again. A failed export requires
        a new key to retry. Creation and replay return 201 with Location pointing to the retrieval endpoint.
        If enqueue acknowledgement or bookkeeping fails, creation returns 503 and retains a pending
        export. Retry with the same key to recover; a job may already be queued, and duplicate deliveries
        are serialized. These failures do not consume the key with a terminal enqueue failure.
        The file is generated in the background; poll Location with backoff until succeeded or failed.
        Every poll counts toward the usual API limits. No webhook or MCP event subscription is created.
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema: {type: string, minLength: 1, maxLength: 255}
          description: Nonblank, case-sensitive key; reuse with identical inputs after an uncertain response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ReportExportRequest'
            examples:
              monthly_income_statement_pdf:
                summary: Monthly income statement PDF by category
                value:
                  report_type: income_statement
                  start_date: '2026-01-01'
                  end_date: '2026-09-30'
                  format: pdf
                  group_by: {period: month, rows: category}
              quarterly_department_income_statement_csv:
                summary: Quarterly GAAP income statement CSV by department
                value:
                  report_type: income_statement
                  start_date: '2026-01-01'
                  end_date: '2026-09-30'
                  format: csv
                  group_by: {period: quarter, rows: department}
              monthly_balance_sheet_xlsx:
                summary: Balance sheet XLSX comparing monthly balances
                value:
                  report_type: balance_sheet
                  start_date: '2026-01-01'
                  end_date: '2026-09-30'
                  format: xlsx
                  group_by: {period: month, rows: category}
              annual_cash_flow_pdf:
                summary: Cash flow PDF with annual columns
                value:
                  report_type: cash_flow
                  start_date: '2024-01-01'
                  end_date: '2025-12-31'
                  format: pdf
                  group_by: {period: year, rows: category}
      responses:
        '201':
          description: Export created or replayed; status may already be terminal on replay.
          headers:
            Location:
              schema: {type: string}
              description: Retrieval URL for this export.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReportExport'
        '400':
          description: Invalid body, field, dates, or Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Insufficient permissions, read-only internal key, or unavailable department feature.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: Idempotency key already identifies different inputs.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: API rate or monthly limit exceeded, or report_export_limit_exceeded when the company has five unfinished exports.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          description: report_export_failed; the export could not be accepted or queued. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
  /reports/exports/{id}:
    get:
      operationId: retrieveExport
      summary: Retrieve export
      tags: [Reports]
      description: |
        Read an existing API export. Requires API access: Manage and Reports: View.
        Any authorized company teammate can retrieve it. Other companies, unknown IDs, raw database IDs,
        and exports created through the web UI return 404. Department exports also require the feature.
        GET never generates reports, dispatches workers, or marks a file downloaded.
        Status is pending, processing, succeeded, or failed. A file is exposed only after successful worker
        completion, final permission checks, and attachment. Fresh links are available for 30 days after
        completed_at. At or after that cutoff, status remains succeeded but file is null and error is null,
        including on an idempotent POST replay. The stored file is retained. Request a new export with a new
        Idempotency-Key to generate another report from the current books.
        Within that window, signed download URLs request expiry seven days after retrieval; retrieve this
        resource again for a fresh URL. Previously issued links keep their original expiry, which can extend
        beyond the 30-day regeneration cutoff.
        Signing credential expiry or storage policies can invalidate a link sooner.
        Link expiry does not delete the file. A download started before expiry can finish afterward;
        restarting or requesting further byte ranges after expiry requires a fresh link.
        Download URLs are bearer links and remain usable until expiration even if API access changes.
        Both responses use Cache-Control no-store. Failure details contain safe codes, never stack traces.
        The existing API request log stores export ID, status, and whether a link was issued, alongside
        caller and request time. Pending/failed polls and HEAD probes do not count as link issuance. Actual S3 downloads
        are not tracked. started_at records the first generation attempt and can remain null when
        access is revoked before generation; generation_duration_ms is the
        cumulative time spent in generation attempts, excluding queue and retry waits.
      parameters:
        - name: id
          in: path
          required: true
          schema: {type: string}
          description: Export ID from creation, starting with export_.
      responses:
        '200':
          description: Current export status and result.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReportExport'
        '401':
          description: Authentication required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          description: Report/API permission or department feature unavailable.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No API export found for this ID in this company.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          description: API rate or monthly limit exceeded.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'
  /reports/income_statement:
    get:
      operationId: retrieveIncomeStatement
      summary: Retrieve income statement
      description: |
        Returns the income statement (profit & loss) for the given date range.
        Defaults to the current quarter to date in America/Los_Angeles (Pacific Time).
        Both calendar-date boundaries are inclusive and honored exactly; response dates
        identify the period actually used.

        `group_by=category` is the default and
        preserves the existing category-based statement. `group_by=department`
        returns the GAAP P&L with expenses grouped by department.

        Department grouping requires the departments feature. It returns typed
        expense rows for departments, COGS categories, and the synthetic Cost of
        Goods Sold and No Department groups. No Department means no department
        assignment, not an uncategorized expense. All other sections keep the
        existing category format. Parent amounts include child amounts; sum only
        root rows to total expenses.

        Department grouping reads non-held journal entries directly, with exact
        inclusive date boundaries, aggregating the requested range as one period
        rather than constructing a table column per month. It returns refreshed_at=null because no summary
        refresh timestamp is available. Category grouping uses the rollup/raw sourcing described below.

        For category grouping, whole months read whole-month rollups. The current month's first day through today
        in Pacific Time uses fresh MTD rollups, keeping month-aligned month-to-date and
        year-to-date requests fast. Other partial months, including a midmonth start, use
        slower raw-entry reads for only those months. Stale or unfilled MTD falls back to raw.
        This changes data sourcing and freshness, not the report's numerical semantics.

        For category grouping, `refreshed_at` covers the whole report: the maximum contributing rollup timestamp,
        including MTD, or current as of the call when any portion reads live raw entries,
        even when no raw entries match. Null when neither source supplies a timestamp.

        The full statement is always returned. You cannot filter to a single category.
        Expenses shown in COGS, taxes, or non-operating income are excluded from
        department subtotals. Unassigned bulk-split parent entries are excluded
        from No Department. Missing optional sections contribute zero to totals.

        Returns one period per request. For monthly breakdowns, make separate calls for
        each month.

        Sign convention: income/revenue amounts are positive, expense amounts are
        negative. Summing all section totals gives net income.
      tags:
        - Reports
      parameters:
        - name: group_by
          in: query
          description: Expense grouping. category returns the standard income statement; department returns the GAAP P&L.
          schema:
            type: string
            enum: [category, department]
            default: category
        - name: start_date
          in: query
          description: Exact first included calendar date (YYYY-MM-DD) in Pacific Time. Defaults to the beginning of the current quarter. For category grouping, a midmonth start uses slower raw entries for that partial month.
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          description: Exact last included calendar date (YYYY-MM-DD) in Pacific Time. Defaults to today. For category grouping, month-start through today uses fresh MTD rollups; other partial-month boundaries use slower raw entries.
          schema:
            type: string
            format: date
      responses:
        "200":
          description: The income statement
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/IncomeStatement"
                  - $ref: "#/components/schemas/DepartmentIncomeStatement"
                discriminator:
                  propertyName: group_by
                  mapping:
                    category: "#/components/schemas/IncomeStatement"
                    department: "#/components/schemas/DepartmentIncomeStatement"
              examples:
                category:
                  summary: Standard income statement (default grouping)
                  value:
                    object: income_statement
                    group_by: category
                    start_date: "2026-02-01"
                    end_date: "2026-02-28"
                    currency: USD
                    refreshed_at: 1772323200
                    uncategorized_income:
                      total_cents: 0
                      categories: []
                    revenue:
                      total_cents: 8500000
                      categories:
                        - category_id: cat_r1a2b3c4d5e6f7
                          name: Subscription Revenue
                          amount_cents: 7200000
                          position: 1
                          parent_category_id: null
                        - category_id: cat_s2b3c4d5e6f7g8
                          name: One-Time Revenue
                          amount_cents: 1300000
                          position: 2
                          parent_category_id: null
                    expenses:
                      total_cents: -5200000
                      categories:
                        - category_id: cat_t3c4d5e6f7g8h9
                          name: Cost of Revenue
                          amount_cents: -420000
                          position: 1
                          parent_category_id: null
                        - category_id: cat_t4costsvc00001
                          name: Cost of Services
                          amount_cents: -350000
                          position: 1
                          parent_category_id: cat_t3c4d5e6f7g8h9
                        - category_id: cat_t5hosting00001
                          name: Hosting
                          amount_cents: -70000
                          position: 2
                          parent_category_id: cat_t3c4d5e6f7g8h9
                        - category_id: cat_u4d5e6f7g8h9i0
                          name: People Expenses
                          amount_cents: -3100000
                          position: 2
                          parent_category_id: null
                        - category_id: cat_u5payroll00001
                          name: Payroll
                          amount_cents: -2800000
                          position: 1
                          parent_category_id: cat_u4d5e6f7g8h9i0
                        - category_id: cat_u6contract0001
                          name: Contractors
                          amount_cents: -300000
                          position: 2
                          parent_category_id: cat_u4d5e6f7g8h9i0
                        - category_id: cat_x7g8h9i0j1k2l3
                          name: Travel & Events
                          amount_cents: -430000
                          position: 3
                          parent_category_id: null
                        - category_id: cat_x8meals000001
                          name: Meals
                          amount_cents: -180000
                          position: 1
                          parent_category_id: cat_x7g8h9i0j1k2l3
                        - category_id: cat_x9flights0001
                          name: Flights & Hotels
                          amount_cents: -200000
                          position: 2
                          parent_category_id: cat_x7g8h9i0j1k2l3
                        - category_id: cat_x0activit0001
                          name: Activities
                          amount_cents: -50000
                          position: 3
                          parent_category_id: cat_x7g8h9i0j1k2l3
                        - category_id: cat_v5e6f7g8h9i0j1
                          name: Software
                          amount_cents: -680000
                          position: 4
                          parent_category_id: null
                        - category_id: cat_z9i0j1k2l3m4n5
                          name: Admin
                          amount_cents: -555000
                          position: 5
                          parent_category_id: null
                        - category_id: cat_z0bankfee0001
                          name: Banking Fees
                          amount_cents: -200000
                          position: 1
                          parent_category_id: cat_z9i0j1k2l3m4n5
                        - category_id: cat_z1training001
                          name: Training
                          amount_cents: -180000
                          position: 2
                          parent_category_id: cat_z9i0j1k2l3m4n5
                        - category_id: cat_z2members0001
                          name: Memberships
                          amount_cents: -175000
                          position: 3
                          parent_category_id: cat_z9i0j1k2l3m4n5
                        - category_id: cat_a0j1k2l3m4n5o6
                          name: Depreciation & Amortization
                          amount_cents: -15000
                          position: 6
                          parent_category_id: null
                        - category_id: cat_a1amortiz0001
                          name: Amortization Expense
                          amount_cents: -15000
                          position: 1
                          parent_category_id: cat_a0j1k2l3m4n5o6
                    uncategorized_expenses:
                      total_cents: 0
                      categories: []
                    operating_income_cents: 3300000
                    non_operating_income:
                      total_cents: 25000
                      categories:
                        - category_id: cat_b1k2l3m4n5o6p7
                          name: Interest Income
                          amount_cents: 25000
                          position: 1
                          parent_category_id: null
                    taxes:
                      total_cents: 0
                      categories: []
                    net_income_cents: 3325000
                department:
                  summary: GAAP P&L grouped by department
                  value:
                    object: income_statement
                    group_by: department
                    start_date: '2026-02-01'
                    end_date: '2026-02-28'
                    currency: USD
                    refreshed_at:
                    revenue:
                      total_cents: 10000000
                      categories:
                      - category_id: cat_subscription00001
                        name: Subscription Revenue
                        amount_cents: 10000000
                        position: 1
                        parent_category_id:
                    expenses:
                      total_cents: -7000000
                      rows:
                      - row_id: grp_cost_of_goods_sold
                        parent_row_id:
                        kind: group
                        department_id:
                        category_id:
                        name: Cost of Goods Sold
                        amount_cents: -2000000
                        position: 1
                      - row_id: cat_hosting00000001
                        parent_row_id: grp_cost_of_goods_sold
                        kind: category
                        department_id:
                        category_id: cat_hosting00000001
                        name: Hosting
                        amount_cents: -2000000
                        position: 1
                      - row_id: dept_research000001
                        parent_row_id:
                        kind: department
                        department_id: dept_research000001
                        category_id:
                        name: Research & Development
                        amount_cents: -4800000
                        position: 2
                      - row_id: dept_engineering001
                        parent_row_id: dept_research000001
                        kind: department
                        department_id: dept_engineering001
                        category_id:
                        name: Engineering
                        amount_cents: -4800000
                        position: 1
                      - row_id: grp_no_department
                        parent_row_id:
                        kind: group
                        department_id:
                        category_id:
                        name: No Department
                        amount_cents: -200000
                        position: 3
                    operating_income_cents: 3000000
                    non_operating_income:
                      total_cents: 100000
                      categories:
                      - category_id: cat_interest0000001
                        name: Interest Income
                        amount_cents: 100000
                        position: 1
                        parent_category_id:
                    taxes:
                      total_cents: -600000
                      categories:
                      - category_id: cat_taxes0000000001
                        name: Taxes / Tax Credits
                        amount_cents: -600000
                        position: 1
                        parent_category_id:
                    net_income_cents: 2500000
        "400":
          description: Invalid date, reversed date range, or unsupported group_by. error.param identifies the invalid parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              examples:
                invalid_date:
                  summary: A date value could not be parsed
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_date
                      message: 'The `start_date` value "banana" is not a valid date. Use ISO 8601 format, for example 2025-12-31.'
                      doc_url: https://www.finta.com/docs/api-reference/errors#invalid_date
                      param: start_date
                invalid_date_range:
                  summary: start_date is after end_date
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_date_range
                      message: start_date must be on or before end_date.
                      doc_url: https://www.finta.com/docs/api-reference/errors#invalid_date_range
                      param: start_date
                invalid_group_by:
                  summary: Unsupported expense grouping
                  value:
                    error:
                      type: invalid_request_error
                      code: invalid_request
                      message: group_by must be category or department.
                      doc_url: https://www.finta.com/docs/api-reference/errors#invalid_request
                      param: group_by
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /reports/balance_sheet:
    get:
      operationId: retrieveBalanceSheet
      summary: Retrieve balance sheet
      description: |
        Returns the balance sheet as of the given date.
        Defaults to today in America/Los_Angeles (Pacific Time). The cutoff is inclusive
        through that exact calendar date, which is returned in the response.

        Whole months read whole-month rollups. A cutoff of today uses fresh current-month
        MTD rollups for month-start through today in Pacific Time. Other non-month-end cutoffs
        read slower raw entries for just the cutoff month. Stale or unfilled MTD safely falls
        back to raw entries. This changes data sourcing and freshness, not reported balances.

        `refreshed_at` covers the whole report: the maximum contributing rollup timestamp,
        including MTD, or current as of the call when any portion reads live raw entries,
        even when no raw entries match. Null when neither source supplies a timestamp.

        The balance sheet is a point-in-time snapshot ("what do my balances look like
        as of this date?"), unlike the income statement and cash flow which measure
        activity over a range.

        The full statement is always returned. You cannot filter to a single section.
      tags:
        - Reports
      parameters:
        - name: date
          in: query
          description: Exact inclusive as-of calendar date (YYYY-MM-DD) in Pacific Time. Defaults to today. Month-end uses whole-month rollups; today uses fresh MTD; other partial-month cutoffs use slower raw entries for the cutoff month.
          schema:
            type: string
            format: date
      responses:
        "200":
          description: The balance sheet
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BalanceSheet"
              example:
                object: balance_sheet
                date: "2026-02-28"
                currency: USD
                refreshed_at: 1772323200
                cash_cents: 9800000
                assets:
                  total_cents: 12500000
                  categories:
                    - category_id: cat_c2l3m4n5o6p7q8
                      name: Cash
                      balance_cents: 9800000
                      position: 1
                      parent_category_id: null
                    - category_id: cat_c3x4y5z6a7b8c9
                      name: Brex (Primary business account)
                      balance_cents: 6500000
                      position: 1
                      parent_category_id: cat_c2l3m4n5o6p7q8
                    - category_id: cat_c4y5z6a7b8c9d0
                      name: Mercury (Checking)
                      balance_cents: 2100000
                      position: 2
                      parent_category_id: cat_c2l3m4n5o6p7q8
                    - category_id: cat_c5z6a7b8c9d0e1
                      name: Brex (Treasury)
                      balance_cents: 800000
                      position: 3
                      parent_category_id: cat_c2l3m4n5o6p7q8
                    - category_id: cat_c6a7b8c9d0e1f2
                      name: Stripe (Stripe)
                      balance_cents: 400000
                      position: 4
                      parent_category_id: cat_c2l3m4n5o6p7q8
                    - category_id: cat_d3m4n5o6p7q8r9
                      name: Accounts Receivable
                      balance_cents: 1500000
                      position: 2
                      parent_category_id: null
                    - category_id: cat_e4n5o6p7q8r9s0
                      name: Prepaid Expenses
                      balance_cents: 450000
                      position: 3
                      parent_category_id: null
                    - category_id: cat_f5o6p7q8r9s0t1
                      name: Security Deposit
                      balance_cents: 500000
                      position: 4
                      parent_category_id: null
                    - category_id: cat_g6p7q8r9s0t1u2
                      name: Intangible Assets
                      balance_cents: 250000
                      position: 5
                      parent_category_id: null
                liabilities:
                  total_cents: 850000
                  categories:
                    - category_id: cat_h7q8r9s0t1u2v3
                      name: Credit Cards
                      balance_cents: 620000
                      position: 1
                      parent_category_id: null
                    - category_id: cat_h8a9b0c1d2e3f4
                      name: Brex (Credit)
                      balance_cents: 420000
                      position: 1
                      parent_category_id: cat_h7q8r9s0t1u2v3
                    - category_id: cat_h9b0c1d2e3f4g5
                      name: Ramp (Credit)
                      balance_cents: 200000
                      position: 2
                      parent_category_id: cat_h7q8r9s0t1u2v3
                    - category_id: cat_i8r9s0t1u2v3w4
                      name: Accounts Payable
                      balance_cents: 230000
                      position: 2
                      parent_category_id: null
                equity:
                  total_cents: 11650000
                  categories:
                    - category_id: cat_j9s0t1u2v3w4x5
                      name: Common Stock
                      balance_cents: 10000
                      position: 1
                      parent_category_id: null
                    - category_id: cat_k0t1u2v3w4x5y6
                      name: SAFE
                      balance_cents: 5000000
                      position: 2
                      parent_category_id: null
                  retained_earnings:
                    beginning_balance_cents: 3315000
                    net_income_cents: 3325000
                    ending_balance_cents: 6640000
                liabilities_and_equity_cents: 12500000
        "400":
          $ref: "#/components/responses/InvalidDate"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /reports/cash_flow:
    get:
      operationId: retrieveCashFlow
      summary: Retrieve cash flow statement
      description: |
        Returns the cash flow statement for the given date range.
        Defaults to the current quarter to date in America/Los_Angeles (Pacific Time).
        Both calendar-date boundaries are inclusive and honored exactly; response dates
        identify the period actually used.

        Whole months read whole-month rollups. The current month's first day through today
        in Pacific Time uses fresh MTD rollups, keeping month-aligned month-to-date and
        year-to-date requests fast. Other partial months, including a midmonth start, use
        slower raw-entry reads for only those months. Stale or unfilled MTD falls back to raw.
        This changes data sourcing and freshness, not the report's numerical semantics.

        `refreshed_at` covers the whole report, including its opening and closing balances:
        the maximum contributing rollup timestamp, including MTD, or current as of the call
        when any portion reads live raw entries, even when no raw entries match. Null when
        neither source supplies a timestamp.

        The full statement is always returned. Returns one period per request.
        Make separate calls for monthly breakdowns.

        Operating activities include presentation groupings that mirror the
        webapp display: "Changes in Working Capital" and "Non-Cash Expenses".
        These are not database categories, so they use synthetic IDs prefixed
        with grp_ (e.g. grp_changes_in_working_capital, grp_non_cash_expenses).
        Their children reference them via parent_category_id. Category-level
        hierarchy within those groupings is also preserved: if a category has
        sub-categories, they appear with parent_category_id pointing to the
        parent, just like on the balance sheet and income statement.
      tags:
        - Reports
      parameters:
        - name: start_date
          in: query
          description: Exact first included calendar date (YYYY-MM-DD) in Pacific Time. Defaults to the beginning of the current quarter. A midmonth start uses slower raw entries for that partial month.
          schema:
            type: string
            format: date
        - name: end_date
          in: query
          description: Exact last included calendar date (YYYY-MM-DD) in Pacific Time. Defaults to today. Month-start through today uses fresh MTD rollups; other partial-month boundaries use slower raw entries.
          schema:
            type: string
            format: date
      responses:
        "200":
          description: The cash flow statement
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/CashFlow"
              example:
                object: cash_flow
                start_date: "2026-02-01"
                end_date: "2026-02-28"
                currency: USD
                refreshed_at: 1772323200
                operating_activities:
                  total_cents: 2890000
                  categories:
                    - category_id: null
                      name: Net Income / (Loss)
                      amount_cents: 3325000
                      position: 1
                      parent_category_id: null
                    - category_id: grp_changes_in_working_capital
                      name: Changes in Working Capital
                      amount_cents: -450000
                      position: 2
                      parent_category_id: null
                    - category_id: cat_d3m4n5o6p7q8r9
                      name: Accounts Receivable
                      amount_cents: -500000
                      position: 3
                      parent_category_id: grp_changes_in_working_capital
                    - category_id: cat_e4n5o6p7q8r9s0
                      name: Prepaid Expenses
                      amount_cents: 50000
                      position: 4
                      parent_category_id: grp_changes_in_working_capital
                    - category_id: cat_h7q8r9s0t1u2v3
                      name: Credit Cards
                      amount_cents: -100000
                      position: 5
                      parent_category_id: grp_changes_in_working_capital
                    - category_id: cat_q1unearned001
                      name: Unearned Revenue
                      amount_cents: 10000
                      position: 6
                      parent_category_id: grp_changes_in_working_capital
                    - category_id: cat_i8r9s0t1u2v3w4
                      name: Accounts Payable
                      amount_cents: 90000
                      position: 7
                      parent_category_id: grp_changes_in_working_capital
                    - category_id: grp_non_cash_expenses
                      name: Non-Cash Expenses
                      amount_cents: 15000
                      position: 8
                      parent_category_id: null
                    - category_id: cat_a0j1k2l3m4n5o6
                      name: Amortization Expense
                      amount_cents: 15000
                      position: 9
                      parent_category_id: grp_non_cash_expenses
                investing_activities:
                  total_cents: -200000
                  categories:
                    - category_id: cat_f5o6p7q8r9s0t1
                      name: Security Deposit
                      amount_cents: -200000
                      position: 1
                      parent_category_id: null
                financing_activities:
                  total_cents: 0
                  categories: []
                beginning_cash_cents: 7110000
                ending_cash_cents: 9800000
                change_in_cash_cents: 2690000
        "400":
          $ref: "#/components/responses/ReportDateError"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /billing:
    get:
      operationId: retrieveBilling
      summary: Retrieve billing
      description: |
        Returns the plan the authenticated company is on and what it pays per interval: the plan's
        price for this company, the discount taken off (negative), and the total. These are the
        figures Settings -> Billing shows. Billing contacts, payment methods and invoices aren't returned.

        `plan_price_cents` is this company's own price, which can differ from the advertised one,
        and `total_cents` is what it pays while any discount lasts. Formation and Enterprise have no
        card subscription, so their `status` and `renews_on` are null.
      tags:
        - Plans & billing
      responses:
        "200":
          description: The company's billing summary
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Billing"
              example:
                object: billing
                plan: startup
                status: active
                interval: month
                renews_on: "2026-11-03"
                plan_price_cents: 9900
                discount_cents: -2475
                total_cents: 7425
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          description: Billing details couldn't be read from the payment provider (`billing_unavailable`). Retry later. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"

  /feedback:
    post:
      operationId: submitFeedback
      summary: Submit feedback
      tags:
      - Feedback
      description: |
        Sends a question, feedback or bug report to Finta support from the authenticated user,
        or from an AI agent acting for them (`reported_by: agent`). It arrives as an email ticket
        from the user, and support replies to the user's email address. Nothing in the company's
        data changes. Requires API access only, and works for MCP connections with read-only access.

        Each call opens a new ticket, so retrying after a timeout may send it twice.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              "$ref": "#/components/schemas/FeedbackCreate"
            example:
              type: bug
              message: Uploading last month's Chase checking export stops at row 212 with a server error.
      responses:
        '201':
          description: The ticket was opened.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Feedback"
        '400':
          description: Invalid JSON, an unknown field, or a missing or invalid field. `param` names the field.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '401':
          "$ref": "#/components/responses/Unauthorized"
        '403':
          "$ref": "#/components/responses/Forbidden"
        '429':
          description: Rate or usage limit exceeded.
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"
        '503':
          description: The ticket couldn't be opened (`feedback_unavailable`). Retry later, or email support@finta.com. Also api_error/invoice_status_unavailable with Retry-After 60 when a linked billing invoice cannot be verified after its Pacific cutoff.
          headers:
            Retry-After:
              description: Seconds to wait; present for invoice_status_unavailable.
              schema: { type: integer, example: 60 }
          content:
            application/json:
              schema:
                "$ref": "#/components/schemas/Error"

  /tax_credits:
    get:
      operationId: listTaxCredits
      summary: List tax credits
      tags: [Tax Credits]
      description: >-
        Returns one credit per type, jurisdiction, and year for the authenticated company.
        Currently supports US research_and_development credits. Includes estimates, undecided
        years, and years where credits were declined. Omit year for all available years, newest
        first. No matching records returns an empty list. Requires Taxes: View and API access:
        Manage. Filed returns take precedence over restarted onboarding for the same year;
        otherwise the most recently created questionnaire supplies the year's credit.
        Reads do not calculate credits, generate IDs in the database, or submit anything.
      parameters:
        - name: type
          in: query
          schema:
            type: string
            enum: [research_and_development]
        - name: year
          in: query
          description: Four-digit tax year. Omit for all available years.
          schema:
            type: integer
            minimum: 1000
            maximum: 9999
        - name: claim_status
          in: query
          schema:
            type: string
            enum: [undecided, not_claimed, pending, claimed]
        - name: limit
          in: query
          description: Page size; omitted or empty defaults to 100.
          schema:
            type: integer
            minimum: 1
            maximum: 500
            default: 100
        - name: starting_after
          in: query
          description: >-
            Pass next_cursor unchanged with the same filters. An unknown, deleted, inaccessible,
            or filtered-out cursor returns 404 resource_missing. Pagination is live, not a snapshot.
          schema:
            type: string
      responses:
        '200':
          description: A page of credits. Empty results have data [], has_more false, and next_cursor null.
          content:
            application/json:
              schema:
                type: object
                additionalProperties: false
                required: [object, url, has_more, next_cursor, data]
                properties:
                  object:
                    type: string
                    enum: [list]
                  url:
                    type: string
                    enum: [/api/v1/tax_credits]
                  has_more:
                    type: boolean
                  next_cursor:
                    type: string
                    nullable: true
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/TaxCredit'
        '400':
          description: Invalid filter or limit; error.param names the invalid parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Cursor unavailable in the filtered company scope; error.param is starting_after.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

  /tax_credits/{id}:
    get:
      operationId: retrieveTaxCredit
      summary: Retrieve tax credit
      tags: [Tax Credits]
      description: >-
        Returns one credit in the authenticated company. Requires Taxes: View and API access: Manage.
        Public IDs remain stable when the underlying questionnaire changes for the same
        company, type, jurisdiction, and year. Other companies' credits return 404.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            pattern: '^tcred_[A-Za-z0-9]+$'
      responses:
        '200':
          description: The tax credit.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TaxCredit'
        '400':
          description: Invalid public ID format; error.param is id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Credit not found in the authenticated company; error.param is id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '503':
          $ref: '#/components/responses/InvoiceStatusUnavailable'

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: "API key prefixed with `finta_`"

  parameters:
    IntegrationKey:
      name: key
      in: path
      required: true
      description: The integration's `key` from `GET /integrations`, e.g. `mercury`.
      schema:
        type: string
      example: mercury
    InboxItemId:
      name: id
      in: path
      required: true
      description: The inbox item's `id` from `GET /inbox`.
      schema:
        type: string
      example: inb_3f2a9c1e7b4d4e0f9a6c2b8d1e5f7a90
    TransactionId:
      name: id
      in: path
      required: true
      description: Transaction ID with `txn_` prefix.
      schema:
        type: string
        pattern: "^txn_"
      example: txn_a1b2c3d4e5f6g7
    JournalEntryId:
      name: id
      in: path
      required: true
      description: Journal entry ID with `je_` prefix.
      schema:
        type: string
        pattern: "^je_"
      example: je_a1b2c3d4e5f6g7

    InvoiceId:
      name: id
      in: path
      required: true
      description: The invoice's `inv_` ID.
      schema:
        type: string
        example: inv_k3m9p2q8r4s7t1
    ReconciliationId:
      name: id
      in: path
      required: true
      description: The period's `recon_` ID, from `GET /reconciliations`.
      schema:
        type: string
        example: recon_3f9a1c27b84e4d0d9e6b5a2c71f08e44

    TransferId:
      name: id
      in: path
      required: true
      description: The transfer's `tfr_` ID, the `transfer_id` of either of its transactions.
      schema:
        type: string
        example: tfr_a1b2c3d4e5f6g7

  schemas:
    SignupRequest:
      type: object
      required: [email]
      properties:
        email:
          type: string
          format: email
          maxLength: 254
          description: The user's email as supplied by them. Never guess. Finta trims whitespace and lowercases it.
        app:
          type: string
          description: >-
            Optional branding for the verification email and page. Supported keys are codex,
            claude, claude-desktop, claude-code, chatgpt, ari-from-ariso, cursor, grok-bot,
            capy, hermes-agent, mem0-gateway, codevisor, crevio, and mcp-inspector.
            When omitted, Finta detects the app from User-Agent. Unknown values use generic
            agent branding and do not cause an error. This only affects branding, not signup behavior.
    Signup:
      type: object
      required: [status, email, expires_in_seconds, mcp_url, next_step]
      properties:
        status:
          type: string
          enum: [verification_sent]
          description: Same value for every account state; does not confirm that an email was sent.
        email:
          type: string
          format: email
          description: The submitted email, trimmed and lowercased.
        expires_in_seconds:
          type: integer
          enum: [3600]
          description: Lifetime of a verification link when one is issued. Present even when no email is sent.
        mcp_url:
          type: string
          format: uri
          description: Finta's OAuth-only MCP resource URL, configured for this environment.
        next_step:
          type: object
          required: [action, message]
          properties:
            action:
              type: string
              enum: [open_verification_link]
            message:
              type: string
              description: Instructions for human activation, MCP connection, and returning to the conversation after any required restart.

    SheetCellValue:
      oneOf:
      - type: string
        nullable: true
      - type: number
      - type: boolean
      description: |
        Spreadsheet cell value in worksheet units, not an automatic cents conversion.
        A $20,000 worksheet amount is 20000; accounting formatting does not rescale it.
        On writes, null skips a cell and an empty string clears its contents; strings beginning
        with = are formulas. No RAW mode or new literal escaping is introduced. Input is stored
        through the existing text-based engine, not as a separate persisted JSON scalar type.
        In calculated output, blank and formula-error cells may be null; consult calculation.errors
        to distinguish failures. Authored whitespace-only strings remain text, not numeric zero.

        FINTA supports FINTA(category, period, [department], [name], [tag]) and
        FINTA(category, [start_date], end_date, [department], [name], [tag]). Square brackets
        denote optional slots, not literal formula syntax. Tag is last: the fifth period-form
        argument or sixth date-form argument. Leave skipped department/name arguments empty,
        for example =FINTA("Expenses", "Sep 2026", , , "Infrastructure") or
        =FINTA("Expenses", "2026-09-01", "2026-09-30", , , "Other").
        Category remains required; end_date remains required in the date form. Existing
        overloads, including name-only and legacy date forms, are not reinterpreted as tags.
        A period still uses the supported year, quarter, half-year, month or All time labels.
        Date/month inputs retain their existing month-end summary-date normalization;
        this does not introduce day-precise FINTA windows. A blank start_date keeps the existing
        default of the end month and its preceding month.

        The tag filter intersects the category, period/dates and any department/name filters.
        A final tag literal can be a current company tag name or stable tag_ ID; tag_ strings
        in that slot are interpreted as IDs. A typed tag-cell reference resolves through its
        reference_id, not its displayed label. IDs preserve filtering across renames; name
        literals resolve by current name and do not provide that identity guarantee.
        Missing/deleted/foreign/unavailable selectors fail instead of widening to unfiltered
        totals. Other is reserved for transactions with no tags, not other named tags omitted
        from a selection. Tag selectors do not create or edit company tags.
        Selector failures use existing calculation diagnostics, not a new HTTP error envelope;
        a successful write may remain saved even when calculation fails.
    SheetValues:
      type: array
      items:
        type: array
        items:
          "$ref": "#/components/schemas/SheetCellValue"
      description: Rectangular row-major matrix exactly matching the inclusive selected range.
        Write values must be strings, booleans, finite numbers or null. Null positions are
        retained in the matrix but skip cell-write operations. Sparse write rectangles can
        exceed 500 cells in area within company grid limits; the aggregate expanded-operation
        budget still applies across the entire request.
    SheetFormat:
      type: object
      additionalProperties: false
      properties:
        align:
          type: string
          enum:
          - left
          - center
          - right
        auto_align:
          type: string
          enum:
          - right
        background_color:
          type: string
        bold:
          type: boolean
        borders:
          type: object
          additionalProperties: false
          properties:
            top:
              type: string
            right:
              type: string
            bottom:
              type: string
            left:
              type: string
        decimals:
          type: integer
          minimum: 0
          maximum: 10
        font_size:
          type: string
          enum:
          - heading
          - default
          - subtext
        indent:
          type: integer
          minimum: 0
          maximum: 10
        italic:
          type: boolean
        number_style:
          type: string
          enum:
          - number
          - accounting
          - date
          - period
          - percent
        reference_kind:
          type: string
          enum:
          - department
          - tag
          description: Semantic reference type. department retains existing department-reference behavior; tag identifies a transaction tag cell rather than a category or an amount.
        reference_id:
          type: string
          pattern: '^(tag_[A-Za-z0-9]+|Other)$'
          description: Stable company tag identity when reference_kind is tag. Write the hash-prefixed cell label and preserve both reference_kind and reference_id when copying or filling a typed tag cell. Use an existing tag_ ID, or exactly Other for the reserved genuinely untagged bucket. A missing or unavailable identity must not be replaced with a same-named tag or an unfiltered query.
        text_color:
          type: string
      description: |
        Existing spreadsheet formatting in public snake_case. Partial updates preserve
        unspecified properties. Period, department-reference and tag-reference formatting
        carries semantic meaning, not only presentation. To author a stable tag reference,
        write a hash-prefixed label such as #Infrastructure and set reference_kind to tag
        with its reference_id. Text alone is not a typed tag reference. Preserve both fields
        when inspecting, copying or filling these cells; native copy_range/fill_range retain
        reference metadata. The label can change while the ID continues to select the same tag.
        Other selects genuinely untagged transactions. A tag reference is not an amount.
        Accounting uses worksheet units without cents conversion; percent displays a fractional
        rate as a percentage. Colors use the existing product color validator. Each formatted
        cell consumes one operation; format selections expand the grid within company limits.
    SheetFormatRange:
      type: object
      additionalProperties: false
      properties:
        range:
          type: string
        format:
          "$ref": "#/components/schemas/SheetFormat"
      required:
      - range
      - format
    SheetWriteRange:
      type: object
      additionalProperties: false
      properties:
        range:
          type: string
        values:
          "$ref": "#/components/schemas/SheetValues"
      required:
      - range
      - values
    SheetChartCreate:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - bar
          - stacked_bar
          - horizontal_bar
          - line
          - area
          - stacked_area
          - pie
        source_range:
          type: string
        series_orientation:
          type: string
          enum:
          - columns
          - rows
        anchor_cell:
          type: string
          description: Automatically appends blank rows or columns to fit this anchor, within company sheet limits. Invalid anchor selections report error.param as chart.anchor_cell.
          default: A1
        width:
          type: number
          default: 408
          minimum: 0
          exclusiveMinimum: true
        height:
          type: number
          default: 240
          minimum: 0
          exclusiveMinimum: true
        offset_x:
          type: number
          default: 0
        offset_y:
          type: number
          default: 0
        title:
          type: string
        x_axis_title:
          type: string
        y_axis_title:
          type: string
        show_title:
          type: boolean
        show_x_axis_title:
          type: boolean
        show_x_axis_labels:
          type: boolean
        show_y_axis_title:
          type: boolean
        show_y_axis_labels:
          type: boolean
        show_data_labels:
          type: boolean
        show_legend:
          type: boolean
      required:
      - type
      - source_range
      description: Chart embedded in the target worksheet using the existing chart engine,
        not an exported image. Source ranges and anchor are local A1 addresses. The source
        must fit the resulting worksheet grid; only the anchor requests automatic expansion.
        series_orientation defaults to columns (series arranged by columns); rows arranges
        series by rows. Width and height are positive and at most 10000; offsets are between
        -10000 and 10000. Geometry supports at most two decimal places. Chart text fields
        are at most 10000 characters. Unsupported values are rejected.
    SheetChartUpdate:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - bar
          - stacked_bar
          - horizontal_bar
          - line
          - area
          - stacked_area
          - pie
        source_range:
          type: string
        series_orientation:
          type: string
          enum:
          - columns
          - rows
        anchor_cell:
          type: string
          description: Automatically appends blank rows or columns to fit this anchor, within company sheet limits.
        width:
          type: number
          minimum: 0
          exclusiveMinimum: true
        height:
          type: number
          minimum: 0
          exclusiveMinimum: true
        offset_x:
          type: number
        offset_y:
          type: number
        title:
          type: string
        x_axis_title:
          type: string
        y_axis_title:
          type: string
        show_title:
          type: boolean
        show_x_axis_title:
          type: boolean
        show_x_axis_labels:
          type: boolean
        show_y_axis_title:
          type: boolean
        show_y_axis_labels:
          type: boolean
        show_data_labels:
          type: boolean
        show_legend:
          type: boolean
      description: Partial chart update; unspecified chart properties are preserved, including position, dimensions and offsets. Creation defaults are not applied.
    SheetChart:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        type:
          type: string
          enum:
          - bar
          - stacked_bar
          - horizontal_bar
          - line
          - area
          - stacked_area
          - pie
        source_range:
          type: string
        source_ranges:
          type: array
          items:
            type: string
        series_orientation:
          type: string
          enum:
          - columns
          - rows
        anchor_cell:
          type: string
          description: Automatically appends blank rows or columns to fit this anchor, within company sheet limits. Invalid anchor selections report error.param as chart.anchor_cell.
          default: A1
        width:
          type: number
          default: 408
          minimum: 0
          exclusiveMinimum: true
        height:
          type: number
          default: 240
          minimum: 0
          exclusiveMinimum: true
        offset_x:
          type: number
          default: 0
        offset_y:
          type: number
          default: 0
        title:
          type: string
        x_axis_title:
          type: string
        y_axis_title:
          type: string
        show_title:
          type: boolean
        show_x_axis_title:
          type: boolean
        show_x_axis_labels:
          type: boolean
        show_y_axis_title:
          type: boolean
        show_y_axis_labels:
          type: boolean
        show_data_labels:
          type: boolean
        show_legend:
          type: boolean
        series:
          type: array
          items:
            "$ref": "#/components/schemas/SheetChartSeries"
      required:
      - id
      - type
      - source_range
    SheetWorksheetCreate:
      description: Custom worksheet tab, initially 100 rows by 26 columns. Names must be
        nonblank and unique ignoring case. Ranges and formats use local A1 selections;
        the existing UI grid grows as needed within company limits. All tabs share one
        500-expanded-operation request budget, including worksheet setup.
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 50
        ranges:
          type: array
          items:
            "$ref": "#/components/schemas/SheetWriteRange"
        formats:
          type: array
          items:
            "$ref": "#/components/schemas/SheetFormatRange"
        charts:
          type: array
          items:
            "$ref": "#/components/schemas/SheetChartCreate"
          maxItems: 50
      required:
      - name
    SheetTemplateOptions:
      description: Only statement templates accept nonempty options. Empty options uses
        the template defaults. Otherwise both ISO dates are required, spanning 1–13 periods;
        dates must preserve an engine-supported previous period (year 2000 or later) and
        end by year 9999. Invalid/reversed dates and selections requiring truncation are
        rejected. period_grouping defaults to month; month, quarter and year select whole
        calendar periods, with the starting date aligned to the group's beginning. These
        are period selections, not arbitrary partial-period filters.
      oneOf:
      - type: object
        additionalProperties: false
        properties: {}
        maxProperties: 0
      - type: object
        additionalProperties: false
        properties:
          starting_month:
            type: string
            format: date
          ending_month:
            type: string
            format: date
          period_grouping:
            type: string
            enum:
            - month
            - quarter
            - year
        required:
        - starting_month
        - ending_month
    SheetCreateRequest:
      description: Name alone creates one blank worksheet named Sheet 1. Names must be nonblank.
        template_id and custom worksheets are mutually exclusive. template_options requires
        template_id. No company selector or grid-size input is accepted. return_ranges selects
        calculated outputs after saving using worksheet-name-qualified A1 addresses, up to
        100 selections and 10000 selected cells in total. Selections must fit the final grid
        and are validated before save. Omit return_ranges for metadata without calculation.
      oneOf:
      - "$ref": "#/components/schemas/SheetBlankCreateRequest"
      - "$ref": "#/components/schemas/SheetTemplateCreateRequest"
      - "$ref": "#/components/schemas/SheetCustomCreateRequest"
    SheetWriteRangeOperation:
      description: Writes a rectangular matrix matching the range. null skips a cell; an empty
        string clears contents. Sparse rectangles may span company grid limits; only emitted
        native operations count toward the shared 500-operation budget.
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - write_range
        range:
          type: string
        values:
          "$ref": "#/components/schemas/SheetValues"
        worksheet_id:
          type: string
          description: Optional stable worksheet selector for an unqualified range. A
            qualified range must agree with this selector.
      required:
      - type
      - range
      - values
    SheetFormatRangeOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - format_range
        range:
          type: string
        format:
          "$ref": "#/components/schemas/SheetFormat"
        worksheet_id:
          type: string
          description: Optional stable worksheet selector for an unqualified range. A
            qualified range must agree with this selector.
      required:
      - type
      - range
      - format
    SheetClearRangeOperation:
      description: Clears contents (default), formats, or both (all) within the existing grid.
        Contents or formats costs one native operation per selected cell, including blank cells;
        all costs two per cell. A selection may contain at most 500 cells.
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - clear_range
        range:
          type: string
        clear:
          type: string
          enum:
          - contents
          - formats
          - all
          default: contents
        worksheet_id:
          type: string
          description: Optional stable worksheet selector for an unqualified range. A
            qualified range must agree with this selector.
      required:
      - type
      - range
    SheetCopyRangeOperation:
      description: Copies values and formatting with existing relative-reference adjustment.
        The destination must match the source size or tile it by whole multiples. The source
        must fit the existing grid; the destination can expand it. Each source/destination
        selection is limited to 500 cells; emitted value and format edits share the request budget.
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - copy_range
        source_range:
          type: string
        destination_range:
          type: string
      required:
      - type
      - source_range
      - destination_range
    SheetFillRangeOperation:
      description: Fills using copy (default, repeats with relative-reference adjustment) or
        series (existing sequence inference). The destination contains the source, extends
        one axis, and remains in the same worksheet. The source must fit the existing grid;
        the destination can expand it. Each selection is limited to 500 cells and emitted
        value and format edits share the request budget.
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - fill_range
        source_range:
          type: string
        destination_range:
          type: string
        mode:
          type: string
          enum:
          - copy
          - series
          default: copy
      required:
      - type
      - source_range
      - destination_range
    SheetInsertRowsOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - insert_rows
        worksheet_id:
          type: string
        before_row:
          type: integer
          minimum: 1
        count:
          type: integer
          minimum: 1
      required:
      - type
      - worksheet_id
      - before_row
      - count
    SheetDeleteRowsOperation:
      description: Deletes an inclusive one-based row selection supplied as a string, such
        as 12 or 12:14. Existing formula references adjust with the structural edit.
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - delete_rows
        worksheet_id:
          type: string
        rows:
          type: string
          example: '12:14'
      required:
      - type
      - worksheet_id
      - rows
    SheetInsertColumnsOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - insert_columns
        worksheet_id:
          type: string
        before_column:
          type: string
          example: C
        count:
          type: integer
          minimum: 1
      required:
      - type
      - worksheet_id
      - before_column
      - count
    SheetDeleteColumnsOperation:
      description: Deletes an inclusive column selection supplied as case-insensitive letters,
        such as G or G:H. Existing formula references adjust with the structural edit.
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - delete_columns
        worksheet_id:
          type: string
        columns:
          type: string
          example: G:H
      required:
      - type
      - worksheet_id
      - columns
    SheetAddWorksheetOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - add_worksheet
        name:
          type: string
          minLength: 1
          maxLength: 50
      required:
      - type
      - name
    SheetRenameWorksheetOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - rename_worksheet
        worksheet_id:
          type: string
        name:
          type: string
          minLength: 1
          maxLength: 50
      required:
      - type
      - worksheet_id
      - name
    SheetDeleteWorksheetOperation:
      description: Removes one worksheet by stable ID. At least one worksheet must remain.
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - delete_worksheet
        worksheet_id:
          type: string
      required:
      - type
      - worksheet_id
    SheetCreateChartOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - create_chart
        worksheet_id:
          type: string
        chart:
          "$ref": "#/components/schemas/SheetChartCreate"
      required:
      - type
      - worksheet_id
      - chart
    SheetUpdateChartOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - update_chart
        worksheet_id:
          type: string
        chart_id:
          type: string
        chart:
          "$ref": "#/components/schemas/SheetChartUpdate"
      required:
      - type
      - worksheet_id
      - chart_id
      - chart
    SheetDeleteChartOperation:
      type: object
      additionalProperties: false
      properties:
        type:
          type: string
          enum:
          - delete_chart
        worksheet_id:
          type: string
        chart_id:
          type: string
      required:
      - type
      - worksheet_id
      - chart_id
    SheetOperation:
      oneOf:
      - "$ref": "#/components/schemas/SheetWriteRangeOperation"
      - "$ref": "#/components/schemas/SheetFormatRangeOperation"
      - "$ref": "#/components/schemas/SheetClearRangeOperation"
      - "$ref": "#/components/schemas/SheetCopyRangeOperation"
      - "$ref": "#/components/schemas/SheetFillRangeOperation"
      - "$ref": "#/components/schemas/SheetInsertRowsOperation"
      - "$ref": "#/components/schemas/SheetDeleteRowsOperation"
      - "$ref": "#/components/schemas/SheetInsertColumnsOperation"
      - "$ref": "#/components/schemas/SheetDeleteColumnsOperation"
      - "$ref": "#/components/schemas/SheetAddWorksheetOperation"
      - "$ref": "#/components/schemas/SheetRenameWorksheetOperation"
      - "$ref": "#/components/schemas/SheetDeleteWorksheetOperation"
      - "$ref": "#/components/schemas/SheetCreateChartOperation"
      - "$ref": "#/components/schemas/SheetUpdateChartOperation"
      - "$ref": "#/components/schemas/SheetDeleteChartOperation"
      discriminator:
        propertyName: type
        mapping:
          write_range: "#/components/schemas/SheetWriteRangeOperation"
          format_range: "#/components/schemas/SheetFormatRangeOperation"
          clear_range: "#/components/schemas/SheetClearRangeOperation"
          copy_range: "#/components/schemas/SheetCopyRangeOperation"
          fill_range: "#/components/schemas/SheetFillRangeOperation"
          insert_rows: "#/components/schemas/SheetInsertRowsOperation"
          delete_rows: "#/components/schemas/SheetDeleteRowsOperation"
          insert_columns: "#/components/schemas/SheetInsertColumnsOperation"
          delete_columns: "#/components/schemas/SheetDeleteColumnsOperation"
          add_worksheet: "#/components/schemas/SheetAddWorksheetOperation"
          rename_worksheet: "#/components/schemas/SheetRenameWorksheetOperation"
          delete_worksheet: "#/components/schemas/SheetDeleteWorksheetOperation"
          create_chart: "#/components/schemas/SheetCreateChartOperation"
          update_chart: "#/components/schemas/SheetUpdateChartOperation"
          delete_chart: "#/components/schemas/SheetDeleteChartOperation"
    SheetUpdateRequest:
      description: At least name or nonempty operations is required. Operations execute
        in order, atomically; later ranges see prior changes. Row numbers are one-based
        and column references use letters. A batch expands to at most 500 native operations;
        each changed cell value or cell format consumes one operation. Unmentioned content
        is preserved. Document renaming precedes operations. Automatic grid growth includes
        formula-restoration operations to preserve references that the structural engine would
        otherwise rewrite, including on other worksheets. These consume the same budget as
        cells, formats, renames, tabs, charts and explicit structural edits. No automatic
        splitting is performed. For larger edits, use separate sequential calls with each
        returned revision; earlier successful calls remain saved if a later call fails.
        A saved mutation may still return calculation errors. Reread and replan on a revision
        conflict; do not blindly replace base_revision. Operation-free no-ops can retain the
        revision, so always use the returned value.
      anyOf:
      - "$ref": "#/components/schemas/SheetNamedUpdateRequest"
      - "$ref": "#/components/schemas/SheetOperationsUpdateRequest"
    SheetSummary:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        name:
          type: string
        url:
          type: string
          format: uri
          description: Absolute link to the document on the configured Finta app host.
        revision:
          type: integer
          minimum: 0
      required:
      - id
      - name
      - url
      - revision
    SheetWorksheet:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        name:
          type: string
        used_range:
          type: string
          nullable: true
        row_count:
          type: integer
          minimum: 1
          description: Current grid height. Writes, formatting, copy/fill destinations and chart anchors automatically expand it within company limits.
        column_count:
          type: integer
          minimum: 1
          description: Current grid width. Writes, formatting, copy/fill destinations and chart anchors automatically expand it within company limits.
        charts:
          type: array
          items:
            "$ref": "#/components/schemas/SheetChart"
      required:
      - id
      - name
      - used_range
      - row_count
      - column_count
    SheetCalculationError:
      type: object
      additionalProperties: false
      properties:
        worksheet_id:
          type: string
        cell:
          type: string
        code:
          type: string
        message:
          type: string
        formula:
          type: string
      required:
      - code
      - message
    SheetCalculationWarning:
      type: object
      additionalProperties: false
      properties:
        code:
          type: string
        formula:
          type: string
        count:
          type: integer
          minimum: 0
        message:
          type: string
      required:
      - code
      - message
    SheetCalculation:
      type: object
      additionalProperties: false
      properties:
        status:
          type: string
          enum:
          - complete
          - error
          - unavailable
        errors:
          type: array
          items:
            "$ref": "#/components/schemas/SheetCalculationError"
        warnings:
          type: array
          items:
            "$ref": "#/components/schemas/SheetCalculationWarning"
      required:
      - status
      - errors
      - warnings
      description: Bounded synchronous calculation; no pending job is created. error means
        one or more formulas failed. unavailable means evaluation could not run; values
        are omitted rather than invented. Formula-error cells have null values and corresponding
        errors. A write can be saved even when this status is not complete. Missing or
        unavailable FINTA tag selectors use these existing diagnostics rather than an unfiltered
        or successful zero result. This feature adds no HTTP error code, response header or
        calculation status.
    SheetRange:
      type: object
      additionalProperties: false
      properties:
        worksheet_id:
          type: string
        range:
          type: string
        values:
          "$ref": "#/components/schemas/SheetValues"
        formulas:
          type: array
          items:
            type: array
            items:
              type: string
              nullable: true
        formats:
          type: array
          items:
            "$ref": "#/components/schemas/SheetFormatRange"
      required:
      - worksheet_id
      - range
    SheetOperationResult:
      type: object
      additionalProperties: false
      properties:
        index:
          type: integer
          minimum: 0
        type:
          type: string
          enum:
          - write_range
          - format_range
          - clear_range
          - copy_range
          - fill_range
          - insert_rows
          - delete_rows
          - insert_columns
          - delete_columns
          - add_worksheet
          - rename_worksheet
          - delete_worksheet
          - create_chart
          - update_chart
          - delete_chart
        range:
          type: string
        worksheet_id:
          type: string
        chart_id:
          type: string
      required:
      - index
      - type
    Sheet:
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        name:
          type: string
        url:
          type: string
        revision:
          type: integer
          minimum: 0
        worksheets:
          type: array
          items:
            "$ref": "#/components/schemas/SheetWorksheet"
        ranges:
          type: array
          items:
            "$ref": "#/components/schemas/SheetRange"
        calculation:
          "$ref": "#/components/schemas/SheetCalculation"
      required:
      - id
      - name
      - url
      - revision
      - worksheets
    SheetMutationResponse:
      description: Saved document metadata with an absolute configured-app URL and stable
        worksheet/chart IDs. POST always includes worksheets and their chart definitions;
        it does not emit operation_results. Use the returned revision rather than assuming
        a fixed initial value. Optional return_ranges adds calculated selections and status;
        calculation errors do not roll back the saved document. Revision tracks sheet edits,
        not freshness of underlying accounting data.
      type: object
      additionalProperties: false
      properties:
        id:
          type: string
        name:
          type: string
        url:
          type: string
        revision:
          type: integer
          minimum: 0
        worksheets:
          type: array
          items:
            "$ref": "#/components/schemas/SheetWorksheet"
        ranges:
          type: array
          items:
            "$ref": "#/components/schemas/SheetRange"
        calculation:
          "$ref": "#/components/schemas/SheetCalculation"
        operation_results:
          type: array
          items:
            "$ref": "#/components/schemas/SheetOperationResult"
      required:
      - id
      - name
      - url
      - revision
    SheetCellMatch:
      type: object
      additionalProperties: false
      properties:
        worksheet_id:
          type: string
        worksheet_name:
          type: string
        cell:
          type: string
        input:
          type: string
      required:
      - worksheet_id
      - worksheet_name
      - cell
      - input
    SheetList:
      type: object
      additionalProperties: false
      properties:
        object:
          type: string
          enum:
          - list
        url:
          type: string
        data:
          type: array
          items:
            "$ref": "#/components/schemas/SheetSummary"
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
      required:
      - object
      - url
      - data
      - has_more
      - next_cursor
    SheetCellMatchList:
      type: object
      additionalProperties: false
      properties:
        object:
          type: string
          enum:
          - list
        url:
          type: string
        data:
          type: array
          items:
            "$ref": "#/components/schemas/SheetCellMatch"
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
        revision:
          type: integer
          minimum: 0
      required:
      - object
      - url
      - data
      - has_more
      - next_cursor
      - revision
    SheetChartSeries:
      type: object
      additionalProperties: false
      required:
      - name
      - source_range
      properties:
        name:
          type: string
        color:
          type: string
          enum:
          - blue
          - yellow
          - green
          - purple
          - orange
          - pink
          - teal
          - coral
        name_range:
          type: string
        source_range:
          type: string
        category_range:
          type: string
        category_cells:
          type: array
          items:
            type: string
            nullable: true
    SheetBlankCreateRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 50
        return_ranges:
          description: Optional selected results after saving. Invalid selections report error.param as return_ranges.
          type: array
          items:
            type: string
          maxItems: 100
      required:
      - name
    SheetTemplateCreateRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 50
        template_id:
          description: income_statement, cash_flow and balance_sheet use the existing UI
            statement templates with live FINTA actuals and spreadsheet totals. revenue_by_customer
            and top_software_expenses use the last six completed months and accept only omitted
            or empty template_options. Prefer a statement template for standard statements;
            preserve live actuals unless the user explicitly requests a frozen snapshot.
          type: string
          enum:
          - income_statement
          - cash_flow
          - balance_sheet
          - revenue_by_customer
          - top_software_expenses
        template_options:
          "$ref": "#/components/schemas/SheetTemplateOptions"
        return_ranges:
          description: Optional selected results after saving. Invalid selections report error.param as return_ranges.
          type: array
          items:
            type: string
          maxItems: 100
      required:
      - name
      - template_id
    SheetCustomCreateRequest:
      type: object
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 50
        worksheets:
          type: array
          items:
            "$ref": "#/components/schemas/SheetWorksheetCreate"
          minItems: 1
          maxItems: 100
        return_ranges:
          description: Optional selected results after saving. Invalid selections report error.param as return_ranges.
          type: array
          items:
            type: string
          maxItems: 100
      required:
      - name
      - worksheets
    SheetNamedUpdateRequest:
      type: object
      additionalProperties: false
      properties:
        base_revision:
          type: integer
          minimum: 0
        name:
          type: string
          minLength: 1
          maxLength: 50
        operations:
          type: array
          items:
            "$ref": "#/components/schemas/SheetOperation"
          maxItems: 500
        return_ranges:
          description: Optional A1 selections using final tab names and positions. Up to
            100 selections and 10000 cells, calculated after saving. Calculation failures
            do not roll back edits. Invalid selections report error.param as return_ranges. Omit for metadata only.
          type: array
          items:
            type: string
          maxItems: 100
      required:
      - base_revision
      - name
    SheetOperationsUpdateRequest:
      type: object
      additionalProperties: false
      properties:
        base_revision:
          type: integer
          minimum: 0
        name:
          type: string
          minLength: 1
          maxLength: 50
        operations:
          type: array
          items:
            "$ref": "#/components/schemas/SheetOperation"
          maxItems: 500
          minItems: 1
        return_ranges:
          description: Optional A1 selections using final tab names and positions. Up to
            100 selections and 10000 cells, calculated after saving. Calculation failures
            do not roll back edits. Invalid selections report error.param as return_ranges. Omit for metadata only.
          type: array
          items:
            type: string
          maxItems: 100
      required:
      - base_revision
      - operations
    Usage:
      type: object
      additionalProperties: false
      required:
        - object
        - categorizations
        - transactions
        - ai_credits
        - api_calls
        - mcp_calls
      properties:
        object:
          type: string
          enum:
            - usage
        categorizations:
          $ref: "#/components/schemas/UsageHeldMeter"
        transactions:
          $ref: "#/components/schemas/UsageHeldMeter"
        ai_credits:
          $ref: "#/components/schemas/UsageCreditMeter"
        api_calls:
          $ref: "#/components/schemas/UsageCallMeter"
        mcp_calls:
          $ref: "#/components/schemas/UsageCallMeter"

    UsageHeldMeter:
      type: object
      additionalProperties: false
      description: Automatic categorizations this month, or transactions stored in total.
      required:
        - used
        - limit
        - held
        - stopped
        - resets_on
      properties:
        used:
          type: integer
          minimum: 0
        limit:
          type: integer
          nullable: true
          minimum: 0
          description: Null when there is no limit.
        held:
          type: integer
          minimum: 0
          description: Transactions waiting because of the limit.
        stopped:
          type: boolean
          description: True when Finta has stopped because of the limit.
        resets_on:
          type: string
          format: date
          nullable: true
          description: When the monthly count starts over. Null for a limit that doesn't reset.

    UsageCreditMeter:
      type: object
      additionalProperties: false
      description: AI credits used this month against the monthly allowance.
      required:
        - used
        - limit
        - remaining
        - stopped
        - resets_on
      properties:
        used:
          type: integer
          minimum: 0
        limit:
          type: integer
          nullable: true
          minimum: 0
          description: Null when there is no limit.
        remaining:
          type: integer
          nullable: true
          minimum: 0
          description: Credits left, including purchased packs. Null when there is no limit.
        stopped:
          type: boolean
          description: True when Finta has stopped because of the limit.
        resets_on:
          type: string
          format: date
          nullable: true
          description: When the monthly count starts over. Null for a limit that doesn't reset.

    UsageCallMeter:
      type: object
      additionalProperties: false
      description: Public API requests or MCP tool calls this month.
      required:
        - used
        - limit
        - custom_limit
        - stopped
        - resets_on
      properties:
        used:
          type: integer
          minimum: 0
        limit:
          type: integer
          nullable: true
          minimum: 0
          description: Null when there is no limit.
        custom_limit:
          type: boolean
          description: True when Finta set this company's limit by hand.
        stopped:
          type: boolean
          description: True when Finta has stopped because of the limit.
        resets_on:
          type: string
          format: date
          nullable: true
          description: When the monthly count starts over. Null for a limit that doesn't reset.

    TaxCredit:
      type: object
      additionalProperties: false
      required: [id, object, type, jurisdiction, year, claim_status, amount_cents, currency, amount_basis, applied_to_income_tax_cents, payroll_credit_cents, payroll_submission, created, updated]
      properties:
        id:
          type: string
          pattern: '^tcred_[A-Za-z0-9]+$'
        object:
          type: string
          enum: [tax_credit]
        type:
          type: string
          enum: [research_and_development]
        jurisdiction:
          type: string
          enum: [US]
        year:
          type: integer
        claim_status:
          type: string
          enum: [undecided, not_claimed, pending, claimed]
          description: >-
            undecided means no decision is recorded; not_claimed means the user declined;
            pending means opted in but inclusion in the filed return is not yet established;
            claimed means a positive credit is recorded in the filed income-tax return.
            Legacy filed returns with positive recorded credits can be claimed without a saved opt-in.
            This says nothing about payroll submission, provider acceptance, or refunds received.
        amount_cents:
          type: integer
          minimum: 0
          nullable: true
          description: >-
            Total credit, before income-tax allocation. Recorded return amounts take precedence
            over estimates. Unknown amounts are null, including unverified database-default zeroes.
            A zero return total accompanied by an explicit zero allocation is a known zero.
            Declined years may retain their estimated potential credit.
        currency:
          type: string
          enum: [usd]
        amount_basis:
          type: string
          nullable: true
          enum: [estimate, prepared_return, filed_return]
          description: Source of amount_cents; null when the amount is unknown.
        applied_to_income_tax_cents:
          type: integer
          minimum: 0
          nullable: true
          description: Recorded allocation against income tax; null when absent or inconsistent with the return total.
        payroll_credit_cents:
          type: integer
          minimum: 0
          nullable: true
          description: >-
            Recorded return total less the income-tax allocation; null unless both are known.
            This allocation is not a refund received and can be shown before filing.
        payroll_submission:
          type: object
          additionalProperties: false
          required: [provider, status]
          properties:
            provider:
              type: string
              nullable: true
              description: Recorded R&D payroll provider, otherwise the sole known payroll provider; null when unresolved.
            status:
              type: string
              enum: [waiting_for_tax_filing, pending, action_required, submitted, not_required, unknown]
              description: >-
                waiting_for_tax_filing means opted in and income tax is not yet filed.
                pending means filed with a positive payroll allocation, awaiting Finta submission to Gusto or Rippling.
                action_required means filed with a positive payroll allocation and a known unsupported provider or no payroll software.
                submitted means an explicit payroll submission is recorded in Finta, not confirmed by the provider.
                not_required means declined or a known zero payroll allocation, including credits fully offset against income tax;
                that allocation takes precedence over Finta's automatically completed submission flag.
                If an automatic full-offset completion later has a positive payroll allocation, it is treated as awaiting submission.
                A historical done flag without a recorded completion source returns unknown when payroll is required.
                unknown means the decision, amount, allocation, provider, or submission state is insufficient to determine progress.
        created:
          type: integer
          description: Unix seconds when the source questionnaire was created.
        updated:
          type: integer
          description: Latest update of the source questionnaire, income-tax filing, or return package, in Unix seconds.
      example:
        id: tcred_example1
        object: tax_credit
        type: research_and_development
        jurisdiction: US
        year: 2025
        claim_status: claimed
        amount_cents: 2500000
        currency: usd
        amount_basis: filed_return
        applied_to_income_tax_cents: 500000
        payroll_credit_cents: 2000000
        payroll_submission:
          provider: gusto
          status: pending
        created: 1769000000
        updated: 1791850000

    TaxList:
      type: object
      required: [object, url, has_more, next_cursor, summaries, data]
      properties:
        object:
          type: string
          enum: [list]
        url:
          type: string
          enum: [/api/v1/taxes]
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
          pattern: '^tax_(income|franchise)_[A-Za-z0-9]+$'
        summaries:
          type: array
          description: One summary per tax year and type, newest year first and income before franchise. Only year and type narrow these; jurisdiction, status and pagination do not. They repeat on every page.
          items:
            $ref: '#/components/schemas/TaxSummary'
        data:
          type: array
          items:
            $ref: '#/components/schemas/Tax'

    TaxSummary:
      type: object
      additionalProperties: false
      required: [year, type, filing_status, tax_id, due_date, overdue]
      properties:
        year:
          type: integer
          minimum: 1000
          maximum: 9999
        type:
          type: string
          enum: [income, franchise]
        filing_status:
          type: string
          enum: [not_started, onboarding, preparing, review_required, filing, filed]
          description: |
            The year's progress. not_started means nothing has been started in Finta for this
            year and no return filed elsewhere has been uploaded; tax_id is null. onboarding means
            Finta tax onboarding has started but is not completed, with or without a filing record
            yet. The other values match the filing_status of the record named by tax_id. When a
            year has several records, a filed one is used, then an active one over an archived
            one, then the furthest along. Handle future unfamiliar values without assuming completion.
        tax_id:
          type: string
          nullable: true
          pattern: '^tax_(income|franchise)_[A-Za-z0-9]+$'
          description: The record this summary describes. Null when no filing record exists yet. Retrieve it for documents and jurisdictions.
        due_date:
          type: string
          format: date
          description: Filing deadline in the following calendar year. Income is April 15, or October 15 when an extension is recorded; Delaware franchise is March 1. Not adjusted for weekends or holidays.
        overdue:
          type: boolean
          description: True when today is after due_date and filing_status is not filed.

    IncomeUploadCreate:
      type: object
      additionalProperties: false
      required: [tax_year, tax_firm, states_filed, file_id]
      properties:
        tax_year:
          type: integer
          description: The tax year the return covers. Only last year or the year before is accepted (2025 or 2024 during 2026); any other year is refused.
        tax_firm:
          type: string
          minLength: 1
          description: The firm or person who prepared the return. Required, as on the Taxes page; like the page, Finta does not currently store it.
        states_filed:
          type: array
          description: USPS codes of the states the return was filed in, such as CA; DC is accepted. Empty when no state return was filed. They become the record's jurisdictions.
          items:
            type: string
            pattern: '^[A-Z]{2}$'
        file_id:
          type: string
          pattern: '^file_[A-Za-z0-9]+$'
          description: A file from Create file or Create file upload that is not yet attached.

    FranchiseUploadCreate:
      type: object
      additionalProperties: false
      required: [tax_year, file_id]
      properties:
        tax_year:
          type: integer
          minimum: 1000
          maximum: 9999
          description: The tax year the report covers, not the year it was filed.
        file_id:
          type: string
          pattern: '^file_[A-Za-z0-9]+$'
          description: A file from Create file or Create file upload that is not yet attached.

    File:
      type: object
      required: [id, object, filename, size, created, expires_at]
      properties:
        id:
          type: string
          pattern: '^file_[A-Za-z0-9]+$'
        object:
          type: string
          enum: [file]
        filename:
          type: string
          description: The uploaded file name, kept when the file is attached (a Delaware confirmation is renamed).
        size:
          type: integer
          description: Size in bytes.
        created:
          type: integer
          description: Upload time in Unix seconds.
        expires_at:
          type: integer
          description: Unix seconds after which an unattached file is deleted, 24 hours after upload. An attached file stays with its tax record.

    FileUploadCreate:
      type: object
      additionalProperties: false
      required: [filename, size, checksum]
      properties:
        filename:
          type: string
          minLength: 1
          description: The PDF's file name, kept and shown on the Taxes page.
        size:
          type: integer
          minimum: 1
          maximum: 5368709120
          description: The file's length in bytes, such as from `wc -c < return.pdf`.
        checksum:
          type: string
          pattern: '^[A-Za-z0-9+/]{22}==$'
          description: The file's MD5 digest in base64, such as from `openssl md5 -binary return.pdf | base64`.

    FileUpload:
      type: object
      required: [object, file_id, url, method, headers, expires_at]
      properties:
        object:
          type: string
          enum: [file_upload]
        file_id:
          type: string
          pattern: '^file_[A-Za-z0-9]+$'
          description: The file the PDF becomes once it is PUT to url. Pass it to a tax upload.
        url:
          type: string
          format: uri
          description: Signed storage URL to PUT the PDF's bytes to. Treat it as a secret.
        method:
          type: string
          enum: [PUT]
        headers:
          type: object
          description: Headers to send with the PUT, exactly as given (Content-Type, Content-MD5 and Content-Disposition).
          additionalProperties:
            type: string
        expires_at:
          type: integer
          description: Unix seconds after which url stops working, 10 minutes after the call. The file itself can be attached for 24 hours.

    Tax:
      type: object
      required: [id, object, type, year, jurisdictions, unmapped_jurisdictions, filing_status, filed_at, filed_elsewhere, extension_filed_at, documents, created, updated]
      properties:
        id:
          type: string
          pattern: '^tax_(income|franchise)_[A-Za-z0-9]+$'
          description: Stable opaque ID. Income and franchise IDs have separate prefixes and cannot collide.
        object:
          type: string
          enum: [tax]
        type:
          type: string
          enum: [income, franchise]
        year:
          type: integer
        jurisdictions:
          type: array
          description: Covered jurisdictions. Income records include US and known recorded states; Delaware franchise records contain only US-DE. The upload alias washington_dc and district_of_columbia both map to US-DC. Status and documents apply to the shared record, not independently to each jurisdiction.
          uniqueItems: true
          items:
            type: string
            pattern: '^US(-[A-Z]{2})?$'
        unmapped_jurisdictions:
          type: array
          description: Stored state names that cannot be mapped to a supported jurisdiction code. Empty when every stored state is recognized.
          items:
            type: string
        filing_status:
          type: string
          enum: [onboarding, preparing, review_required, filing, filed]
          description: |
            Recorded filing progress. onboarding means filing onboarding is not yet completed;
            preparing means onboarding is completed and draft preparation is pending;
            review_required means draft preparation is completed and income review/signing
            or Delaware payment information is needed; filing means review/signing is completed
            and filing completion is pending; filed means the filing workflow is recorded as
            completed. It does not establish tax payment or authority acceptance. Handle future
            unfamiliar values without assuming completion.
        filed_at:
          type: integer
          nullable: true
          description: Recorded filing-workflow completion time in Unix seconds when filing is completed. Null when unavailable. Never substitutes updated. For uploaded returns this may be when completion was recorded in Finta.
        filed_elsewhere:
          type: boolean
          description: Whether the record is an uploaded income return or a Delaware filing marked already filed outside Finta.
        extension_filed_at:
          type: integer
          nullable: true
          description: Recorded income-extension filing time in Unix seconds; null for franchise taxes or when no extension is recorded.
        documents:
          type: array
          description: Available PDF attachments, which may be absent even on a filed record. Income records expose their draft/final package; franchise records expose their confirmation PDF. No bank or signing credentials are returned.
          items:
            $ref: '#/components/schemas/TaxDocument'
        created:
          type: integer
          description: Record creation time in Unix seconds.
        updated:
          type: integer
          description: Record update time in Unix seconds; not an incremental-sync watermark for related attachments.

    TaxDocument:
      type: object
      required: [type, filename, content_type, download_url, expires_at]
      properties:
        type:
          type: string
          enum: [draft_return, filed_return, filing_confirmation]
          description: Income packages are draft_return before recorded filing completion and filed_return afterward. filing_confirmation is an available Delaware franchise filing confirmation.
        filename:
          type: string
        content_type:
          type: string
          enum: [application/pdf]
        download_url:
          type: string
          format: uri
          description: Signed attachment download URL; treat it as a temporary credential.
        expires_at:
          type: integer
          description: Approximate download URL expiry in Unix seconds, five minutes after retrieval.

    BulkJobError:
      description: Stable failure detail. transaction_id is null for job-level failures; param may be null. Codes include invalid_request (malformed update), permission_denied (actor access revoked), resource_missing (unavailable target/reference), transaction_limit_exceeded (over 5,000 matches), transaction_conflict (relevant state changed), transaction_update_failed (shared eligibility or write failure), and update_failed (execution exhausted or failed). Shared selection failures may supply other codes; tolerate unfamiliar codes and inspect the message. A failed job commits no edits.
      type: object
      required:
      - transaction_id
      - code
      - param
      - message
      properties:
        transaction_id:
          type: string
          nullable: true
        code:
          type: string
        param:
          type: string
          nullable: true
        message:
          type: string
      additionalProperties: false
    BulkJobErrorList:
      type: object
      required:
      - object
      - url
      - has_more
      - next_cursor
      - data
      properties:
        object:
          type: string
          enum:
          - list
        url:
          type: string
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
        data:
          type: array
          items:
            "$ref": "#/components/schemas/BulkJobError"
      additionalProperties: false
    BulkJob:
      type: object
      required:
      - id
      - object
      - type
      - status
      - matched_count
      - updated_count
      - errors
      properties:
        id:
          type: string
          pattern: "^job_[A-Za-z0-9]+$"
        object:
          type: string
          enum:
          - job
        type:
          type: string
          enum:
          - bulk_update_transactions
        status:
          description: queued awaits execution; processing has a frozen selection; success has committed edits and durable reporting refresh intent; failed has no committed edits. Reporting refresh can finish after success.
          type: string
          enum:
          - queued
          - processing
          - success
          - failed
        matched_count:
          description: Null until selection succeeds, then the number of frozen targets. A successful empty selection is zero.
          type: integer
          nullable: true
          minimum: 0
        updated_count:
          description: Distinct transactions actually changed, including assignment provenance, counted once each. Failed jobs always report zero.
          type: integer
          minimum: 0
        errors:
          "$ref": "#/components/schemas/BulkJobErrorList"
      additionalProperties: false
    SourceAccount:
      type: object
      required: [id, object, account_name, vendor, type, reconciled_through]
      properties:
        id:
          type: string
          pattern: "^acct_[A-Za-z0-9]+$"
          description: Stable, opaque account identity. Retained when the backing provider record is deleted.
        object:
          type: string
          enum: [account]
        account_name:
          type: string
          nullable: true
          description: Display label from synchronized metadata; null when unknown. Names may change or be duplicated and must not be used as identifiers.
        vendor:
          type: string
          description: Lowercase source vendor label. This is an open string, not a closed enum.
        type:
          type: string
          nullable: true
          description: Stored source classification, such as bank, credit, reimbursement, or intercompany_expense; null when unknown. This is an open string, not a closed enum.
        reconciled_through:
          type: string
          format: date
          nullable: true
          description: >-
            The date this account is reconciled through, as `YYYY-MM-DD`: the end of its latest
            reconciliation. Transactions on or before it are locked, so their amount, date and
            account can't change and uploads can't add to that range; categories can. Null when the
            account has never been reconciled or can't be reconciled.
      additionalProperties: false
    FeedbackCreate:
      type: object
      required: [type, message]
      properties:
        type:
          type: string
          enum: [question, feedback, bug]
          description: "`question` when you want an answer, `bug` when something is broken, `feedback` for anything else."
        subject:
          type: string
          minLength: 1
          maxLength: 200
          description: >-
            Optional subject line. Surrounding whitespace is removed. Left out, the subject is the
            start of `message` (up to 80 characters).
        message:
          type: string
          minLength: 1
          maxLength: 10000
          description: The message to support. Surrounding whitespace is removed.
        reported_by:
          type: string
          enum: [user, agent]
          default: user
          description: >-
            `user` when the authenticated user wrote it. `agent` when an AI agent acting for the
            user reports something on its own; the ticket still comes from the user, marked as
            not written by them.
        model:
          type: string
          maxLength: 100
          description: >-
            The AI model sending this, as the agent reports it (such as `claude-opus-5-5`). Shown to
            support as reported, not verified.
        goal:
          type: string
          maxLength: 300
          description: >-
            What the user was trying to get done, in the sender's own words, such as `Split a payroll
            charge across two departments`. Shown to support under its own heading below the message.
            Surrounding whitespace is removed; blank is the same as leaving it out.
        steps_tried:
          type: string
          maxLength: 1000
          description: >-
            What was tried before reaching out, in order, and what each step returned, so support can
            reproduce the problem. An agent lists the Finta calls it made. Shown to support under its own
            heading below the message. Surrounding whitespace is removed; blank is the same as leaving it out.
      additionalProperties: false
    Feedback:
      type: object
      required: [id, object, type, reported_by]
      properties:
        id:
          type: string
          description: The support ticket's ID.
        object:
          type: string
          enum: [feedback]
        type:
          type: string
          enum: [question, feedback, bug]
        reported_by:
          type: string
          enum: [user, agent]
    SourceAccountCreate:
      type: object
      required: [account_name, vendor, type]
      properties:
        account_name:
          type: string
          minLength: 1
          description: Display name for the account, such as `Chase Checking 4821`. Surrounding and repeated whitespace is collapsed.
        vendor:
          type: string
          minLength: 1
          description: >-
            The bank or card issuer, such as `chase`. Returned lowercase and formatted for display
            (`JPMorgan` comes back as `jp morgan`); posting the returned form matches the same account.
        type:
          type: string
          enum: [bank, credit]
          description: "`bank` for a checking or savings account, `credit` for a credit card."
      additionalProperties: false
    SourceCard:
      type: object
      required:
      - id
      - object
      - account_id
      - name
      - last_four
      - type
      - status
      properties:
        id:
          type: string
          pattern: "^card_[A-Za-z0-9]+$"
        object:
          type: string
          enum:
          - card
        account_id:
          description: Stable account ID from the existing Brex card-account, Mercury credit-account, or Slash polymorphic account association. Null for unproven relationships, Ramp, Rho, or a deleted backing card. Serialization and account filtering use the same associations; no name/vendor inference.
          type: string
          nullable: true
          pattern: "^acct_[A-Za-z0-9]+$"
        name:
          description: Nullable, nonunique display label from synchronized identity metadata, including supported display overrides and the last known label after deletion. Never use it as an identifier.
          type: string
          nullable: true
        last_four:
          description: Exactly four digits when known; otherwise null, including unavailable backing records and Rho.
          pattern: "^[0-9]{4}$"
          type: string
          nullable: true
        type:
          description: Physical means a physical card; virtual means a virtual card. Brex uses card_type, Ramp/Slash use is_physical. Mercury uses a separate physical_card_status allowlist (active, inactive, locked, shipped, terminated, cancelled/canceled, suspended, deleted, frozen, expired, closed); blank means virtual. This allowlist is independent of card status normalization. Unsupported metadata, Rho, and unavailable backing cards return null.
          type: string
          nullable: true
          enum:
          - physical
          - virtual
          -
        status:
          description: Active maps a known active provider status. Inactive maps inactive, locked, shipped, terminated, cancelled/canceled, suspended, deleted, frozen, expired, or closed (case-insensitive). Unsupported or blank values, Rho, and unavailable backing cards return null. This is stored provider state, not a live availability check.
          type: string
          nullable: true
          enum:
          - active
          - inactive
          -
      additionalProperties: false
    Form1099List:
      type: object
      additionalProperties: false
      required: [object, url, has_more, next_cursor, summaries, data]
      properties:
        object: { type: string, enum: [list] }
        url: { type: string, enum: [/api/v1/forms/1099] }
        has_more: { type: boolean }
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for the next page, or null when has_more is false.
        summaries:
          type: array
          description: Complete annual summaries, newest year first. Only year filters these; record filters and pagination do not. Explicit missing years have one unknown summary; unfiltered discovery lists only existing reports.
          items: { "$ref": "#/components/schemas/Form1099Summary" }
        data:
          type: array
          items: { "$ref": "#/components/schemas/Form1099" }

    Form1099Summary:
      type: object
      additionalProperties: false
      required: [year, filing_status, counts]
      properties:
        year: { type: integer, minimum: 1000, maximum: 9999 }
        filing_status:
          type: string
          enum: [unknown, review_required, pending, filed, not_required]
          description: |
            unknown means no report exists for the explicitly requested year, not no obligations.
            For existing reports, review_required means at least one record is unconfirmed and
            takes precedence over pending; pending means confirmed required filings remain
            incomplete; filed means all tracked required filings are complete and at least one
            is filed; not_required means there are no tracked required or unconfirmed records,
            including an empty report. These summarize Finta's known records, not a separate
            legal assessment. filing is counted as pending, never filed.
        counts:
          type: object
          additionalProperties: false
          required: [review_required, required, pending, filed]
          properties:
            review_required:
              type: integer
              minimum: 0
              description: Records whose requirement has not been confirmed. Excluded from required and pending.
            required:
              type: integer
              minimum: 0
              description: Confirmed required records, including already filed. Always pending + filed. Excludes unconfirmed and not-required records.
            pending:
              type: integer
              minimum: 0
              description: Confirmed required records in w9_required, w9_requested, ready_to_file, or filing.
            filed:
              type: integer
              minimum: 0
              description: Completed tracked records, including reported filings outside Finta. Never includes filing in progress.

    Form1099:
      type: object
      additionalProperties: false
      required: [id, object, year, vendor_name, filing_status, filed_at, filed_elsewhere, documents, created, updated]
      properties:
        id:
          type: string
          pattern: '^f1099_[A-Za-z0-9]+$'
          description: Stable public filing-record identity. Issued IDs and their unique index are retained across schema rollback and reapplication.
        object: { type: string, enum: [form_1099] }
        year:
          type: integer
          minimum: 1000
          maximum: 9999
          description: Reporting year, independent of the year of filing.
        vendor_name:
          type: string
          description: Vendor display name. Not a unique recipient identity; merchant/category grouping can yield multiple same-name records in one year.
        filing_status:
          type: string
          enum: [review_required, w9_required, w9_requested, ready_to_file, filing, filed, not_required]
          description: |
            review_required: whether filing is required needs confirmation.
            w9_required: confirmed required and waiting for W-9 collection, with no current
            W-9 request stage recorded through Finta.
            w9_requested: Finta recorded a W-9 request and the workflow is awaiting collection.
            ready_to_file: ready for the user to pay and request filing.
            filing: filing requested and processing, not completed.
            filed: recorded as complete through Finta or elsewhere; PDF may be unavailable.
            not_required: the record was marked as not requiring filing by the user or Finta.
            These are workflow stages, not an IRS acceptance or document-presence guarantee.
        filed_at:
          type: integer
          nullable: true
          description: Recorded filing time in Unix seconds for filings through Finta. Null if unknown, unfiled, or filed elsewhere. The time when a user marks filed elsewhere and updated are never substituted for a filing date.
        filed_elsewhere:
          type: boolean
          description: True only for records reported as filed outside Finta. Such records have filing_status filed and count toward filed totals. False for all other stages.
        documents:
          type: array
          description: Available attachments with signed links. Empty when none are available. Does not establish requirement, filing completion, or IRS acceptance; a filed record can have no documents.
          items: { "$ref": "#/components/schemas/Form1099Document" }
        created:
          type: integer
          description: Unix seconds when this tracked filing record was created.
        updated:
          type: integer
          description: Unix seconds when the tracked workflow record was last modified. Not a guarantee that vendor names or all attachment changes have changed this timestamp; no incremental-sync filter is offered.

    Form1099Document:
      type: object
      additionalProperties: false
      required: [id, type, filename, content_type, download_url, expires_at]
      properties:
        id:
          type: string
          pattern: '^doc_[a-f0-9]{24}$'
          description: Stable opaque identity of the attached file. Link refreshes preserve it; replacing the file changes it. No standalone document endpoint is offered.
        type: { type: string, enum: [w9, form_1099] }
        filename: { type: string }
        content_type:
          type: string
          nullable: true
          description: Stored MIME type, if known. Do not assume every attachment is a PDF.
        download_url:
          type: string
          description: Signed URL usable without API authentication for five minutes. Retrieve the form again to refresh the URL after expiry.
        expires_at:
          type: integer
          description: Unix timestamp in seconds when the signed download link expires, not when the filing or stored file expires.

    SourceCardList:
      type: object
      required:
      - object
      - url
      - has_more
      - next_cursor
      - data
      properties:
        object:
          type: string
          enum:
          - list
        url:
          type: string
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
        data:
          type: array
          items:
            "$ref": "#/components/schemas/SourceCard"
      additionalProperties: false
    SourceAccountList:
      type: object
      required: [object, url, has_more, next_cursor, data]
      properties:
        object:
          type: string
          enum: [list]
        url:
          type: string
        has_more:
          type: boolean
          description: Whether another page is available after the returned accounts.
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for starting_after; null on the final or empty page. Bound to this company and account collection.
        data:
          type: array
          description: Known account identities, oldest first; empty when no identities are available.
          items:
            $ref: "#/components/schemas/SourceAccount"
      additionalProperties: false
    Reconciliation:
      type: object
      required: [id, object, reconciled, account_id, account_name, period_start, period_end, finta_balance_cents, statement_balance_cents, reconciled_at, reconciled_by, reconciled_via]
      properties:
        id:
          type: string
          pattern: '^recon_[a-f0-9]{32}$'
          description: Derived from the account, its ledger category and the period's end date, so it stays the same when the period is reconciled or undone.
        object:
          type: string
          enum: [reconciliation]
        reconciled:
          type: boolean
        account_id:
          type: string
          description: The acct_ ID, as in `GET /accounts`.
        account_name:
          type: string
          nullable: true
        period_start:
          type: string
          format: date
        period_end:
          type: string
          format: date
          description: The calendar month's end, or the bank statement's date for statement-dated accounts.
        finta_balance_cents:
          type: integer
          nullable: true
          description: What Finta's books add up to through period_end, in the statement's terms (a credit card's balance owed is positive). Null for a period reconciled as part of a later one.
        statement_balance_cents:
          type: integer
          nullable: true
          description: The balance the period is checked against, entered by someone or supplied by a bank feed. Null until one is provided, and for a period reconciled as part of a later one.
        reconciled_at:
          type: integer
          nullable: true
          description: When it was reconciled, in Unix seconds. Null until reconciled.
        reconciled_by:
          type: string
          nullable: true
          description: The usr_ ID of who reconciled it. Null until reconciled, for the nightly job, and for reconciliations made before this was recorded.
        reconciled_via:
          type: string
          nullable: true
          enum: [web_app, api, mcp, null]
          description: How a person reconciled it. Null on the same conditions as reconciled_by.
      additionalProperties: false
    ReconciliationList:
      type: object
      required: [object, url, has_more, next_cursor, data]
      properties:
        object:
          type: string
          enum: [list]
        url:
          type: string
        has_more:
          type: boolean
        next_cursor:
          type: string
          nullable: true
          description: Opaque cursor for starting_after; null on the final or empty page.
        data:
          type: array
          items:
            $ref: '#/components/schemas/Reconciliation'
      additionalProperties: false
    ReconcileRequest:
      type: object
      required: [statement_balance_cents]
      properties:
        statement_balance_cents:
          type: integer
          description: The statement's ending balance in cents, as the statement prints it (a credit card's balance owed is positive).
        excluded_transaction_ids:
          type: array
          items:
            type: string
          description: txn_ IDs to leave out of this period, such as a duplicate or an item that clears in a later period. Transactions posted after the statement's cutoff are always left out.
      additionalProperties: false
    TransactionUploadRequest:
      type: object
      additionalProperties: false
      properties:
        account_id:
          type: string
          description: An acct_ ID from GET or POST /accounts. Required on the first call.
        upload_id:
          type: string
          nullable: true
          description: The `id` returned by the first call.
        file_totals:
          type: object
          additionalProperties: false
          required: [row_count, net_amount_cents]
          description: Computed from the file itself. Required on the first call; cannot change.
          properties:
            row_count:
              type: integer
              minimum: 1
              maximum: 2000
              description: Transaction rows in the file, including excluded ones.
            net_amount_cents:
              type: integer
              description: Sum of every row's amount, with the file's signs.
        columns:
          type: array
          description: The order of values in each row. Required with the first rows.
          items:
            type: string
            enum: [row, date, description, amount]
        rows:
          type: array
          maxItems: 500
          description: >-
            `row` is the 1-based row number; resending it replaces the row. `date` is as the file
            writes it, so Finta can tell month/day from day/month. `description` is at most 40
            characters. `amount` is a signed decimal such as `-45.00`.
          items:
            type: array
            items:
              nullable: true
              anyOf:
              - type: string
              - type: number
        date_format:
          type: string
          enum: [MDY, DMY]
          description: Order of numeric dates, when an open question asks.
        sign_convention:
          type: string
          enum: [negative_is_money_out, positive_is_money_out]
          description: How the file signs money out. Defaults to negative_is_money_out.
        exclude:
          type: array
          description: Rows to leave out. Adds to earlier exclusions.
          items:
            type: object
            additionalProperties: false
            required: [row, reason]
            properties:
              row:
                type: integer
                minimum: 1
              reason:
                type: string
                enum: [investment_trade, reversal_pair, duplicate, not_a_transaction, other]
              note:
                type: string
                maxLength: 500
        acknowledged_warnings:
          type: array
          description: Blocking warnings you accept, covering the rows they list now.
          items:
            type: string
            enum: [possible_duplicate, overlaps_existing_data, possible_investment_trade, possible_reversal_pair]
        allow_synced_account:
          type: boolean
          description: Upload to an account that syncs from an integration. Confirm with the user first.
        commit:
          type: boolean
          description: Import the upload if nothing blocks it.
    TransactionUploadIssue:
      type: object
      additionalProperties: false
      required: [code, rows, message, suggestion]
      properties:
        code:
          type: string
          description: |
            Errors: invalid_date, invalid_amount, missing_description, description_too_long, zero_amount, totals_mismatch,
            reconciled_period, import_in_progress (another upload to the account is still importing; retry shortly).
            Warnings: account_syncs_automatically, possible_duplicate, overlaps_existing_data,
            possible_investment_trade, possible_reversal_pair, future_dated.
        rows:
          type: array
          description: Affected rows; empty for whole-upload issues.
          items:
            type: integer
        blocking:
          type: boolean
          description: Warnings only. Must be excluded or acknowledged before commit.
        acknowledged:
          type: boolean
        message:
          type: string
        suggestion:
          type: string
    TransactionUploadQuestion:
      type: object
      additionalProperties: false
      required: [code, parameter, question, suggestion, default, why_default]
      properties:
        code:
          type: string
          enum: [date_format_ambiguous, sign_convention_unclear]
        parameter:
          type: string
          enum: [date_format, sign_convention]
          description: The parameter that answers it.
        question:
          type: string
        suggestion:
          type: string
        default:
          type: string
          description: What to pass when you can't tell.
        why_default:
          type: string
    TransactionUpload:
      type: object
      additionalProperties: false
      required: [id, object, account_id, status, rows_expected, rows_received, rows_remaining, totals_match, summary, errors, warnings, open_questions, ready_to_commit, next_step, result]
      properties:
        id:
          type: string
          description: Pass as upload_id on later calls.
        object:
          type: string
          enum: [transaction_upload]
        account_id:
          type: string
        status:
          type: string
          enum: [draft, importing, imported, failed]
        rows_expected:
          type: integer
        rows_received:
          type: integer
        rows_remaining:
          type: integer
        totals_match:
          type: boolean
          nullable: true
          description: Null until every row is received and readable.
        summary:
          type: object
          additionalProperties: false
          required: [start_date, end_date, rows_to_upload, excluded, skipped_exact_duplicates, money_in_cents, money_out_cents]
          description: What a commit would import, or did.
          properties:
            start_date:
              type: string
              format: date
              nullable: true
            end_date:
              type: string
              format: date
              nullable: true
            rows_to_upload:
              type: integer
            excluded:
              type: integer
            skipped_exact_duplicates:
              type: integer
            money_in_cents:
              type: integer
            money_out_cents:
              type: integer
              description: Zero or negative.
        errors:
          type: array
          description: Fix by resending or excluding rows; no override.
          items:
            "$ref": "#/components/schemas/TransactionUploadIssue"
        warnings:
          type: array
          items:
            "$ref": "#/components/schemas/TransactionUploadIssue"
        open_questions:
          type: array
          items:
            "$ref": "#/components/schemas/TransactionUploadQuestion"
        ready_to_commit:
          type: boolean
        next_step:
          type: string
        result:
          type: object
          nullable: true
          additionalProperties: false
          description: Null while the upload is a draft.
          required: [uploaded, excluded, skipped_exact_duplicates, auto_categorized, url, failure_message]
          properties:
            uploaded:
              type: integer
            excluded:
              type: array
              items:
                type: object
                additionalProperties: false
                required: [row, reason]
                properties:
                  row:
                    type: integer
                  reason:
                    type: string
                  note:
                    type: string
            skipped_exact_duplicates:
              type: array
              items:
                type: integer
            auto_categorized:
              type: integer
              nullable: true
              description: >-
                Set once status is imported. The rows a rule or Finta's automatic categorization put in a
                category. Rows left in Uncategorized Expenses or Uncategorized Income don't count, even
                though Finta approves them.
            url:
              type: string
              description: The upload in Finta, where it can be reviewed or undone.
            failure_message:
              type: string
              nullable: true
    BulkTransactionFilters:
      description: Exactly these eleven filters are public; combine supplied fields with AND against pre-update values. Selects all matches without pagination. start_date and end_date are inclusive and end_date must not precede start_date. Explicit null is invalid for filters.
      type: object
      properties:
        merchant:
          type: string
          minLength: 1
          pattern: "\\S"
        category_id:
          type: string
          pattern: "^cat_[A-Za-z0-9]+$"
        account_id:
          type: string
          pattern: "^acct_[A-Za-z0-9]+$"
        card_id:
          type: string
          pattern: "^card_[A-Za-z0-9]+$"
        start_date:
          type: string
          format: date
          pattern: "^(?!0000)[0-9]{4}-[0-9]{2}-[0-9]{2}$"
        end_date:
          type: string
          format: date
          pattern: "^(?!0000)[0-9]{4}-[0-9]{2}-[0-9]{2}$"
        status:
          type: string
          enum:
          - approved
          - pending
        transaction_type:
          type: string
          enum:
          - standard
          - transfer
        categorized:
          description: A JSON boolean; false selects uncategorized transactions and must not be omitted or converted to a string.
          type: boolean
        no_department:
          description: >-
            The JSON boolean true, the only accepted value. Selects transactions that need a department but don't
            have one, as `no_department` does on `GET /transactions`. A selected transaction that gets a department,
            moves its accounting date, or falls before a newly moved department cutoff before the job runs fails the
            job. A company without departments gets 403. See "Early access endpoints" under Errors.
          type: boolean
          enum: [true]
        department_id:
          description: Selects transactions in this department only, not its sub-departments, as `department_id` does on `GET /transactions`. A company without departments gets 403. See "Early access endpoints" under Errors.
          type: string
          pattern: "^dept_[A-Za-z0-9]+$"
      additionalProperties: false
      minProperties: 1
    BulkTransactionUpdates:
      description: Omitted fields are unchanged. Null clears department/spread or restores the original date. merchant and category_id cannot be null. accounting_date and accounting_shift cannot coexist, including a null date. Shared eligibility restrictions apply atomically at execution.
      type: object
      properties:
        merchant:
          type: string
          minLength: 1
          maxLength: 40
          pattern: "\\S"
        category_id:
          type: string
          pattern: "^cat_[A-Za-z0-9]+$"
        department_id:
          type: string
          nullable: true
          pattern: "^dept_[A-Za-z0-9]+$"
          description: A company without departments gets 403 for this, including null. See "Early access endpoints" under Errors.
        spread:
          type: integer
          nullable: true
          minimum: 1
        accounting_date:
          type: string
          nullable: true
          format: date
        accounting_shift:
          description: Integer calendar months from each frozen effective accounting date, clamped once to the destination month. Zero preserves the current date or absent override, including on transfers; other edits still validate. Nonzero shifts retain date and transfer restrictions.
          type: integer
          minimum: -36
          maximum: 36
      additionalProperties: false
      minProperties: 1
      not:
        required:
        - accounting_date
        - accounting_shift
    BulkTransactionRequest:
      description: Exactly one non-empty selector and a non-empty updates object; no unknown top-level keys.
      oneOf:
      - type: object
        required: [filters, updates]
        properties:
          filters:
            "$ref": "#/components/schemas/BulkTransactionFilters"
          updates:
            "$ref": "#/components/schemas/BulkTransactionUpdates"
        additionalProperties: false
      - type: object
        required: [transaction_ids, updates]
        properties:
          transaction_ids:
            type: array
            minItems: 1
            maxItems: 5000
            uniqueItems: true
            items:
              type: string
              pattern: "^txn_[A-Za-z0-9]+$"
          updates:
            "$ref": "#/components/schemas/BulkTransactionUpdates"
        additionalProperties: false

    Team:
      type: object
      required: [object, members, invitations]
      properties:
        object:
          type: string
          enum: [team]
        members:
          type: array
          description: Active and inactive company members in the Settings team page order.
          items:
            $ref: "#/components/schemas/Member"
        invitations:
          type: array
          description: Pending invitations in the Settings team page order.
          items:
            $ref: "#/components/schemas/Invitation"

    Member:
      type: object
      required: [id, object, email, name, state, access_template, permissions, created]
      properties:
        id:
          type: string
          pattern: '^usr_[A-Za-z0-9]+$'
          nullable: true
          description: Public user identifier, or null when unavailable. UUIDs and numeric IDs are never returned. The same user has the same public ID across companies.
          example: usr_a1b2c3d4e5f6g7
        object:
          type: string
          enum: [member]
        email:
          type: string
          example: alex@example.com
        name:
          type: string
          nullable: true
          description: The member's stored first and last names with surrounding whitespace trimmed, joined with a space using whichever parts are present. Null when both are blank; never derived from email.
          example: Alex Chen
        state:
          type: string
          enum: [active, inactive]
          description: Membership state in this company. Active means an active membership; inactive means a deactivated membership retained in the directory. Deleted memberships are excluded.
        access_template:
          $ref: "#/components/schemas/TeamAccessTemplate"
        permissions:
          $ref: "#/components/schemas/TeamPermissions"
        created:
          type: integer
          description: Unix timestamp when this company membership was created, not when the user registered with Finta.
          example: 1751371200

    CreateTeamInvitation:
      type: object
      additionalProperties: false
      required: [first_name, last_name, email, access_template]
      properties:
        first_name:
          type: string
          minLength: 1
          pattern: '\S'
        last_name:
          type: string
          minLength: 1
          pattern: '\S'
        email:
          type: string
          minLength: 1
          description: A valid email address. Email validity and existing teammate checks are enforced by the invitation service.
        access_template:
          $ref: '#/components/schemas/TeamAccessTemplate'
        permissions:
          $ref: '#/components/schemas/TeamPermissions'
      oneOf:
        - type: object
          properties:
            access_template:
              type: string
              enum: [admin, read_only]
          not:
            type: object
            required: [permissions]
        - type: object
          required: [permissions]
          properties:
            access_template:
              type: string
              enum: [custom]

    UpdateTeamInvitation:
      type: object
      additionalProperties: false
      required: [access_template]
      properties:
        access_template:
          $ref: '#/components/schemas/TeamAccessTemplate'
        permissions:
          $ref: '#/components/schemas/TeamPermissions'
      oneOf:
        - type: object
          properties:
            access_template:
              type: string
              enum: [admin, read_only]
          not:
            type: object
            required: [permissions]
        - type: object
          required: [permissions]
          properties:
            access_template:
              type: string
              enum: [custom]

    InvitationOperation:
      type: object
      additionalProperties: false
      required: [invitation, changed]
      properties:
        invitation:
          $ref: '#/components/schemas/Invitation'
        changed:
          type: boolean
          description: False for a newly created invitation (201) and an unchanged replay. True when an existing invitation is renewed, its permissions change, or delivery work is requested. Not a guarantee that email arrived; use HTTP 201 to identify creation.

    Invitation:
      type: object
      additionalProperties: false
      required: [id, object, email, name, access_template, permissions, created, invited_by, expires, version]
      properties:
        version:
          type: string
          description: Informational opaque revision of the saved invitation, also returned by GET /team. Resend loads the current revision internally and requires no version input. This is not an acceptance credential. Delivery completion alone does not change this revision.
          example: '2026-09-09T17:20:00.000000Z'
        id:
          type: string
          pattern: '^invite_[A-Za-z0-9]+$'
          nullable: true
          description: Public invitation identifier, or null when unavailable. UUIDs, numeric IDs, and secret acceptance tokens are never returned.
          example: invite_h8i9j0k1l2m3n4
        invited_by:
          type: string
          pattern: '^usr_[A-Za-z0-9]+$'
          nullable: true
          description: Public user ID of the original inviter, or null when unavailable. Preserved when another user resends the invitation or the inviter becomes inactive. The inviter may no longer appear in members. UUIDs and numeric IDs are never returned.
          example: usr_a1b2c3d4e5f6g7
        expires:
          type: integer
          description: Unix timestamp in seconds of the current acceptance deadline. An invitation is expired when this value is at or before the current time. Pending invitations can be expired; resending extends this deadline without changing created or invited_by.
          example: 1791568800
        object:
          type: string
          enum: [invitation]
        email:
          type: string
          example: sam@example.com
        name:
          type: string
          nullable: true
          description: The invitee's stored first and last names with surrounding whitespace trimmed, joined with a space using whichever parts are present. Null when both are blank; never derived from email.
          example: Sam Rivera
        access_template:
          $ref: "#/components/schemas/TeamAccessTemplate"
        permissions:
          $ref: "#/components/schemas/TeamPermissions"
        created:
          type: integer
          description: Unix timestamp when the invitation was created. Resending does not change this value.
          example: 1788976800

    TeamAccessTemplate:
      type: string
      enum: [admin, read_only, custom]
      description: The template matching stored permissions. Admin means all areas have manage access. Read_only means view access for every area except api_access, which is none. Custom means neither template matches exactly; inspect permissions for the actual access.

    TeamPermissions:
      type: object
      additionalProperties: false
      description: Stored company permissions shown on the Settings team page. All areas are always included. None means no access to the area; view means read access; manage means management access. API access supports none or manage; other areas also support view.
      required: [dashboard, transactions, accounting_setup, reconciliation, invoices, reports, forecast, taxes, integrations, company, team_access, billing, ask_finta, api_access]
      properties:
        dashboard:
          type: string
          enum: [none, view, manage]
        transactions:
          type: string
          enum: [none, view, manage]
        accounting_setup:
          type: string
          enum: [none, view, manage]
        reconciliation:
          type: string
          enum: [none, view, manage]
        invoices:
          type: string
          enum: [none, view, manage]
        reports:
          type: string
          enum: [none, view, manage]
        forecast:
          type: string
          enum: [none, view, manage]
        taxes:
          type: string
          enum: [none, view, manage]
        integrations:
          type: string
          enum: [none, view, manage]
        company:
          type: string
          enum: [none, view, manage]
        team_access:
          type: string
          enum: [none, view, manage]
        billing:
          type: string
          enum: [none, view, manage]
        ask_finta:
          type: string
          enum: [none, view, manage]
        api_access:
          type: string
          enum: [none, manage]

    Company:
      type: object
      required:
        - id
        - object
        - name
        - created
      properties:
        id:
          type: string
          description: Unique identifier with `comp_` prefix.
          example: comp_a1b2c3d4e5f6g7
        object:
          type: string
          enum:
            - company
        name:
          type: string
          description: The company's preferred display name.
          example: Acme Corp
        legal_name:
          type: string
          nullable: true
          description: The company's legal name.
          example: Acme Corp Inc.
        entity_type:
          type: string
          nullable: true
          enum:
            - c_corp
            - pbc
            - llc
            - s_corp
            - other
          description: >-
            Legal entity type. Closed enum: `c_corp` (C corporation), `pbc` (public benefit corporation),
            `llc` (limited liability company), `s_corp` (S corporation), `other` (any other legal structure
            the company self-identified). Returns `null` when the company has not yet completed the
            onboarding step that captures legal structure. New values may be added over time as the
            onboarding questionnaire grows; consumers should treat unrecognized values defensively.
          example: c_corp
        federal_ein_last4:
          type: string
          nullable: true
          description: Last 4 digits of the company's Federal Employer Identification Number. The full EIN is not exposed via the API.
          example: "6789"
        delaware_file_number:
          type: string
          nullable: true
          description: Delaware file number, if applicable.
        incorporation:
          type: object
          nullable: true
          properties:
            date:
              type: string
              nullable: true
              format: date
              description: Date of incorporation (YYYY-MM-DD).
            state:
              type: string
              nullable: true
              description: State of incorporation (USPS 2-letter code, uppercase).
              example: DE
        legal_address:
          type: object
          nullable: true
          properties:
            line_1:
              type: string
              nullable: true
              example: "123 Main St"
            line_2:
              type: string
              nullable: true
              example: "Suite 100"
            city:
              type: string
              nullable: true
              example: "San Francisco"
            state:
              type: string
              nullable: true
              description: State (USPS 2-letter code, uppercase).
              example: "CA"
            postal_code:
              type: string
              nullable: true
              example: "94105"
            country:
              type: string
              nullable: true
              description: Country (ISO 3166-1 alpha-2 code).
              example: "US"
        created:
          type: integer
          description: Unix timestamp of when the company was created.
          example: 1737000000

    Onboarding:
      type: object
      required:
        - object
        - status
        - answers
        - questions
        - decision
        - integrations
        - sync
        - notify_when_synced
        - next_step
      properties:
        object:
          type: string
          enum:
            - onboarding
        status:
          type: string
          enum:
            - questionnaire
            - ready_to_submit
            - call_required
            - not_eligible
            - connect_banks
            - syncing
            - complete
          description: >-
            Where the company is in onboarding. `questionnaire`: required questions are unanswered.
            `ready_to_submit`: every required question is answered and Finta hasn't decided yet.
            `call_required`: Finta needs a call with the company before it can onboard it.
            `not_eligible`: Finta can't onboard the company. `connect_banks`: approved, and the
            selected banks and cards are being connected. `syncing`: Finta is importing transactions
            and setting up the books, or a completed web-started import awaits `finish_in_web`.
            `complete`: the company is onboarded and every endpoint is available.
        answers:
          $ref: "#/components/schemas/OnboardingAnswers"
        questions:
          type: array
          description: >-
            The questions still to ask at this stage, in order: the required ones unanswered, and
            `credit_cards`, which is optional, until a card is chosen. Empty once there is nothing
            to ask. Once an answer means Finta can't onboard the company, later questions aren't asked.
            Entity and industry questions show short choice lists. Bank and card questions have
            empty `options`; gather answers from context or plain text. Accepted values are in
            `OnboardingUpdate.answers` and the MCP `update_onboarding` input enums.
          items:
            $ref: "#/components/schemas/OnboardingQuestion"
        decision:
          type: object
          nullable: true
          description: Finta's eligibility decision. `null` until Finta has decided.
          required:
            - outcome
            - reason
            - decided
          properties:
            outcome:
              type: string
              enum:
                - approved
                - call_required
                - not_eligible
              description: "`approved`: Finta can onboard the company. `call_required`: after a call. `not_eligible`: Finta can't onboard it."
            reason:
              type: string
              nullable: true
              description: Why the company isn't eligible, as the web app tells the user. `null` for any other outcome.
              example: Unfortunately, Finta cannot support crypto startups.
            decided:
              type: integer
              nullable: true
              description: Unix timestamp of the decision. `null` for companies decided before Finta recorded the time.
              example: 1792540579
        integrations:
          type: array
          description: >-
            The banks and cards the company selected that onboarding waits on, in this order: Mercury,
            Brex, Ramp, Rho, SVB, Chase. A bank or card typed in as "other" isn't listed. Follow `next_step.message`
            for connecting them: OAuth and Plaid banks, including Chase and SVB, through the API. Plaid reconnects still require the web app.
          items:
            type: object
            required:
              - key
              - name
              - status
            properties:
              key:
                type: string
                enum:
                  - mercury
                  - brex
                  - ramp
                  - rho
                  - svb
                  - chase
                description: The integration's `key`, as in `GET /integrations`.
              name:
                type: string
                example: Mercury
              status:
                type: string
                enum:
                  - not_connected
                  - connected
                  - synced
                  - failed
                description: >-
                  `not_connected`: connect it. `connected`: connected, and its first sync hasn't
                  finished. `synced`: its first sync finished. `failed`: its first sync failed or the
                  connection needs reauthenticating. Follow `next_step`: reconnect only when reauthentication
                  is required; failed imports with active connections need Finta support.
        sync:
          type: object
          nullable: true
          description: The first sync's progress while `status` is `syncing`; `null` otherwise.
          required:
            - stage
            - check_again_in_seconds
          properties:
            stage:
              type: string
              enum:
                - downloading
                - categorizing
                - matching_transfers
                - failed
              description: >-
                `downloading`: Finta is downloading the transactions. `categorizing`: it's categorizing
                them. `matching_transfers`: it's matching transfers and preparing the dashboard, the last
                step. `failed`: a bank's sync failed; follow `next_step` to reconnect an expired connection
                or contact Finta support when the connection is still active.
            check_again_in_seconds:
              type: integer
              description: >
                The soonest a later check of `GET /onboarding` is useful, in seconds; not a polling
                interval. Re-check only after the user reports an action, asks about status or comes
                back later. Do not wait or poll.
              example: 30
        notify_when_synced:
          type: boolean
          description: Whether Finta emails the user when the first sync finishes, as the web app's "Email me when it's ready" sets.
        next_step:
          type: object
          description: What to do now. See `GET /onboarding` for what each action means.
          required:
            - action
            - message
          properties:
            action:
              type: string
              enum:
                - answer_questions
                - confirm_and_submit
                - book_call
                - contact_support
                - connect_banks
                - start_import
                - wait_for_sync
                - finish_in_web
                - none
            message:
              type: string
              description: Instructions for the agent, naming the banks to connect or the reason Finta gave.
            url:
              type: string
              format: uri
              description: Only with `book_call` (book a call with Finta) or `finish_in_web` (switch to the API company in Finta first, then open the completed web import and choose See dashboard).
              example: https://cal.com/finta/intro
            email:
              type: string
              format: email
              description: Only with `contact_support`. Finta support's email address.
              example: support@finta.com

    OnboardingAnswers:
      type: object
      description: >-
        The answers saved so far; `null` (or an empty list) when unanswered. `accounting_software`,
        `files_taxes_with`, `goals` and `subsidiary_countries` are asked only when Finta needs a call.
      required:
        - incorporation_year
        - legal_entity_type
        - legal_entity_state
        - industry
        - international_subsidiaries
        - banks
        - other_bank
        - credit_cards
        - other_credit_card
        - accounting_software
        - files_taxes_with
        - goals
        - subsidiary_countries
      properties:
        incorporation_year:
          type: integer
          nullable: true
          description: The year the company incorporated. `null` when the user gave only a range in the web app.
          example: 2024
        legal_entity_type:
          type: string
          nullable: true
          enum:
            - c_corp
            - pbc
            - llc
            - s_corp
            - other
            - null
          description: "`c_corp` (C corporation), `pbc` (public benefit corporation), `llc` (limited liability company), `s_corp` (S corporation) or `other`."
        legal_entity_state:
          type: string
          nullable: true
          description: >-
            The US state it incorporated in (USPS 2-letter code, uppercase). `null` until answered:
            the web app preselects Delaware, the API asks. Saved legacy state aliases return their
            mapped code, including DC; unmappable values return null. Echoing either preserves the saved value.
          example: DE
        industry:
          type: string
          nullable: true
          description: >-
            What it sells to make revenue: `software`, `services`, `marketplace`, `biotech`,
            `payments_and_financial_products`, `crypto` or `hardware`.
          example: software
        international_subsidiaries:
          type: boolean
          nullable: true
          description: Whether it has, or will soon have, foreign subsidiaries.
        banks:
          type: array
          description: >-
            The banks it uses: `mercury`, `brex`, `rho`, `svb`, `chase`, or `other` with `other_bank`.
            Recognized "other" providers are saved as their canonical key, such as `boa` for Bank of America.
            Canonical provider keys are also accepted directly for new answers. Saved `no_bank_yet` can be
            echoed unchanged but cannot be newly selected.
          items:
            type: string
          example:
            - mercury
        other_bank:
          type: string
          nullable: true
          description: The bank typed in under `other`.
        credit_cards:
          type: array
          description: >-
            The credit cards it uses: `mercury`, `brex`, `ramp`, or `other` with `other_credit_card`.
            Empty for none. An "other" card Finta can connect is saved as its own key instead, such as
            `american_express`. Canonical provider keys are also accepted directly for new answers.
            Saved `no_credit_cards_yet` can be echoed unchanged but cannot be newly selected.
          items:
            type: string
          example:
            - ramp
        other_credit_card:
          type: string
          nullable: true
          description: The card typed in under `other`.
        accounting_software:
          type: string
          nullable: true
          description: What it uses for accounting now. A call follow-up.
        files_taxes_with:
          type: string
          nullable: true
          description: What it uses to file taxes now. A call follow-up.
        goals:
          type: string
          nullable: true
          description: What it wants to accomplish with Finta. A call follow-up.
        subsidiary_countries:
          type: array
          description: The countries it has subsidiaries in, as the question's option values, such as `canada`. A call follow-up.
          items:
            type: string

    OnboardingQuestion:
      type: object
      example:
        key: legal_entity_type
        label: What is your legal entity type?
        type: single_select
        required: true
        options:
          - value: c_corp
            label: C Corporation
          - value: llc
            label: LLC
          - value: pbc
            label: Public benefit corporation
          - value: s_corp
            label: S Corporation
      required:
        - key
        - label
        - type
        - required
        - options
      properties:
        key:
          type: string
          description: The answer key to send in `PATCH /onboarding`'s `answers`.
          example: legal_entity_type
        label:
          type: string
          description: The question to cover conversationally, following `next_step.message`.
          example: What is your legal entity type?
        description:
          type: string
          description: >-
            Hints and private examples for accounting_software, files_taxes_with and goals,
            not to read aloud. Omitted for entity, industry, bank and card questions.
        type:
          type: string
          enum:
            - single_select
            - multi_select
            - boolean
            - year
            - text
          description: >-
            `single_select`: one accepted value. `multi_select`: a list of accepted values. `boolean`:
            true or false. `year`: a 4-digit year from 1900 to the current year. `text`: free text,
            up to 500 characters.
        required:
          type: boolean
          description: Whether Finta needs the answer to decide, or, after a call is required, before the call.
        options:
          type: array
          description: >-
            Suggested choices to show, in order; not the full set of accepted values (see
            `OnboardingUpdate.answers`). Entity: C Corporation (`c_corp`), LLC (`llc`), Public benefit
            corporation (`pbc`), S Corporation (`s_corp`). Industry: Software (`software`), Hardware
            (`hardware`), Marketplace (`marketplace`), Payments or fintech (`payments_and_financial_products`).
            Empty for banks and credit cards: gather answers from context or plain text and map
            to accepted input values. Their type remains `multi_select`. State choices are unchanged;
            also empty for `boolean`, `text` and `year`.
          items:
            type: object
            required:
              - value
              - label
            properties:
              value:
                oneOf:
                  - type: string
                  - type: integer
                description: What to send.
              label:
                type: string

    OnboardingUpdate:
      type: object
      additionalProperties: false
      properties:
        answers:
          type: object
          additionalProperties: false
          description: >-
            Only the answers to save, keyed as in `questions`. Answers left out keep their saved values.
            The enums below include accepted values hidden from the question's suggested choices.
            `null` clears an optional answer; a required one can't be cleared.
          properties:
            incorporation_year:
              type: integer
              nullable: true
              description: >-
                The year the company incorporated, such as 2024: any year from 1900 to the current
                year. A company incorporated 3 or more years ago needs a call with Finta before it
                can onboard.
            legal_entity_type:
              type: string
              nullable: true
              enum:
                - c_corp
                - pbc
                - llc
                - s_corp
                - other
                - null
            legal_entity_state:
              type: string
              nullable: true
              description: >-
                The USPS 2-letter code of one of the 50 US states, such as DE. An unchanged
                code or null returned for a saved legacy state is accepted, including DC;
                DC cannot be newly selected.
              example: DE
            industry:
              type: string
              nullable: true
              enum:
                - software
                - services
                - marketplace
                - biotech
                - payments_and_financial_products
                - crypto
                - hardware
                - null
            international_subsidiaries:
              type: boolean
              nullable: true
            banks:
              type: array
              nullable: true
              description: >-
                Every bank it uses. Recognized provider keys such as `boa` are accepted directly,
                or as `other` with `other_bank` and saved as the same key. `no_bank_yet` is accepted
                only when the bank/other-bank pair matches the saved answer; a new or changed
                selection containing it returns 400 invalid_request.
              items:
                type: string
                enum:
                  - mercury
                  - brex
                  - rho
                  - svb
                  - chase
                  - no_bank_yet
                  - other
                  - ramp
                  - american_express
                  - boa
                  - citi
                  - arc
                  - slash
            other_bank:
              type: string
              nullable: true
              maxLength: 500
            credit_cards:
              type: array
              nullable: true
              description: >-
                Every credit card it uses; empty for none. Recognized provider keys such as
                `american_express` are accepted directly, or as `other` with `other_credit_card`
                and saved as the same key. `no_credit_cards_yet` is accepted only when the
                card/other-card pair matches the saved answer; a new or changed selection
                containing it returns 400 invalid_request.
              items:
                type: string
                enum:
                  - mercury
                  - brex
                  - ramp
                  - no_credit_cards_yet
                  - other
                  - rho
                  - chase
                  - american_express
                  - boa
                  - citi
                  - arc
                  - slash
            other_credit_card:
              type: string
              nullable: true
              maxLength: 500
            accounting_software:
              type: string
              nullable: true
              maxLength: 500
            files_taxes_with:
              type: string
              nullable: true
              maxLength: 500
            goals:
              type: string
              nullable: true
              maxLength: 500
            subsidiary_countries:
              type: array
              nullable: true
              items:
                type: string
        submit:
          type: boolean
          description: >-
            `true` asks Finta to decide once no required question is unanswered. Send it only after the
            user explicitly says yes to a recap of every answer in a later turn; stop and wait after the recap.
            A yes picked in the app's multiple-choice question tool after the recap counts.
            Earlier confirmations do not count; never recap and submit in the same turn.
            Repeating it after Finta decided changes nothing.
        notify_when_synced:
          type: boolean
          description: "`true` to email the user who sends it when the first sync finishes; `false` to stop that email."

    Plan:
      type: object
      additionalProperties: false
      required:
        - id
        - object
        - name
        - description
        - price_cents
        - currency
        - interval
        - limits
        - features
      properties:
        id:
          type: string
          enum:
            - formation
            - startup
            - growth
            - enterprise
        object:
          type: string
          enum:
            - plan
        name:
          type: string
          example: Startup
        description:
          type: string
        price_cents:
          type: integer
          nullable: true
          minimum: 0
          description: Monthly list price in cents. A company's own price can be lower. Null for Enterprise, which is priced by contract.
          example: 9900
        currency:
          type: string
          enum:
            - USD
        interval:
          type: string
          nullable: true
          enum:
            - month
            - null
          description: Null when there's no list price, as for Enterprise.
        limits:
          type: object
          additionalProperties: false
          description: Each limit is null for Enterprise, where it's set by contract.
          required:
            - categorizations_per_month
            - transactions
            - api_calls_per_month
            - mcp_calls_per_month
            - ai_credits_per_month
          properties:
            categorizations_per_month:
              type: integer
              nullable: true
              description: Transactions Finta categorizes automatically each calendar month.
            transactions:
              type: integer
              nullable: true
              description: Total transactions a company can store. Not a monthly count.
            api_calls_per_month:
              type: integer
              nullable: true
              description: Public API requests per calendar month.
            mcp_calls_per_month:
              type: integer
              nullable: true
              description: MCP tool calls per calendar month.
            ai_credits_per_month:
              type: integer
              nullable: true
              description: AI credits included each calendar month, before any purchased packs.
        features:
          type: array
          description: The lines on the plan's card in Finta, in order, as plain text.
          items:
            type: string

    Category:
      type: object
      required:
        - id
        - object
        - name
        - description
        - account_type
        - point_in_time
        - tier
        - position
        - accounting_treatment
        - created
      properties:
        id:
          type: string
          description: Unique identifier with `cat_` prefix.
          example: cat_abc123
        object:
          type: string
          enum:
            - category
        name:
          type: string
          example: Software & SaaS
        description:
          type: string
          description: >-
            When to use this category, written for AI agents choosing a category for a transaction. Read it
            before picking a category. It is the same guidance Finta's AI categorizer uses, maintained by the
            Finta team and read when the request is made, so the text can change between requests. Empty
            string when the category has no description yet.
          example: Recurring subscriptions to software and SaaS tools, such as GitHub, Slack, or Figma.
        account_type:
          type: string
          nullable: true
          enum:
            - asset
            - liability
            - equity
            - income
            - expense
          description: Top-level accounting classification derived from the root ancestor category. Null if the category has no top-level ancestor.
          example: expense
        point_in_time:
          type: boolean
          description: Whether the category represents a cumulative value at a point in time rather than activity over a period. True for balance-sheet categories and false for income-statement categories.
          example: false
        tier:
          type: integer
          enum:
            - 1
            - 2
            - 3
            - 4
          description: Category depth (1 = top-level, 2 = sub-category, 3 = sub-sub-category, 4 = detail category).
        position:
          type: integer
          nullable: true
          description: Sort order within sibling categories at the same tier. Null if not explicitly set.
          example: 3
        parent_id:
          type: string
          nullable: true
          description: ID of the parent category, or null for top-level categories.
          example: cat_parent123
        accounting_treatment:
          type: string
          nullable: true
          description: >-
            What Finta does to the books when a transaction is categorized here.
            It is applied automatically and can't be turned off.
            Null when categorizing has no automatic accounting effect. Today only Equipment has one: the
            purchase is recorded as an asset and depreciated straight-line in monthly entries over 5 years.
          example: "Depreciated straight-line over 5 years: the purchase is recorded as an asset and expensed monthly as depreciation."
        created:
          type: integer
          description: Unix timestamp of when the category was created.
          example: 1688053841

    CategoryList:
      type: object
      required:
        - object
        - url
        - data
      properties:
        object:
          type: string
          enum:
            - list
        url:
          type: string
          description: The canonical path of the collection, relative to the API host.
          example: /api/v1/categories
        data:
          type: array
          items:
            $ref: "#/components/schemas/Category"

    CategoryCreate:
      type: object
      additionalProperties: false
      required:
        - template
      properties:
        template:
          type: string
          enum:
            - fulfillment
            - cost_of_services
            - rd_expenses
            - intercompany_expenses
            - equipment
            - inventory
            - investments
            - shareholder_dividends
          description: >-
            The standard category to add; see `POST /categories` for what each adds.
            `rd_expenses` needs the Startup plan or higher. `intercompany_expenses`, `investments`,
            and `shareholder_dividends` are added only by Finta and return `403` `request_required`.
          example: equipment

    RuleUpdate:
      type: object
      additionalProperties: false
      minProperties: 1
      description: Partial update. Conditions replace the list; actions merge by key with null for removal. The resulting definition must remain nonempty and valid. An actions-only empty object is rejected.
      anyOf:
        - required: [conditions]
        - required: [actions]
          properties:
            actions:
              minProperties: 1
      properties:
        conditions:
          type: array
          minItems: 1
          description: Replaces all conditions. Omission preserves existing conditions, including legacy text predicates.
          items:
            $ref: '#/components/schemas/RuleWriteCondition'
        actions:
          type: object
          additionalProperties: false
          description: Omitted action keys stay unchanged. Null removes a configured action. At least one action must remain after merging.
          properties:
            merchant:
              type: string
              nullable: true
              minLength: 1
              maxLength: 40
            category_id:
              type: string
              nullable: true
              pattern: '^cat_[A-Za-z0-9]+$'
            department_id:
              type: string
              nullable: true
              pattern: '^dept_[A-Za-z0-9]+$'
              description: A company without departments gets 403 for this, including null. See "Early access endpoints" under Errors.
            tag_id:
              type: string
              nullable: true
              pattern: '^tag_[A-Za-z0-9]+$'
              description: Omission preserves the saved action. Null removes the action and at least one action must remain. Malformed IDs return 400 invalid_request; unknown, foreign-company or deleted tags return 404 resource_missing. A tag deleted after lookup but before saving can instead return 400 invalid_request.
            spread:
              type: integer
              nullable: true
              minimum: 2
              maximum: 36
            accounting_date_offset_months:
              type: integer
              nullable: true
              minimum: -24
              maximum: 24

    RuleCreate:
      type: object
      additionalProperties: false
      required: [conditions, actions]
      properties:
        conditions:
          type: array
          minItems: 1
          description: Nonempty flat AND list. Repeated fields are allowed; all constraints must match.
          items:
            $ref: '#/components/schemas/RuleWriteCondition'
        actions:
          allOf:
            - $ref: '#/components/schemas/RuleActions'
            - type: object
              properties:
                merchant:
                  maxLength: 40
          description: Nonempty supported subset. Explicit nulls are rejected. Categories must be selectable and departments assignable; nonzero offsets cannot accompany Asset categories. A company without departments gets 403 for any `department_id`. See "Early access endpoints" under Errors.
        apply_to_existing_transactions:
          type: boolean
          default: true
          description: Create-only switch requesting asynchronous historical application. False saves for future matching only. A saved response does not confirm execution completed. Tag actions additionally fill missing tags on matching past transactions regardless of category or approval, excluding deleted transactions and split lines; other actions retain their existing eligibility.

    Rule:
      type: object
      additionalProperties: false
      required: [id, object, conditions, actions]
      description: >-
        The complete saved company automation definition. IDs are stable opaque rule_ identifiers,
        including for historical rules. No public condition IDs, priority, enable state, or historical
        application status are exposed. The create-time application flag is not response state.
      properties:
        id:
          type: string
          pattern: '^rule_[A-Za-z0-9]+$'
        object:
          type: string
          enum: [rule]
        conditions:
          type: array
          minItems: 1
          description: Flat AND conditions; repeated fields express multiple constraints. Text matches are case-insensitive.
          items:
            $ref: '#/components/schemas/RuleCondition'
        actions:
          $ref: '#/components/schemas/RuleActions'

    RuleCondition:
      description: >-
        merchant matches the assigned merchant name; description matches the transaction description.
        account_id and card_id match exact stable identities, never display names. Legacy account and
        card_name are compatibility text fields; duplicate names and contains can match multiple sources.
        Their original stored text is preserved. transaction_type currently represents transfer only;
        it is not the public transaction resource's standard/transfer/split enum. Signed amount_cents
        uses integer cents without absolute values or currency conversion. greater_than means >=,
        less_than means <=, and between includes both bounds, minimum first. Signed amounts express
        inflow/outflow constraints; there is no separate direction field. Consumers must handle legacy
        text fields and should tolerate future supported condition variants.
      oneOf:
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [merchant, description, account, card_name]
            operator:
              type: string
              enum: [equals, contains]
            value:
              type: string
              minLength: 1
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [account_id]
            operator:
              type: string
              enum: [equals]
            value:
              type: string
              pattern: '^acct_[A-Za-z0-9]+$'
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [card_id]
            operator:
              type: string
              enum: [equals]
            value:
              type: string
              pattern: '^card_[A-Za-z0-9]+$'
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [transaction_type]
            operator:
              type: string
              enum: [equals]
            value:
              type: string
              enum: [transfer]
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [amount_cents]
            operator:
              type: string
              enum: [equals, greater_than, less_than]
            value:
              type: integer
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [amount_cents]
            operator:
              type: string
              enum: [between]
            value:
              type: array
              minItems: 2
              maxItems: 2
              items:
                type: integer

    RuleWriteCondition:
      description: >-
        merchant matches the assigned merchant name; description matches the transaction description.
        account_id and card_id must be accessible exact stable identities. Legacy account and card_name
        text conditions are read-only compatibility fields and are rejected on create. Combined account/card
        references require a provable relationship; incompatible or unverifiable pairs are rejected. transaction_type currently represents transfer only;
        it is not the public transaction resource's standard/transfer/split enum. Signed amount_cents
        uses integer cents without absolute values or currency conversion. greater_than means >=,
        less_than means <=, and between includes both bounds, minimum first. Signed amounts express
        inflow/outflow constraints; there is no separate direction field. Consumers must handle legacy
        text fields and should tolerate future supported condition variants.
      oneOf:
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [merchant, description]
            operator:
              type: string
              enum: [equals, contains]
            value:
              type: string
              minLength: 1
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [account_id]
            operator:
              type: string
              enum: [equals]
            value:
              type: string
              pattern: '^acct_[A-Za-z0-9]+$'
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [card_id]
            operator:
              type: string
              enum: [equals]
            value:
              type: string
              pattern: '^card_[A-Za-z0-9]+$'
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [transaction_type]
            operator:
              type: string
              enum: [equals]
            value:
              type: string
              enum: [transfer]
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [amount_cents]
            operator:
              type: string
              enum: [equals, greater_than, less_than]
            value:
              type: integer
        - type: object
          additionalProperties: false
          required: [field, operator, value]
          properties:
            field:
              type: string
              enum: [amount_cents]
            operator:
              type: string
              enum: [between]
            value:
              type: array
              minItems: 2
              maxItems: 2
              items:
                type: integer

    RuleActions:
      type: object
      additionalProperties: false
      minProperties: 1
      description: >-
        Only configured actions are present; absent actions are omitted rather than null.
        category_id and department_id preserve the saved reference's public ID, including legacy
        references that may no longer be selectable for a new rule. Reading a rule performs no actions.
        Accounting-date offsets are relative to the original transaction date on import, and do not
        apply historically. Nonzero offsets cannot be combined with Asset category actions.
      properties:
        merchant:
          type: string
          minLength: 1
          description: Assigned merchant name.
        category_id:
          type: string
          pattern: '^cat_[A-Za-z0-9]+$'
          description: Saved category reference; categories are created separately.
        department_id:
          type: string
          pattern: '^dept_[A-Za-z0-9]+$'
          description: Saved department reference.
        tag_id:
          type: string
          pattern: '^tag_[A-Za-z0-9]+$'
          description: Saved tag reference. Fills only a missing tag and never replaces a tag already set, whatever the transaction's category. Runs on new transactions as they sync or import, including ones already categorized; excludes deleted transactions and split lines. Malformed IDs return 400 invalid_request; unknown, foreign-company or deleted tags return 404 resource_missing. A tag deleted after lookup but before saving can instead return 400 invalid_request. Create rejects null; update accepts null to remove the action.
        spread:
          type: integer
          minimum: 2
          maximum: 36
          description: Number of months over which to spread the transaction.
        accounting_date_offset_months:
          type: integer
          minimum: -24
          maximum: 24
          description: Months before (negative) or after (positive) the original transaction date on import; zero means the original date.

    Billing:
      type: object
      additionalProperties: false
      required:
        - object
        - plan
        - status
        - interval
        - renews_on
        - plan_price_cents
        - discount_cents
        - total_cents
      properties:
        object:
          type: string
          enum:
            - billing
        plan:
          type: string
          enum:
            - formation
            - startup
            - growth
            - enterprise
        status:
          type: string
          nullable: true
          enum:
            - incomplete
            - incomplete_expired
            - trialing
            - active
            - past_due
            - canceled
            - unpaid
            - paused
            - null
          description: The card subscription's status. Null without one, as on Formation or Enterprise.
        interval:
          type: string
          enum:
            - month
            - year
        renews_on:
          type: string
          format: date
          nullable: true
          description: When the plan next renews (UTC), or the trial ends. Null when it won't renew, including when a cancellation is scheduled.
        plan_price_cents:
          type: integer
          nullable: true
          minimum: 0
          description: This company's price for the plan per interval, before any discount. Null when it isn't on file, as for a legacy subscriber billed outside the card subscription.
        discount_cents:
          type: integer
          maximum: 0
          description: The discount taken off each interval, as a negative amount. 0 without a discount. Counts a coupon only against the plans it covers; how long it lasts isn't returned.
        total_cents:
          type: integer
          nullable: true
          minimum: 0
          description: What the company pays per interval while any discount lasts. Null when the price isn't on file.

    TagInput:
      type: object
      required: [name]
      additionalProperties: false
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 32
          description: >-
            Up to 32 characters using letters, numbers, spaces and hyphens, with at least one letter or number.
            Leading and trailing spaces are removed and repeated spaces are collapsed. Names are unique ignoring case; "Other" is reserved in any letter case.
    TagList:
      type: object
      required: [object, url, data]
      properties:
        object:
          type: string
          enum: [list]
        url:
          type: string
          enum: [/api/v1/tags]
        data:
          type: array
          items:
            $ref: "#/components/schemas/Tag"
    Tag:
      type: object
      required: [id, object, name, transaction_count, vendor_count, vendor_names]
      properties:
        id:
          type: string
          pattern: '^tag_'
        object:
          type: string
          enum: [tag]
        name:
          type: string
        transaction_count:
          type: integer
          nullable: true
          description: Visible transactions carrying this tag. Null without Transactions View.
        vendor_count:
          type: integer
          nullable: true
          description: Distinct vendors on visible transactions. Null without Transactions View.
        vendor_names:
          type: array
          nullable: true
          maxItems: 5
          description: Top five vendors by transaction count. Null without Transactions View.
          items:
            type: string

    Department:
      type: object
      required:
        - id
        - object
        - name
        - tier
        - parent_id
        - assignable
      properties:
        id:
          type: string
          pattern: "^dept_"
          description: Unique identifier with `dept_` prefix.
          example: dept_a1b2c3d4e5f6g7
        object:
          type: string
          enum:
            - department
        name:
          type: string
          example: Engineering
        tier:
          type: integer
          minimum: 1
          maximum: 4
          description: Department depth in the hierarchy.
        parent_id:
          type: string
          nullable: true
          pattern: "^dept_"
          description: ID of the parent department, or null for a top-level department.
          example: dept_parent123456
        assignable:
          type: boolean
          description: Whether the department is a leaf that can be assigned to a transaction.
          example: true

    ConnectionMethod:
      type: object
      required:
        - type
        - status
        - connect_via
      properties:
        type:
          type: string
          enum:
            - oauth
            - plaid
            - api_key
            - grant_access
          description: >-
            How this method connects. `oauth`: the user signs in to the provider and approves
            Finta. `plaid`: the user signs in to their bank through Plaid. `api_key`: the user
            gives Finta an API key from the provider. `grant_access`: the user invites Finta as
            a user in the provider. New values may be added; treat an unknown one like
            `connect_via` says.
        status:
          type: string
          enum:
            - not_connected
            - requested
            - granted
            - connecting
            - connected
            - requires_reconnect
          description: >-
            Where this method stands. `requested`: Finta has asked the company to do it, in the
            Finta inbox. `granted`: the company has invited Finta, as that inbox item asked.
            The other values mean what they do on the integration's `status`.
        connect_via:
          type: string
          enum:
            - api
            - web_app
          description: >-
            How to start or redo it. `api`: `POST /integrations/{key}/connect` returns a link
            the user opens. `web_app`: the user does it in the Finta web app (Settings →
            Integrations, or the inbox item for a `requested` method). New values may be added.

    Integration:
      type: object
      required:
        - key
        - object
        - name
        - type
        - status
        - connection_methods
        - quality
        - signup_referral
      properties:
        key:
          type: string
          description: Stable identifier for the integration, e.g. `mercury`, `ramp`, `carta`.
          example: mercury
        object:
          type: string
          enum:
            - integration
        name:
          type: string
          description: Human-readable vendor name.
          example: Mercury
        type:
          type: string
          enum:
            - banking
            - cap_table
            - payroll
            - revenue
            - notifications
            - legal
          description: What kind of integration this is.
        status:
          type: string
          enum:
            - not_connected
            - connected
            - connecting
            - requires_reconnect
          description: Connection state. `requires_reconnect` means a previously working connection needs the user to reauthenticate.
        connection_methods:
          type: array
          description: >-
            Each way this integration can be connected, with where it stands. Every integration
            has the method it connects with. Mercury and Ramp also list a `grant_access` method
            once Finta asks the company to invite Finta for Mercury Treasury or Ramp Checking,
            data their sign-in connection doesn't include. Order is not significant.
          items:
            $ref: "#/components/schemas/ConnectionMethod"
        quality:
          type: string
          enum:
            - great
            - good
            - poor
          description: >-
            How reliable this integration's data is, the same rating the Finta web app shows.
            `great` is an OAuth or API connection with real-time updates, `good` is read-only
            access straight from the source that is not real-time, and `poor` is a Plaid-based
            bank connection whose data can be incomplete or error-prone.
        signup_referral:
          type: object
          nullable: true
          description: >-
            A referral offer for opening an account with this provider. Present only while
            `status` is `not_connected` and the provider has a Finta referral; `null` otherwise.
          required:
            - reward
            - link
          properties:
            reward:
              type: string
              description: What signing up through the link earns, as shown in the Finta web app.
              example: $500 sign up bonus
            link:
              type: string
              format: uri
              description: The provider's sign-up page for Finta's referral.
              example: https://mercury.com/r/fintadotcom

    InboxItem:
      type: object
      required:
        - id
        - object
        - type
        - status
        - title
        - instructions
        - url
        - created
      properties:
        id:
          type: string
          description: Unique identifier, prefixed `inb_`.
          example: inb_3f2a9c1e7b4d4e0f9a6c2b8d1e5f7a90
        object:
          type: string
          enum:
            - inbox_item
        type:
          type: string
          enum:
            - connect_integration
            - reconnect_integration
            - grant_access
            - categorize_transactions
            - upload_tax_return
            - sign_tax_draft
            - provide_delaware_ach
            - start_tax_onboarding
          description: >-
            What Finta is asking for. `connect_integration`: connect a provider Finta found the
            company uses. `reconnect_integration`: a connection's data stopped updating until
            someone signs in to the provider again. `grant_access`: invite Finta as a user in the
            provider's own app, for data its sign-in connection doesn't include.
            `categorize_transactions`: approved transactions have no category.
            `upload_tax_return`: Finta needs to know whether a tax year is filed.
            `sign_tax_draft`: a draft tax return is ready to review and sign.
            `provide_delaware_ach`: Finta needs ACH details to pay the Delaware franchise tax.
            `start_tax_onboarding`: a tax year is open for Finta to file. New values may be added.
        status:
          type: string
          enum:
            - open
            - completed
          description: "`open` in the list; `completed` once the item is done."
        title:
          type: string
          description: >-
            The item's title as the Finta inbox shows it, with its specifics, such as the bank's name or the tax
            year. An open categorize_transactions item's count is recounted on every request.
          example: Grant access for Mercury Treasury
        instructions:
          type: string
          description: >-
            What to do about the item. When a Finta skill covers it, the skill to run. Otherwise
            the inbox's own text, and that the user does it in the Finta web app, at `url`.
          example: Get the grant-access-mercury skill and run it, then mark this item complete.
        url:
          type: string
          format: uri
          description: Where the user does it in the Finta web app.
        created:
          type: integer
          description: When Finta added the item, in Unix seconds.
    ConnectRequest:
      type: object
      required:
        - object
        - integration
        - intent
        - status
        - url
        - expires_at
        - failure_code
      properties:
        object:
          type: string
          enum:
            - connect_request
        integration:
          type: string
          description: The integration's `key`.
          example: mercury
        intent:
          type: string
          enum:
            - connect
            - reconnect
          description: "`reconnect` re-authenticates an existing connection whose status is `requires_reconnect`. Always `connect` for Plaid banks."
        status:
          type: string
          enum:
            - pending
            - authorizing
            - complete
            - failed
            - expired
          description: >-
            `pending` until the provider sends the user back (for a Plaid bank, until Plaid
            reports that the session ended), then `authorizing` while Finta finishes the
            connection, then `complete` or `failed`. `expired` when nobody finished the link
            before `expires_at`. A request already `authorizing` when `expires_at` passes stays
            `authorizing` while Finta finishes it. For a Plaid bank, a request can still turn
            `complete` after `expires_at` when the user finished in Plaid just before it and Plaid
            reported it late, or after failing with `access_denied` when the user reopens the same
            link and connects.
          example: pending
        url:
          type: string
          format: uri
          nullable: true
          description: >-
            The provider's authorization page. A person opens it in a browser signed in to Finta
            as the user who started the request. Single use. For a Plaid bank it is a Plaid Hosted
            Link page instead, which needs no Finta sign-in: anyone with it can connect a bank to
            the company until `expires_at`. `null` unless `status` is `pending`.
          example: https://oauth2.mercury.com/oauth2/auth?client_id=example&redirect_uri=https%3A%2F%2Fapp.finta.com%2Fintegrations%2Fmercury%2Fcallback&response_type=code&scope=offline_access+read&state=example
        expires_at:
          type: integer
          description: Unix timestamp (seconds) after which the link stops working.
          example: 1790170200
        failure_code:
          type: string
          nullable: true
          enum:
            - access_denied
            - provider_admin_required
            - provider_error
            - connection_failed
          description: >-
            Why a `failed` request failed; `null` for every other status. `access_denied`: the user
            declined at the provider, or left Plaid without connecting a bank.
            `provider_admin_required`: the provider account that approved is not an administrator.
            `provider_error`: the provider returned an error or no authorization code.
            `connection_failed`: Finta could not finish the connection, for example because the
            bank picked in Plaid is not the one requested.

    Total:
      type: object
      required:
        - object
        - category_id
        - merchant
        - start_month
        - end_month
        - start_date
        - end_date
        - date
        - point_in_time
        - total_cents
        - currency
        - debit_cents
        - credit_cents
        - refreshed_at
      properties:
        object:
          type: string
          enum:
            - total
        category_id:
          type: string
          pattern: "^cat_[A-Za-z0-9]+$"
          description: The requested public category ID.
          example: cat_o5p6q7r8s9t0u1
        merchant:
          type: string
          nullable: true
          description: The exact merchant filter, or null when no merchant filter was applied.
          example: AWS
        start_month:
          type: string
          nullable: true
          pattern: "^\\d{4}-(0[1-9]|1[0-2])$"
          description: The calendar month containing the actual periodic start date. Derived from `start_date` for full-date requests; it does not widen the range to a whole month. Null for point-in-time totals.
          example: "2026-01"
        end_month:
          type: string
          pattern: "^\\d{4}-(0[1-9]|1[0-2])$"
          description: The calendar month containing the actual periodic end or point-in-time cutoff date. Derived from `end_date` or `date` for full-date requests; it does not widen the range to a whole month.
          example: "2026-03"
        start_date:
          type: string
          format: date
          nullable: true
          description: The actual inclusive periodic start calendar date in Pacific Time. For a legacy month request, this is the first day of `start_month`. Null when `point_in_time` is true.
          example: "2026-01-15"
        end_date:
          type: string
          format: date
          nullable: true
          description: The actual inclusive periodic end calendar date in Pacific Time. For a legacy month request, this is the last day of `end_month`, not clamped to today. Null when `point_in_time` is true.
          example: "2026-03-20"
        date:
          type: string
          format: date
          nullable: true
          description: The actual inclusive point-in-time cutoff calendar date in Pacific Time. For a legacy month request, this is the last day of `end_month`, not clamped to today. Null when `point_in_time` is false.
          example: null
        point_in_time:
          type: boolean
          description: When true, request the total with `date`. When false, request it with `start_date` and `end_date`.
          example: false
        total_cents:
          type: integer
          description: Signed total in cents. Assets use debit minus credit; all other categories use credit minus debit.
          example: -182340
        currency:
          type: string
          enum:
            - USD
          description: ISO 4217 currency code for the total.
          example: USD
        debit_cents:
          type: integer
          description: Contributing debit amount in cents before sign calculation.
          example: 182340
        credit_cents:
          type: integer
          description: Contributing credit amount in cents before sign calculation.
          example: 0
        refreshed_at:
          type: integer
          nullable: true
          description: >-
            Unix timestamp covering the entire result, including partial months. For rollup-only
            reads, the maximum contributing rollup timestamp including MTD. Any live raw-entry
            portion is current as of the call, even with no matching raw entries. Null only when
            no contributing timestamp or live raw read is available.
          example: 1786992120

    TotalList:
      type: object
      required:
        - object
        - url
        - group_by
        - order
        - category_id
        - merchant
        - start_date
        - end_date
        - date
        - point_in_time
        - currency
        - refreshed_at
        - has_more
        - next_cursor
        - data
      properties:
        object:
          type: string
          enum:
            - list
        url:
          type: string
          enum:
            - /api/v1/aggregations/totals
        group_by:
          type: string
          enum:
            - merchant
            - month
          description: The requested grouping.
        order:
          type: string
          nullable: true
          enum:
            - asc
            - desc
          description: The order used. Null for months requested without `order`, which are in calendar order, oldest first.
        category_id:
          type: string
          pattern: "^cat_[A-Za-z0-9]+$"
          description: The requested public category ID.
          example: cat_o5p6q7r8s9t0u1
        merchant:
          type: string
          nullable: true
          description: The exact merchant filter, or null when no merchant filter was applied.
        start_date:
          type: string
          format: date
          nullable: true
          description: The actual inclusive periodic start calendar date in Pacific Time, or the date selecting the first displayed month for point-in-time month groups. Null for a point-in-time category with `group_by=merchant`.
        end_date:
          type: string
          format: date
          nullable: true
          description: The actual inclusive periodic end or final monthly closing calendar date in Pacific Time. Null for a point-in-time category with `group_by=merchant`.
        date:
          type: string
          format: date
          nullable: true
          description: The actual inclusive point-in-time cutoff calendar date in Pacific Time. Set only for a point-in-time category with `group_by=merchant`.
        point_in_time:
          type: boolean
          description: When true, merchant rows are cumulative through `date`, and month rows through each month's closing date, with the last calendar month's cutoff at `end_date`. When false, rows are activity within the exact inclusive requested period.
        currency:
          type: string
          enum:
            - USD
          description: ISO 4217 currency code for every amount in `data`.
        refreshed_at:
          type: integer
          nullable: true
          description: >-
            Unix timestamp covering the entire returned page, including partial months, not just
            full-month rows. For rollup-only reads, the maximum contributing rollup timestamp
            including MTD. Any live raw-entry portion is current as of the call, even with no
            matching raw entries. Null only when no contributing timestamp or live raw read is available.
        has_more:
          type: boolean
          description: True when more rows follow. Always false for months requested without `limit`.
        next_cursor:
          type: string
          nullable: true
          description: Pass as `starting_after`, with the same request parameters, for the next page. Null when `has_more` is false.
        data:
          type: array
          items:
            oneOf:
              - $ref: "#/components/schemas/MerchantTotal"
              - $ref: "#/components/schemas/MonthTotal"

    MerchantTotal:
      type: object
      required:
        - object
        - merchant
        - total_cents
        - debit_cents
        - credit_cents
      properties:
        object:
          type: string
          enum:
            - merchant_total
        merchant:
          type: string
          nullable: true
          description: The merchant or counterparty name exactly as recorded, or null for activity recorded without one.
          example: AWS
        total_cents:
          type: integer
          description: Signed total in cents. Assets use debit minus credit; all other categories use credit minus debit, so expenses are negative.
          example: -182340
        debit_cents:
          type: integer
          description: Contributing debit amount in cents before sign calculation.
          example: 190340
        credit_cents:
          type: integer
          description: Contributing credit amount in cents before sign calculation.
          example: 8000

    MonthTotal:
      type: object
      required:
        - object
        - month
        - total_cents
        - debit_cents
        - credit_cents
      properties:
        object:
          type: string
          enum:
            - month_total
        month:
          type: string
          pattern: "^\\d{4}-(0[1-9]|1[0-2])$"
          description: The calendar month, as `YYYY-MM`.
          example: "2026-01"
        total_cents:
          type: integer
          description: >-
            Signed total in cents. Periodic activity is clipped to the intersection of this
            calendar month and the exact inclusive requested period. Point-in-time balances are
            cumulative through this month's closing date, with the last calendar month's cutoff
            at `end_date`. All dates are in Pacific Time. Assets use debit minus credit; all other
            categories use credit minus debit.
          example: -60780
        debit_cents:
          type: integer
          description: Contributing debit cents before sign calculation. Periodic rows are clipped to the exact requested range within this month. Point-in-time rows are cumulative through month-end, or `end_date` for the last calendar month, in Pacific Time.
          example: 60780
        credit_cents:
          type: integer
          description: Contributing credit cents before sign calculation. Periodic rows are clipped to the exact requested range within this month. Point-in-time rows are cumulative through month-end, or `end_date` for the last calendar month, in Pacific Time.
          example: 0

    TransactionSplit:
      type: object
      description: One line of a split transaction. Lines have no ID of their own.
      required:
        - object
        - transaction_id
        - date
        - accounting_date
        - amount_cents
        - currency
        - merchant
        - category_id
        - category_name
        - department_id
        - department_name
        - spread
      properties:
        object:
          type: string
          enum:
            - transaction_split
        transaction_id:
          type: string
          pattern: "^txn_"
          description: The split transaction this line belongs to.
        date:
          type: string
          format: date
          description: The transaction's date, as the bank or card reports it.
        accounting_date:
          type: string
          format: date
          nullable: true
          description: The date the books use for this line.
        amount_cents:
          type: integer
          description: Signed amount in cents, like `amount_cents` on a transaction. Negative for money out.
        currency:
          type: string
          example: USD
        merchant:
          type: string
          nullable: true
        category_id:
          type: string
          nullable: true
          pattern: "^cat_"
        category_name:
          type: string
          nullable: true
        department_id:
          type: string
          nullable: true
          pattern: "^dept_"
        department_name:
          type: string
          nullable: true
        spread:
          type: integer
          nullable: true
          description: Number of months this line is spread over, or null if it isn't spread.
    TransactionSplitRequest:
      type: object
      additionalProperties: false
      required:
        - lines
      properties:
        lines:
          type: array
          minItems: 2
          maxItems: 100
          description: The lines, which the response lists in this order. Their signed `amount_cents` must add up exactly to the transaction's.
          items:
            $ref: "#/components/schemas/TransactionSplitLineRequest"
    TransactionSplitLineRequest:
      type: object
      additionalProperties: false
      required:
        - amount_cents
        - category_id
      properties:
        amount_cents:
          type: integer
          description: >-
            Signed amount in cents, like the transaction's `amount_cents`: negative for money out. Not zero,
            and at most 100000000000000 in absolute value. Lines can have different signs, such as a refund
            within a charge, as long as they add up to the transaction's amount.
          example: -60000
        category_id:
          type: string
          pattern: "^cat_"
          description: A category from `GET /categories`. Matched Transfer can't be used; match a transfer with `POST /transfers`.
          example: cat_v5e6f7g8h9i0j1
        merchant:
          type: string
          nullable: true
          minLength: 1
          maxLength: 40
          description: The line's merchant. Null or left out keeps the transaction's merchant.
          example: Figma
        department_id:
          type: string
          nullable: true
          pattern: "^dept_"
          description: >-
            A department without sub-departments, from `GET /departments`, or null for none. Left out, the
            line gets the transaction's department when it's being split; a split transaction has none, so
            when updating a split, a line without it has none. Only a line in a category that can have a
            department, such as an expense, can have one. A company without departments gets 403 when any
            line sends `department_id`, including null. See "Early access endpoints" under Errors.
          example: dept_d4e5f6g7h8i9j0
        spread:
          type: integer
          nullable: true
          minimum: 2
          maximum: 120
          description: Number of months to spread the line over, or null or left out for none. Only income and expense lines can be spread.
          example: 12
        accounting_date:
          type: string
          format: date
          nullable: true
          description: >-
            The date the books use for the line, as YYYY-MM-DD, like `accounting_date` on a split line. Null or
            left out, it defaults as the operation describes. Only an income or expense line can have a date
            other than the transaction's `date`. The same limits apply as for a transaction's accounting date:
            not before the company's incorporation date (or two years ago, when it has none), and less than
            three years ahead.
          example: "2026-02-15"
    Spread:
      type: object
      description: An approved spread, on a whole transaction or on one line of a split. Spreads have no ID of their own.
      required:
        - object
        - transaction_id
        - split
        - months
        - date
        - accounting_date
        - amount_cents
        - currency
        - merchant
        - category_id
        - category_name
        - department_id
        - department_name
      properties:
        object:
          type: string
          enum:
            - spread
        transaction_id:
          type: string
          pattern: "^txn_"
          description: The transaction the spread is on, or the split transaction when the spread is on one of its lines.
        split:
          type: boolean
          description: "`true` when the spread is on one line of a split transaction. The amount, category and dates below are then the line's."
        months:
          type: integer
          minimum: 2
          description: Number of months the amount is spread over.
        date:
          type: string
          format: date
          description: The transaction's date, as the bank or card reports it.
        accounting_date:
          type: string
          format: date
          nullable: true
          description: The date the books use for this line.
        amount_cents:
          type: integer
          description: Signed amount in cents, like `amount_cents` on a transaction. Negative for money out.
        currency:
          type: string
          example: USD
        merchant:
          type: string
          nullable: true
        category_id:
          type: string
          nullable: true
          pattern: "^cat_"
        category_name:
          type: string
          nullable: true
        department_id:
          type: string
          nullable: true
          pattern: "^dept_"
        department_name:
          type: string
          nullable: true
    Transaction:
      type: object
      required:
        - id
        - object
        - date
        - accounting_date
        - amount_cents
        - currency
        - categorized
        - department_id
        - department_name
        - tag_id
        - tag_name
        - transaction_type
        - source
        - status
        - created
      properties:
        id:
          type: string
          description: Unique identifier with `txn_` prefix.
          example: txn_abc123
        object:
          type: string
          enum:
            - transaction
        date:
          type: string
          format: date
          description: Transaction date.
          example: "2026-02-15"
        accounting_date:
          type: string
          format: date
          nullable: true
          description: Date used for accounting and financial reports. Defaults to `date` unless overridden.
          example: "2026-02-15"
        amount_cents:
          type: integer
          description: Transaction amount in cents. Positive for income, negative for expenses. Divide by 100 to get the dollar amount.
          example: -49900
        currency:
          type: string
          description: ISO 4217 currency code.
          example: USD
        merchant:
          type: string
          nullable: true
          description: Merchant or payee name.
          example: AWS
        description:
          type: string
          nullable: true
          description: Description provided by the bank or payment processor. Null when not available.
          example: null
        categorized:
          type: boolean
          description: Whether the transaction has been categorized. False when assigned to Uncategorized Income or Uncategorized Expenses. Note that `category_id` is still present for uncategorized transactions.
        category_id:
          type: string
          nullable: true
          description: Prefixed ID of the assigned category.
          example: cat_v5e6f7g8h9i0j1
        category_name:
          type: string
          nullable: true
          description: Name of the assigned category.
          example: Software
        department_id:
          type: string
          nullable: true
          description: Prefixed ID of the assigned department.
          example: dept_d4e5f6g7h8i9j0
        department_name:
          type: string
          nullable: true
          description: >
            Current name of the assigned department. Renaming a department does not change the transaction's `updated`;
            store `department_id` and get names from `GET /departments`.
          example: Engineering
        tag_id:
          type: string
          nullable: true
          description: Assigned tag_ ID, or null without a tag.
        tag_name:
          type: string
          nullable: true
          description: >
            Current name of the assigned tag, or null without a tag. Renaming a tag does not change the transaction's `updated`;
            store `tag_id` and get names from `GET /tags`.
        transaction_type:
          type: string
          enum:
            - standard
            - transfer
            - split
          description: "Type of transaction: `standard` (normal), `transfer` (matched transfer between accounts), or `split` (parent of split children)."
        source:
          type: object
          description: Where the transaction originated.
          properties:
            vendor:
              type: string
              description: Integration or vendor name (e.g. `mercury`, `brex`, `stripe`, `ramp`).
              example: mercury
            type:
              type: string
              nullable: true
              description: Source account type.
              enum:
                - bank
                - credit
                - reimbursement
              example: bank
            account_name:
              type: string
              nullable: true
              description: Display name of the source account.
              example: Mercury Checking
        status:
          type: string
          enum:
            - approved
            - pending
          description: Approval status of the transaction.
        spread:
          type: integer
          nullable: true
          description: Number of months this transaction is spread over, or null if not spread.
          example: null
        transfer_id:
          type: string
          nullable: true
          description: The `tfr_` ID of the matched transfer, or null if not a transfer. Match with `POST /transfers`; unmatch with `DELETE /transfers/{id}`.
          example: null
        created:
          type: integer
          description: Unix timestamp of when the transaction was created.
          example: 1771183600
        updated:
          type: integer
          description: Unix timestamp of when the transaction was last modified. Use this with `updated_after` to sync incrementally.
          example: 1771183600
        deleted:
          type: boolean
          description: Whether the transaction has been deleted. Always false unless you are syncing with `updated_after`, which is the only mode that returns deleted transactions so you can remove them from your own copy.
          example: false

    JournalEntry:
      type: object
      required:
        - id
        - object
        - date
        - amount_cents
        - type
        - currency
        - transaction_id
        - department_id
        - created
      properties:
        id:
          type: string
          description: Unique identifier with `je_` prefix.
          example: je_abc123
        object:
          type: string
          enum:
            - journal_entry
        merchant:
          type: string
          nullable: true
          description: The merchant or counterparty name for this journal entry.
        name:
          type: string
          nullable: true
          description: "Deprecated: use `merchant` instead. Same value as `merchant`."
          deprecated: true
        date:
          type: string
          format: date
          description: Entry date.
          example: "2026-01-15"
        amount_cents:
          type: integer
          description: Amount in cents (always positive). Use `type` to determine whether this is a debit or credit.
          example: 49900
        type:
          type: string
          enum:
            - debit
            - credit
          description: Whether this entry is a debit or credit. Each journal entry represents one side of a double-entry pair.
        currency:
          type: string
          description: ISO 4217 currency code.
          example: USD
        category_id:
          type: string
          nullable: true
          description: The prefixed ID of the assigned category, or null if uncategorized.
          example: cat_o5p6q7r8s9t0u1
        category_name:
          type: string
          nullable: true
          description: The name of the assigned category, or null if uncategorized.
          example: Software
        transaction_id:
          type: string
          nullable: true
          description: The transaction that directly or indirectly created this entry, or null for entries without a transaction.
          example: txn_a1b2c3d4e5f6g7
        department_id:
          type: string
          nullable: true
          description: The prefixed ID of the assigned department, or null if unassigned.
          example: dept_b2c3d4e5f6g7h8
        source_type:
          type: string
          nullable: true
          enum:
            - transaction
            - spread
            - invoice
            - csv_upload
            - manual_batch
            - capitalization
            - split
            - intercompany
          description: What created this journal entry.
        created:
          type: integer
          description: Unix timestamp of when the entry was created.
          example: 1771113600

    ReportCategoryEntry:
      type: object
      required:
        - category_id
        - name
        - amount_cents
        - position
        - parent_category_id
      properties:
        category_id:
          type: string
          nullable: true
          description: Prefixed ID of the category, or null for calculated rows (e.g. Net Income on cash flow).
          example: cat_v5e6f7g8h9i0j1
        name:
          type: string
          description: Display name of the category or calculated row.
          example: Software
        amount_cents:
          type: integer
          description: >-
            Signed amount in cents for the reporting period. Income/revenue amounts are positive, expense amounts are negative. Parent categories contain the subtotal of their children. To compute the section total, sum only root-level entries (those with parent_category_id null).
          example: -680000
        position:
          type: integer
          description: Sort order among siblings that share the same parent_category_id.
          example: 3
        parent_category_id:
          type: string
          nullable: true
          description: >-
            Prefixed ID of the parent category within this section. Null means this is a top-level category in the section. When non-null, the referenced parent also appears in the same categories array with its own subtotal. Use this field to reconstruct the display hierarchy (e.g. Admin as a parent with Software and Hosting as children).
          example: cat_u4d5e6f7g8h9i0

    ReportSection:
      type: object
      required:
        - total_cents
        - categories
      properties:
        total_cents:
          type: integer
          description: >-
            Signed total amount in cents for this section. Equals the sum of root-level categories (those with parent_category_id null). Do not sum all entries, as parent categories contain subtotals of their children. Positive for income/revenue sections, negative for expense sections.
          example: 8500000
        categories:
          type: array
          items:
            $ref: "#/components/schemas/ReportCategoryEntry"

    ReportExportRequest:
      type: object
      additionalProperties: false
      required: [report_type, start_date, end_date]
      properties:
        report_type:
          type: string
          enum: [income_statement, balance_sheet, cash_flow]
          description: income_statement exports profit and loss; balance_sheet compares cumulative balances across the requested periods; cash_flow exports operating, investing, and financing activity. GAAP P&L uses income_statement with department rows.
        format:
          type: string
          enum: [pdf, csv, xlsx]
          default: pdf
        start_date:
          type: string
          format: date
          description: Required for all report types. For balance sheets, selects the first displayed period while retaining cumulative historical balances.
        end_date:
          type: string
          format: date
          description: Inclusive; required for all report types. Must be on or after start_date. The range may cover at most 120 calendar months, counting both boundary months. PDF exports additionally allow at most 13 displayed periods.
        group_by:
          $ref: '#/components/schemas/ReportExportGrouping'
    ReportExportGrouping:
      type: object
      additionalProperties: false
      description: Omitted members default to month for period and category for rows. Department rows are supported only for income statements and require the feature.
      properties:
        period:
          type: string
          enum: [month, quarter, year]
          default: month
          description: Time columns by month, quarter, or year. Quarter/year boundaries follow the company's calendar/fiscal report settings. First and last columns may cover partial periods.
        rows:
          type: string
          enum: [category, department]
          default: category
          description: category uses accounting categories. department uses the GAAP department income statement; it is supported only for income_statement and requires the departments feature.
    ReportExport:
      type: object
      required: [id, object, report_type, start_date, end_date, format, group_by, status, created, started_at, completed_at, generation_duration_ms, file, error]
      properties:
        id:
          type: string
          pattern: '^export_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
        object:
          type: string
          enum: [report_export]
        report_type:
          type: string
          enum: [income_statement, balance_sheet, cash_flow]
        format:
          type: string
          enum: [pdf, csv, xlsx]
        start_date: {type: string, format: date}
        end_date: {type: string, format: date}
        group_by:
          allOf:
            - $ref: '#/components/schemas/ReportExportGrouping'
            - required: [period, rows]
        status:
          type: string
          enum: [pending, processing, succeeded, failed]
          description: pending awaits generation; processing includes generation and retry waits; succeeded exposes the retained file after final access checks while within 30 days of completed_at, then file is null; failed is terminal and requires a new key for a new job. An HTTP 200 retrieval may still carry failed status.
        created: {type: integer}
        started_at:
          type: integer
          nullable: true
          description: Unix timestamp of the first generation attempt. Null until generation starts, including a terminal failure when access is revoked before generation.
        completed_at:
          type: integer
          nullable: true
          description: Unix timestamp of terminal completion, whether succeeded or failed. Null while unfinished.
        generation_duration_ms:
          type: integer
          nullable: true
          minimum: 0
          description: Cumulative generation attempt duration in milliseconds, including upload but excluding queue and retry wait time. Null until an attempt finishes.
        file:
          type: object
          nullable: true
          description: Available only for succeeded exports before completed_at plus 30 days. Null at or after that cutoff, including on idempotent POST replay; status remains succeeded and error remains null. Within the window, refresh download_url by retrieving the export again. Already-issued links keep their original expiry; neither link expiry nor the regeneration cutoff removes the stored file.
          required: [filename, content_type, size, download_url, expires_at]
          properties:
            filename: {type: string}
            content_type:
              type: string
              enum: [application/pdf, text/csv, application/vnd.openxmlformats-officedocument.spreadsheetml.sheet]
            size: {type: integer, minimum: 0}
            download_url: {type: string, format: uri}
            expires_at:
              type: integer
              description: Requested Unix timestamp of download link expiry, seven days after retrieval. Signing credentials or storage policies may invalidate it sooner. This does not expire the stored file.
        error:
          type: object
          nullable: true
          description: Safe job failure details, present only when status is failed. This is distinct from an HTTP error envelope. New submissions with unconfirmed dispatch return HTTP 503 and retain a pending job, rather than setting enqueue_failed.
          required: [code, message]
          properties:
            code:
              type: string
              enum: [access_revoked, enqueue_failed, generation_failed]
            message: {type: string}
    IncomeStatement:
      type: object
      required:
        - object
        - group_by
        - start_date
        - end_date
        - currency
        - refreshed_at
        - revenue
        - expenses
        - operating_income_cents
        - non_operating_income
        - net_income_cents
      properties:
        object:
          type: string
          enum:
            - income_statement
        group_by:
          type: string
          enum: [category]
          description: Expense grouping used for this statement.
        start_date:
          type: string
          format: date
          description: The actual inclusive first calendar date used for this report in Pacific Time.
          example: "2026-01-01"
        end_date:
          type: string
          format: date
          description: The actual inclusive last calendar date used for this report in Pacific Time.
          example: "2026-03-31"
        currency:
          type: string
          example: USD
        refreshed_at:
          type: integer
          nullable: true
          description: >-
            Unix timestamp covering the entire report, including partial months. For rollup-only
            reads, the maximum contributing rollup timestamp including MTD. Any live raw-entry
            portion is current as of the call, even with no matching raw entries. Null only when
            no contributing timestamp or live raw read is available.
          example: 1772323200
        uncategorized_income:
          $ref: "#/components/schemas/ReportSection"
          description: Uncategorized income items. Present when uncategorized income exists.
        revenue:
          $ref: "#/components/schemas/ReportSection"
        expenses:
          $ref: "#/components/schemas/ReportSection"
        uncategorized_expenses:
          $ref: "#/components/schemas/ReportSection"
          description: Uncategorized expense items. Present when uncategorized expenses exist.
        operating_income_cents:
          type: integer
          description: Operating income in cents. Equals revenue.total_cents + uncategorized_income.total_cents + expenses.total_cents + uncategorized_expenses.total_cents (expenses are already negative). Can be negative if the company is unprofitable.
          example: 3300000
        non_operating_income:
          $ref: "#/components/schemas/ReportSection"
          description: Non-operating income and expenses (e.g. interest income, gains/losses).
        taxes:
          $ref: "#/components/schemas/ReportSection"
          description: Tax expense. Present when tax data exists.
        net_income_cents:
          type: integer
          description: Net income in cents for the period. Equals operating_income_cents + non_operating_income.total_cents + taxes.total_cents (taxes are already negative). Can be negative if the company is unprofitable.
          example: 3325000

    DepartmentExpenseRow:
      type: object
      required:
        - row_id
        - parent_row_id
        - kind
        - department_id
        - category_id
        - name
        - amount_cents
        - position
      properties:
        row_id:
          type: string
          description: Stable row ID. Uses the department or category prefixed ID, or grp_cost_of_goods_sold / grp_no_department for synthetic groups.
        parent_row_id:
          type: string
          nullable: true
          description: Parent row in this response's expenses.rows array. Null for root rows.
        kind:
          type: string
          enum: [department, category, group]
          description: department is an actual department; category is an actual COGS category; group is the synthetic Cost of Goods Sold or No Department row.
        department_id:
          type: string
          nullable: true
          description: Department prefixed ID for department rows; null otherwise.
        category_id:
          type: string
          nullable: true
          description: Category prefixed ID for category rows; null otherwise.
        name:
          type: string
          description: Plain-text display label. Labels need not be unique; use row_id, not name, to identify a row.
        amount_cents:
          type: integer
          description: Signed period amount in cents, including the amounts of child rows.
        position:
          type: integer
          minimum: 1
          description: Sort order among siblings sharing the same parent_row_id.

    DepartmentExpenseSection:
      type: object
      required: [total_cents, rows]
      properties:
        total_cents:
          type: integer
          description: Signed expense total in cents. Equals the sum of root rows, whose parent_row_id is null.
        rows:
          type: array
          description: Complete expense hierarchy, including present rows whose amounts are zero. Synthetic COGS and No Department rows may appear even for an empty period.
          items:
            $ref: "#/components/schemas/DepartmentExpenseRow"

    DepartmentIncomeStatement:
      type: object
      required:
        - object
        - group_by
        - start_date
        - end_date
        - currency
        - refreshed_at
        - revenue
        - expenses
        - operating_income_cents
        - non_operating_income
        - net_income_cents
      properties:
        object:
          type: string
          enum:
            - income_statement
        group_by:
          type: string
          enum: [department]
          description: Expense grouping used for this statement.
        start_date:
          type: string
          format: date
          example: "2026-01-01"
        end_date:
          type: string
          format: date
          example: "2026-03-31"
        currency:
          type: string
          example: USD
        refreshed_at:
          type: integer
          nullable: true
          description: Always null for this journal-entry-backed report; there is no summary refresh timestamp.
          example: null
        uncategorized_income:
          $ref: "#/components/schemas/ReportSection"
          description: Uncategorized income items. Present when uncategorized income exists.
        revenue:
          $ref: "#/components/schemas/ReportSection"
        expenses:
          $ref: "#/components/schemas/DepartmentExpenseSection"
        operating_income_cents:
          type: integer
          description: Operating income in cents. Equals revenue.total_cents + uncategorized_income.total_cents + expenses.total_cents (expenses are already negative). Can be negative if the company is unprofitable.
          example: 3300000
        non_operating_income:
          $ref: "#/components/schemas/ReportSection"
          description: Non-operating income and expenses (e.g. interest income, gains/losses).
        taxes:
          $ref: "#/components/schemas/ReportSection"
          description: Tax expense. Present when tax data exists.
        net_income_cents:
          type: integer
          description: Net income in cents for the period. Equals operating_income_cents + non_operating_income.total_cents + taxes.total_cents (taxes are already negative). Can be negative if the company is unprofitable.
          example: 3325000

    BalanceSheetCategoryEntry:
      type: object
      required:
        - category_id
        - name
        - balance_cents
        - position
        - parent_category_id
      properties:
        category_id:
          type: string
          description: Prefixed ID of the category.
          example: cat_c2l3m4n5o6p7q8
        name:
          type: string
          description: Display name of the category.
          example: Cash
        balance_cents:
          type: integer
          description: >-
            Cumulative balance in cents as of the report date. Parent categories contain the subtotal of their children. To compute the section total, sum only root-level entries (those with parent_category_id null).
          example: 9800000
        position:
          type: integer
          description: Sort order among siblings that share the same parent_category_id.
          example: 1
        parent_category_id:
          type: string
          nullable: true
          description: >-
            Prefixed ID of the parent category within this section. Null means this is a top-level category in the section. When non-null, the referenced parent also appears in the same categories array with its own subtotal. Use this field to reconstruct the display hierarchy (e.g. Cash as a parent with individual bank accounts as children).
          example: null

    BalanceSheetSection:
      type: object
      required:
        - total_cents
        - categories
      properties:
        total_cents:
          type: integer
          description: >-
            Total balance in cents for this section. Equals the sum of root-level categories (those with parent_category_id null). Do not sum all entries, as parent categories contain subtotals of their children.
          example: 12500000
        categories:
          type: array
          items:
            $ref: "#/components/schemas/BalanceSheetCategoryEntry"

    RetainedEarnings:
      type: object
      required:
        - beginning_balance_cents
        - net_income_cents
        - shareholder_dividends_cents
        - ending_balance_cents
      properties:
        beginning_balance_cents:
          type: integer
          description: Retained earnings balance at the start of the period.
          example: 3315000
        net_income_cents:
          type: integer
          description: Net income for the period.
          example: 3325000
        shareholder_dividends_cents:
          type: integer
          description: Dividends paid to shareholders during the period. Negative when dividends were paid (a reduction in retained earnings); zero when no dividends were paid.
          example: 0
        ending_balance_cents:
          type: integer
          description: Retained earnings balance at the end of the period. Equals beginning_balance_cents + net_income_cents + shareholder_dividends_cents.
          example: 6640000

    BalanceSheetEquity:
      type: object
      required:
        - total_cents
        - categories
        - retained_earnings
      properties:
        total_cents:
          type: integer
          description: Total equity in cents.
          example: 11650000
        categories:
          type: array
          items:
            $ref: "#/components/schemas/BalanceSheetCategoryEntry"
        retained_earnings:
          $ref: "#/components/schemas/RetainedEarnings"

    BalanceSheet:
      type: object
      required:
        - object
        - date
        - currency
        - refreshed_at
        - cash_cents
        - assets
        - liabilities
        - equity
        - liabilities_and_equity_cents
      properties:
        object:
          type: string
          enum:
            - balance_sheet
        date:
          type: string
          format: date
          description: The actual inclusive as-of calendar date used for this report in Pacific Time.
          example: "2026-03-16"
        currency:
          type: string
          example: USD
        refreshed_at:
          type: integer
          nullable: true
          description: >-
            Unix timestamp covering the entire report through its exact as-of date. For rollup-only
            reads, the maximum contributing rollup timestamp including MTD. Any live raw-entry
            portion is current as of the call, even with no matching raw entries. Null only when
            no contributing timestamp or live raw read is available.
          example: 1772323200
        cash_cents:
          type: integer
          description: Total cash balance in cents. Same value as the Cash category in assets, elevated for convenience.
          example: 9800000
        assets:
          $ref: "#/components/schemas/BalanceSheetSection"
        liabilities:
          $ref: "#/components/schemas/BalanceSheetSection"
        equity:
          $ref: "#/components/schemas/BalanceSheetEquity"
        liabilities_and_equity_cents:
          type: integer
          description: Sum of liabilities and equity in cents. Must equal assets.total_cents (accounting equation check).
          example: 12500000

    CashFlow:
      type: object
      required:
        - object
        - start_date
        - end_date
        - currency
        - refreshed_at
        - operating_activities
        - investing_activities
        - financing_activities
        - beginning_cash_cents
        - ending_cash_cents
        - change_in_cash_cents
      properties:
        object:
          type: string
          enum:
            - cash_flow
        start_date:
          type: string
          format: date
          description: The actual inclusive first calendar date used for this report in Pacific Time.
          example: "2026-01-01"
        end_date:
          type: string
          format: date
          description: The actual inclusive last calendar date used for this report in Pacific Time.
          example: "2026-03-31"
        currency:
          type: string
          example: USD
        refreshed_at:
          type: integer
          nullable: true
          description: >-
            Unix timestamp covering the entire report, including partial months and opening and
            closing balances. For rollup-only reads, the maximum contributing rollup timestamp
            including MTD. Any live raw-entry portion is current as of the call, even with no
            matching raw entries. Null only when no contributing timestamp or live raw read is available.
          example: 1772323200
        operating_activities:
          $ref: "#/components/schemas/ReportSection"
        investing_activities:
          $ref: "#/components/schemas/ReportSection"
        financing_activities:
          $ref: "#/components/schemas/ReportSection"
        beginning_cash_cents:
          type: integer
          description: Cash balance in cents at the start of the period.
          example: 10000000
        ending_cash_cents:
          type: integer
          description: Cash balance in cents at the end of the period.
          example: 14500000
        change_in_cash_cents:
          type: integer
          description: Net change in cash for the period. Equals ending_cash_cents - beginning_cash_cents.
          example: 4500000

    Invoice:
      type: object
      description: An invoice the company sent, with the revenue it booked and the deposit that paid it.
      required:
        - id
        - object
        - status
        - customer_name
        - invoice_date
        - amount_cents
        - currency
        - line_items
        - adjustments
        - paid_transaction_id
        - paid_with_invoice_ids
        - paid_date
        - source
        - stripe_url
        - created
      properties:
        id:
          type: string
          example: inv_k3m9p2q8r4s7t1
        object:
          type: string
          enum:
            - invoice
        status:
          type: string
          enum:
            - open
            - paid
            - voided
          description: "`open` until a deposit is matched as its payment, then `paid`. `voided` invoices booked nothing."
        customer_name:
          type: string
          example: Acme Corp
        invoice_date:
          type: string
          format: date
          example: "2026-09-01"
        amount_cents:
          type: integer
          description: The total due in cents, after discounts, credits and tax.
          example: 1100000
        currency:
          type: string
          example: USD
        line_items:
          type: array
          description: Every line item, in order. At most 10 on invoices created in Finta or through the API; invoices recorded from Stripe can have more.
          items:
            $ref: "#/components/schemas/InvoiceLineItem"
        adjustments:
          type: array
          description: Discounts, customer balance credits and tax, at most one of each kind.
          items:
            $ref: "#/components/schemas/InvoiceAdjustment"
        paid_transaction_id:
          type: string
          nullable: true
          description: The bank deposit matched as this invoice's payment. Null unless paid. It stays set even if that transaction is later deleted in Finta, so it may not be retrievable with `GET /transactions/{id}`.
          example: txn_a1b2c3d4e5f6g7
        paid_with_invoice_ids:
          type: array
          description: The other invoices sharing this invoice's payment, as inv_ IDs in stable order. Empty when unpaid, voided, or paid one-to-one. Unmatching any invoice on the payment reopens every one of them.
          items:
            type: string
            pattern: "^inv_"
          example: []
        paid_date:
          type: string
          format: date
          nullable: true
          description: The date of the matched deposit. Null unless paid.
        source:
          type: string
          enum:
            - manual
            - stripe
          description: "`stripe` when Finta recorded the invoice from the company's Stripe account; `manual` when it was entered in Finta or through the API."
        stripe_url:
          type: string
          nullable: true
          description: The invoice in the Stripe dashboard, for Stripe invoices.
        created:
          type: integer
          description: When the invoice was recorded in Finta, as a Unix timestamp.
          example: 1788220800
    InvoiceLineItem:
      type: object
      required:
        - description
        - amount_cents
        - category_id
        - category_name
        - spread_months
      properties:
        description:
          type: string
          nullable: true
          example: Annual license
        amount_cents:
          type: integer
          description: Positive for revenue; negative for a refund, which is recognized at once.
          example: 1200000
        category_id:
          type: string
          nullable: true
          description: The revenue category the line books to. A refund line always books to the company's refunds category, whatever category was sent.
          example: cat_r1a2b3c4d5e6f7
        category_name:
          type: string
          nullable: true
          example: Subscription Revenue
        spread_months:
          type: integer
          description: Months the revenue is recognized over, starting with the invoice date's month. 0 recognizes it on the invoice date.
          example: 12
    InvoiceAdjustment:
      type: object
      required:
        - kind
        - amount_cents
        - description
      properties:
        kind:
          type: string
          enum:
            - discount
            - customer_balance_credit
            - tax
          description: Discounts and customer balance credits reduce the amount due; tax adds to it.
        amount_cents:
          type: integer
          description: Always positive; `kind` says which way it moves the total.
          example: 100000
        description:
          type: string
          nullable: true
    InvoiceCreate:
      type: object
      additionalProperties: false
      required:
        - customer_name
        - invoice_date
        - line_items
      properties:
        customer_name:
          type: string
          minLength: 1
        invoice_date:
          type: string
          format: date
          description: The date the invoice was issued, as YYYY-MM-DD.
        line_items:
          type: array
          minItems: 1
          maxItems: 10
          items:
            type: object
            additionalProperties: false
            required:
              - amount_cents
            properties:
              description:
                type: string
                nullable: true
              amount_cents:
                type: integer
                minimum: -100000000000000
                maximum: 100000000000000
                description: Non-zero. Negative for a refund.
              category_id:
                type: string
                nullable: true
                pattern: "^cat_"
                description: A revenue category from `GET /categories`. Omit it for the company's default revenue category. Ignored on a refund (negative) line, which books to the company's refunds category. Choosing a category needs only Invoices Manage, not access to the categories list.
              spread_months:
                type: integer
                description: 0 (the default), or 2 to 120 to recognize the revenue monthly. Must be 0 on a refund line.
        adjustments:
          type: array
          description: Each kind at most once.
          items:
            type: object
            additionalProperties: false
            required:
              - kind
              - amount_cents
            properties:
              kind:
                type: string
                enum:
                  - discount
                  - customer_balance_credit
                  - tax
              amount_cents:
                type: integer
                minimum: 1
                maximum: 100000000000000
              description:
                type: string
                nullable: true
    InvoicePayment:
      type: object
      additionalProperties: false
      required:
        - transaction_id
      properties:
        transaction_id:
          type: string
          pattern: "^txn_"
          description: The bank deposit, from `GET /transactions`.
          example: txn_a1b2c3d4e5f6g7
    TransferCreate:
      type: object
      additionalProperties: false
      required:
        - outgoing_transaction_id
        - incoming_transaction_id
      properties:
        outgoing_transaction_id:
          type: string
          pattern: "^txn_"
          description: The money-out side, with a negative `amount_cents`.
          example: txn_a1b2c3d4e5f6g7
        incoming_transaction_id:
          type: string
          pattern: "^txn_"
          description: The money-in side in the other account, with the opposite, positive `amount_cents`.
          example: txn_h8i9j0k1l2m3n4
    Transfer:
      type: object
      additionalProperties: false
      required: [id, object, transfer_type, outgoing_transaction_id, incoming_transaction_id, amount_cents, currency, created]
      properties:
        id:
          type: string
          description: The transfer's `tfr_` ID, also the `transfer_id` of both transactions.
          example: tfr_a1b2c3d4e5f6g7
        object:
          type: string
          enum: [transfer]
        transfer_type:
          type: string
          enum: [bank_transfer, credit_card_payment]
          description: bank_transfer between two bank accounts; credit_card_payment when either side is a card.
        outgoing_transaction_id:
          type: string
          example: txn_a1b2c3d4e5f6g7
        incoming_transaction_id:
          type: string
          example: txn_h8i9j0k1l2m3n4
        amount_cents:
          type: integer
          description: The amount moved, positive.
          example: 2500000
        currency:
          type: string
          example: USD
        created:
          type: integer
          description: When the transfer was matched, as a Unix timestamp.
          example: 1771183600
    Error:
      type: object
      required:
        - error
      properties:
        error:
          type: object
          required:
            - type
            - code
            - message
            - doc_url
          properties:
            type:
              type: string
              enum:
                - authentication_error
                - permission_error
                - invalid_request_error
                - rate_limit_error
                - limit_error
                - api_error
              description: The error category. `authentication_error` (401) means the bearer credential was missing, malformed, or unrecognized. `permission_error` (403) means the credential was valid but the caller is not allowed to access the resource. `invalid_request_error` covers invalid parameters, unavailable resources, invitation validation failures, and requests that conflict with current state (409). `rate_limit_error` is for per-minute burst limits or signup email limits (wait for Retry-After). `limit_error` is for monthly API call limits per company (upgrade or wait for reset). `api_error` denotes an operation failure on the server (500), or temporarily unavailable rule definitions (503 rules_not_ready). Retry with exponential backoff; persistent 503 errors require operator attention.
            code:
              type: string
              enum:
                - invalid_api_key
                - user_inactive
                - user_removed_from_company
                - company_closed
                - subscription_required
                - invoice_overdue
                - invoice_status_unavailable
                - staging_access_denied
                - insufficient_permission
                - resource_missing
                - parameter_missing
                - invalid_email
                - category_update_failed
                - transaction_update_failed
                - rate_limit_exceeded
                - monthly_limit_exceeded
                - invalid_date_range
                - invalid_date
                - invalid_request
                - idempotency_key_conflict
                - plan_upgrade_required
                - request_required
                - feature_disabled
                - invitation_invalid
                - invitation_create_failed
                - permissions_update_failed
                - invitation_resend_failed
                - invitation_revoke_failed
                - integration_not_connectable
                - integration_already_connected
                - connect_link_in_progress
                - connect_link_failed
                - rules_not_ready
                - forms_not_ready
                - taxes_not_ready
                - rule_already_exists
                - rule_busy
                - tag_already_exists
                - tag_busy
                - tag_deletion_failed
                - revision_conflict
                - revision_history_mismatch
                - rule_deletion_failed
                - account_create_failed
                - category_create_failed
                - upload_committed
                - upload_commit_failed
                - feedback_unavailable
                - billing_unavailable
                - closed_books
                - invoice_already_exists
                - invoice_not_open
                - invoice_not_matched
                - payment_already_matched
                - transaction_not_matchable
                - invoice_operation_failed
                - transaction_already_matched
                - transfer_not_eligible
                - transfer_operation_failed
                - transaction_already_split
                - transaction_not_splittable
                - transaction_not_split
                - split_operation_failed
                - report_export_failed
                - report_export_limit_exceeded
                - invalid_file
                - wrong_document_type
                - file_already_attached
                - tax_upload_not_allowed
                - tax_upload_failed
                - inbox_item_not_completable
                - inbox_item_complete_failed
                - onboarding_incomplete
                - answers_incomplete
                - onboarding_locked
                - onboarding_complete
                - balance_mismatch
                - already_reconciled
                - account_closed
                - concurrent_change
                - not_reconciled
                - not_latest_reconciliation
                - reconciliation_failed
                - unreconcile_failed
              description: A specific error code.
            message:
              type: string
              description: A human-readable description of the error.
            doc_url:
              type: string
              format: uri
              description: A link to documentation about this error.
              example: https://www.finta.com/docs/api-reference/errors#invalid_api_key
            current_revision:
              type: integer
              minimum: 0
              description: Current sheet revision, returned on a revision conflict when available.
            finta_balance_cents:
              type: integer
              description: On balance_mismatch, Finta's balance through the period's end.
            statement_balance_cents:
              type: integer
              description: On balance_mismatch, the statement balance that was sent.
            difference_cents:
              type: integer
              description: On balance_mismatch, Finta's balance minus the statement balance.
            last_reconciliation_id:
              type: string
              nullable: true
              description: On not_latest_reconciliation, the recon_ ID of the account's latest reconciliation, which has to be undone first.
            param:
              type: string
              nullable: true
              description: The parameter that caused the error, if applicable.
            missing:
              type: array
              items:
                type: string
              description: With `answers_incomplete`, the onboarding answer keys still required before submitting.
            onboarding_status:
              type: string
              enum:
                - questionnaire
                - ready_to_submit
                - call_required
                - not_eligible
                - connect_banks
                - syncing
                - complete
              description: With `onboarding_incomplete`, where the company is in onboarding, as `GET /onboarding` returns it in `status`.

  responses:
    Unauthorized:
      description: >-
        Authentication failed. Returned when the bearer credential itself is missing,
        malformed, or unrecognized. The consumer should obtain a valid API key and retry.
        This is distinct from `403 Forbidden`, which is returned when the credential is
        valid but the caller is not allowed to access the resource.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              type: authentication_error
              code: invalid_api_key
              message: Invalid API key provided. Check that your API key is correct and active.
              doc_url: https://www.finta.com/docs/api-reference/errors#invalid_api_key

    Forbidden:
      description: >-
        The API key was successfully authenticated, but the caller is not allowed to access
        this resource. The fix is not "get a new key" (that returns 401) but "regain access"
        (reactivate the user, restore company access, restore an active subscription, or ask
        an administrator for the required product permission). These
        codes can be returned, distinguished by `error.code`:
          - `user_inactive` - the user that owns the API key has been deactivated.
          - `user_removed_from_company` - the user no longer has access to the company the key was created for.
          - `company_closed` - the company is closed, closing, or deleted.
          - `subscription_required` - the company does not have an active subscription.
          - `invoice_overdue` - a linked billing invoice remains unpaid after its Pacific due date; pay every overdue linked invoice in the web app to restore access, independently of the subscription or plan.
          - `staging_access_denied` - the staging environment is restricted to Finta users.
          - `insufficient_permission` - the key owner lacks API access or the endpoint's required area permission.
          - `feature_disabled` - the endpoint, or an input it was sent such as a department, is in early access and not yet turned on for this company; contact Finta to request access. See "Early access endpoints" under Errors.
          - `plan_upgrade_required` - the request names a department and the company's plan doesn't include departments. The message links the company's Plans page. See "Early access endpoints" under Errors.
          - `onboarding_incomplete` - the company hasn't finished onboarding, so only the onboarding endpoints and the few it needs are available; the connect endpoints only once Finta has approved the company. `onboarding_status` says where it is; call `GET /onboarding` and follow its `next_step`. See "Onboarding".
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            user_inactive:
              summary: User has been deactivated
              value:
                error:
                  type: permission_error
                  code: user_inactive
                  message: The user associated with this API key is no longer active. Reactivate the user or create a new API key under an active user.
                  doc_url: https://www.finta.com/docs/api-reference/errors#user_inactive
            user_removed_from_company:
              summary: User no longer has access to this company
              value:
                error:
                  type: permission_error
                  code: user_removed_from_company
                  message: The user associated with this API key no longer has access to this company. Ask a company admin to re-grant access, then create a new API key.
                  doc_url: https://www.finta.com/docs/api-reference/errors#user_removed_from_company
            company_closed:
              summary: Company is closed
              value:
                error:
                  type: permission_error
                  code: company_closed
                  message: This company is closed. The API is not available for closed companies.
                  doc_url: https://www.finta.com/docs/api-reference/errors#company_closed
            subscription_required:
              summary: Company subscription is not active
              value:
                error:
                  type: permission_error
                  code: subscription_required
                  message: This company does not have an active subscription. Visit Settings -> Plans at app.finta.com to resolve, then retry.
                  doc_url: https://www.finta.com/docs/api-reference/errors#subscription_required
            staging_access_denied:
              summary: Staging access is restricted
              value:
                error:
                  type: permission_error
                  code: staging_access_denied
                  message: This staging environment is restricted to Finta users.
                  doc_url: https://www.finta.com/docs/api-reference/errors#staging_access_denied
            insufficient_permission:
              summary: Key owner lacks the required product permission
              value:
                error:
                  type: permission_error
                  code: insufficient_permission
                  message: "The API key owner needs the Transactions: View permission to use this endpoint."
                  doc_url: https://www.finta.com/docs/api-reference/errors#insufficient_permission
            feature_disabled:
              summary: Endpoint is in early access and not yet on for this company
              value:
                error:
                  type: permission_error
                  code: feature_disabled
                  message: This feature isn’t turned on for this company yet. Contact Finta to request access.
                  doc_url: https://www.finta.com/docs/api-reference/errors#feature_disabled
            onboarding_incomplete:
              summary: The company hasn't finished onboarding
              value:
                error:
                  type: permission_error
                  code: onboarding_incomplete
                  message: This company hasn’t finished onboarding with Finta, so this endpoint isn’t available yet. Call GET /api/v1/onboarding and follow its next_step.
                  doc_url: https://www.finta.com/docs/api-reference/errors#onboarding_incomplete
                  onboarding_status: connect_banks

    InvoiceStatusUnavailable:
      description: >-
        Payment status for a linked billing invoice could not be verified after its
        Pacific due-date cutoff. No endpoint data or mutation is returned. The error type
        is api_error and the code is invoice_status_unavailable. Retry after 60 seconds,
        then use bounded exponential backoff while the status service is unavailable.
        This refusal does not apply to unlinked companies or before the cutoff.
      headers:
        Retry-After:
          description: Seconds to wait before retrying this temporary payment-status failure.
          schema:
            type: integer
            example: 60
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          example:
            error:
              type: api_error
              code: invoice_status_unavailable
              message: We couldn’t verify your invoice payment status. Please try again shortly.
              doc_url: https://www.finta.com/docs/api-reference/errors#invoice_status_unavailable

    InvoiceNotFound:
      description: >-
        The invoice ID is malformed, unknown, or belongs to another company. All three return the
        same `resource_missing` error so callers cannot probe for IDs in other companies.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              type: invalid_request_error
              code: resource_missing
              message: No invoice found for id.
              param: id
              doc_url: https://www.finta.com/docs/api-reference/errors#resource_missing

    RateLimited:
      description: >-
        Too many requests. Two distinct limits can trigger a 429:
        (1) Per-minute burst limit (6,000 requests per 60 seconds per API key) returns `rate_limit_error` / `rate_limit_exceeded` with `Retry-After` header.
        (2) Monthly API call limit per company returns `limit_error` / `monthly_limit_exceeded` with the reset date in the message. Monthly limits vary by plan: Formation (10/month), Startup (300/month), Growth (30,000/month). Enterprise bypasses commercial monthly quotas, but still counts requests and enforces per-minute abuse limits. Blocked requests do not count toward the limit.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the burst rate limit resets. Only present for rate_limit_error responses.
        X-RateLimit-Limit:
          schema:
            type: integer
          description: Maximum requests per burst window. Only present for rate_limit_error responses.
        X-RateLimit-Remaining:
          schema:
            type: integer
          description: Requests remaining in burst window (0 when rate limited). Only present for rate_limit_error responses.
        X-RateLimit-Reset:
          schema:
            type: integer
          description: Unix timestamp when the burst window resets. Only present for rate_limit_error responses.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            burst_rate_limit:
              summary: Per-minute burst rate limit exceeded
              value:
                error:
                  type: rate_limit_error
                  code: rate_limit_exceeded
                  message: Too many requests. Please retry after the Retry-After period.
                  doc_url: https://www.finta.com/docs/api-reference/errors#rate_limit_exceeded
            monthly_limit:
              summary: Monthly API call limit exceeded
              value:
                error:
                  type: limit_error
                  code: monthly_limit_exceeded
                  message: "Monthly API limit reached. Your plan allows 300 calls per company per month. Resets on April 1, 2026. Upgrade in Settings -> Plans at app.finta.com."
                  doc_url: https://www.finta.com/docs/api-reference/errors#monthly_limit_exceeded

    InvalidDate:
      description: >-
        A date query parameter was present but could not be parsed as a date, so the
        request was rejected instead of silently defaulting. `param` names the
        offending query parameter.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            error:
              type: invalid_request_error
              code: invalid_date
              message: 'The `date` value "banana" is not a valid date. Use ISO 8601 format, for example 2025-12-31.'
              doc_url: https://www.finta.com/docs/api-reference/errors#invalid_date
              param: date

    ReportDateError:
      description: >-
        The request was rejected because of a date query parameter. Either a date value
        could not be parsed (`invalid_date`) or `start_date` was after `end_date`
        (`invalid_date_range`). `param` names the offending parameter.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            invalid_date:
              summary: A date value could not be parsed
              value:
                error:
                  type: invalid_request_error
                  code: invalid_date
                  message: 'The `start_date` value "banana" is not a valid date. Use ISO 8601 format, for example 2025-12-31.'
                  doc_url: https://www.finta.com/docs/api-reference/errors#invalid_date
                  param: start_date
            invalid_date_range:
              summary: start_date is after end_date
              value:
                error:
                  type: invalid_request_error
                  code: invalid_date_range
                  message: start_date must be on or before end_date.
                  doc_url: https://www.finta.com/docs/api-reference/errors#invalid_date_range
                  param: start_date
