---
title: "Import bank-statement CSV rows for a customer"
method: POST
path: "/accountant/companies/{customerId}/transactions/import"
tags: ["Accountant Portal", "Transactions"]
---

# Import bank-statement CSV rows for a customer

`POST /accountant/companies/{customerId}/transactions/import`

## Request

- Base URL: `https://api.medianfi.com/api/v1`
- URL: `https://api.medianfi.com/api/v1/accountant/companies/{customerId}/transactions/import`
- Auth: one of:
  - HTTP bearer
  - API key in header `X-API-Key`

Accepts rows parsed from a bank-statement CSV in the accountant portal and posts them to the selected "deposit to" account. `accountId` is polymorphic — an integration_account id (Mercury/Ramp/Plaid), a bank_account id (legacy/manual), or a gl_account id (direct Chart-of-Accounts selection) — and is resolved to a concrete GL account before any rows are written. Rows are deduped by a stable (company, account/entity, date, amount, normalized description) identity stored in `transactions.external_id`, so re-uploading the same file produces zero duplicates. The command carries the concrete legal entity; it is mandatory for a direct GL-account import and must match any connected account owner. The categorization engine runs on the newly imported set. When the async job service is configured (the production default) the request returns 202 with a pending job to poll via the import-jobs endpoint; account resolution and row processing happen in the worker, and failures surface on the job record. Servers without the job service process synchronously and return 200 with the final summary.

## Request body

- ImportTransactionsCsvInput
  - `accountId` string, uuid, required: The "deposit to" account rows will post against. May be an integration_account id (Mercury/Ramp/Plaid), a bank_account id (legacy/manual), or a gl_account id (direct Chart-of-Accounts selection, e.g. Undeposited Funds); resolved server-side to a concrete GL account.
  - `entityId` string, uuid: Concrete legal entity that owns the import. Required for direct GL-account imports; when the selected account is connected, this value must match its immutable entity owner.
  - `rows` TransactionImportRow[], required
    - `rowNumber` integer: 1-based row position in the source CSV, echoed back in results
    - `occurredOn` string, nullable: Posting date (YYYY-MM-DD)
    - `description` string, nullable
    - `amountCents` integer, nullable: Positive amount in cents; `direction` carries the sign
    - `direction` 'in' | 'out' | 'null', nullable
    - `merchant` string, nullable
    - `merchantName` string, nullable
    - `currency` string, nullable: ISO 4217 code. When omitted, the row takes the resolved account's currency (the integration or bank account, or the sole bank account bound to a selected GL account) and must otherwise match it. A row with no currency on an account that has none is rejected as invalid. A value that is not an ISO 4217 code marks only that row invalid; the other rows still import.
    - `sourceStatus` string, nullable
    - `sourceAccount` string, nullable
    - `bankDescription` string, nullable
    - `memo` string, nullable
    - `counterparty` string, nullable
    - `counterpartyId` string, nullable
    - `sourceCategory` string, nullable
    - `providerCategory` string, nullable
    - `merchantCategoryCode` string, nullable
    - `trackingId` string, nullable
    - `timestamp` string, nullable
    - `categoryAccountCode` string, nullable: Optional pre-categorization by Chart-of-Accounts code (e.g. "4100"). The row posts already categorized against the resolved account instead of landing in Uncategorized, and is excluded from the post-import rule engine. `categoryGlAccountId` wins when both are present.
    - `categoryGlAccountId` string, uuid, nullable: Optional pre-categorization by GL account id (must belong to the company and be active). Wins over `categoryAccountCode`.
  - `csvFingerprint` string, nullable: Optional client-generated hash of the raw CSV content. Kept for tracing in raw payloads only; it does not scope row-level dedup.

## Response `200`

Import processed synchronously (job service not configured)

- TransactionImportSummaryResponse
  - `success` boolean
  - `data` TransactionImportSummary: Final result of one CSV import run (`ImportSummary` in the API source)
    - `requested` integer: Number of rows in the request
    - `imported` integer
    - `skipped` integer: duplicates + invalid
    - `duplicates` integer
    - `sourceMapped` integer: Imported rows linked to an integration or bank account. A Chart-of-Accounts pick counts when the importer adopted the sole bank account bound to that GL (#2119); it is 0 only when no bank account could be resolved for the deposit-to account.
    - `errors` integer: Mirrors `invalid`
    - `invalid` integer
    - `results` TransactionImportResultItem[]
      - `index` integer: 0-based position of the row in the request `rows` array
      - `rowNumber` integer: Echo of the request row's `rowNumber` when supplied
      - `status` 'imported' | 'duplicate' | 'invalid'
      - `transactionId` string, uuid: Created transaction id, or the existing transaction for duplicates
      - `externalId` string: Stable dedup identity stored in `transactions.external_id`
      - `reason` string: Why the row was skipped (`duplicate`) or rejected (`invalid`)
      - `code` string: Error code behind an `invalid` row whose write failed (for example `CONFLICT`), when the failure carried one
      - `postingError` object: Set on an `imported` row whose transaction was written but whose categorize update or journal posting then failed (#2930). The transaction is kept without a journal entry; `message` is the real failure.
        - `message` string, required
        - `code` string: Error code of the failure, when it carried one
    - `categorizationTriggered` boolean
    - `engineSubmissionTriggered` boolean
    - `bookkeepingEngineTriggered` boolean
    - `bookkeepingEngineRunId` string
    - `balancesRecomputed` boolean
    - `resolvedAccount` TransactionImportResolvedAccount: Which resolution path the polymorphic request `accountId` took and which account FKs the imported rows carry. `via: gl_account` (direct Chart-of-Accounts selection) leaves `integrationAccountId` null; `bankAccountId` is the sole non-personal bank account bound to that GL account when exactly one exists (the importer adopts it so rows stay reconcilable, #2119) and null when none or several are bound — the null case is a genuinely GL-only account with no linked bank feed, expected rather than orphaned data. Added by issue #1231; absent on summaries recorded before it shipped.
      - `via` 'integration_account' | 'bank_account' | 'gl_account': Which lookup matched the request `accountId` (table names)
      - `glAccountId` string, uuid
      - `integrationAccountId` string, uuid, nullable
      - `bankAccountId` string, uuid, nullable
      - `label` string
    - `postingFailed` integer: Imported rows carrying `postingError` (#2930). Absent on summaries recorded before it shipped.

## Other responses

- `202`: Import job accepted (async path). Poll the import-jobs endpoint until `status` is `completed` or `failed`; the final summary lands on the job's `summary` field.
- `401`: Authentication required
- `403`: Insufficient permissions or not a company member
- `404`: Deposit-to account not found for this customer (synchronous path)
- `422`: Request failed validation

---

[API](https://skmtc.dev/medianfi/apis/median-api.md) · [All operations](https://skmtc.dev/medianfi/apis/median-api/llms.txt) · [OpenAPI document](https://skmtc.dev/medianfi/apis/median-api/revisions/20090ea32b0d?raw)
