> ## Documentation Index
> Fetch the complete documentation index at: https://docs.tesouro.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Transaction CSV download

> Stream a bank account's transaction history as a ready-to-import, QuickBooks-compatible CSV file.

One request returns an account's transaction history for a date range as a CSV file, ready to import into QuickBooks or any spreadsheet. Tesouro generates the file on demand and streams it back within the same request. Nothing is stored, there is no job to poll and no download URL to retain.

```http lines theme={null}
GET /embedded-banking/v1/bank-accounts/{id}/transactions/download?startDate=2024-06-01&endDate=2024-06-30
```

Required scope: `bank_account:read:org`. Accepts both APP and USER tokens.

Permission to download an account's transactions is the same permission as reading the account itself. There is no separate entitlement — if Northfield Software can read the account, it can download the transactions; if it can't, the download is denied.

## Parameters

| Parameter   | In    | Description                                               |
| :---------- | :---- | :-------------------------------------------------------- |
| `id`        | path  | The bank account ID.                                      |
| `startDate` | query | Inclusive start of the range, `YYYY-MM-DD`. **Required.** |
| `endDate`   | query | Inclusive end of the range, `YYYY-MM-DD`. **Required.**   |

<Warning>
  Both dates are required here. This differs from the [JSON transactions endpoint](/embedded-banking/guides/transactions/filtering), where each bound is optional and can be supplied independently. Omitting either date returns a `400`.
</Warning>

`endDate` must be on or after `startDate`.

## The response

A successful download is a `200` carrying the file itself:

| Header                | Value                                                                   |
| :-------------------- | :---------------------------------------------------------------------- |
| `Content-Type`        | `text/csv; charset=utf-8`                                               |
| `Content-Disposition` | `attachment; filename="transactions_{last4}_{startDate}_{endDate}.csv"` |

The filename carries the account's last four digits and the requested range — for example, `transactions_7902_2024-06-01_2024-06-30.csv`.

The response is chunked and carries no `Content-Length`. Tesouro pages through the banking core and writes rows as each page arrives, so the download starts promptly and stays flat in memory no matter how long the history is.

## File format

Three columns, always in this order:

```csv theme={null}
Date,Description,Amount
2024-06-15,Cash Withdrawal,-100.00
2024-06-14,Cash Deposit,250.50
```

| Column        | Contract                                                                                                                                                                                                                                                              |
| :------------ | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Date`        | The transaction's effective date, `YYYY-MM-DD`. A transaction the banking core reports with no effective date renders as `0001-01-01`.                                                                                                                                |
| `Description` | The description as posted by the banking core, with multi-part descriptions joined into one field with spaces. When the core posts no description, this falls back to the transaction code's description, or the literal `Unknown Transaction` if neither is present. |
| `Amount`      | A single signed value. Debits are negative, credits are positive and unsigned.                                                                                                                                                                                        |

The file is RFC 4180-compliant, CRLF-terminated, and UTF-8 without a byte-order mark.

Amounts carry no currency symbol and no thousands separator, and are written at the currency's own precision — USD writes `12.34`, JPY writes `12`. The file has no currency column, so every row in a single export shares one currency.

<Info>
  The `Amount` column is **signed**, unlike the JSON endpoint's `amount` field, which is unsigned and paired with a separate `direction`. See [Record shape](/embedded-banking/guides/transactions/record-shape). Don't carry the unsigned-plus-direction rule over to the CSV.
</Info>

### Empty ranges

A valid range with no transactions returns `200` and a header-only file — just `Date,Description,Amount` and nothing else. It isn't an error, and clients should render it as an empty statement rather than a failure.

### Descriptions and spreadsheet formulas

Descriptions are free text from the banking core. When a description begins with a character a spreadsheet reads as the start of a formula — `=`, `+`, `-`, `@`, tab, or carriage return — Tesouro prefixes it with a single quote so Excel, Sheets, and QuickBooks treat the value as literal text.

Only `Description` is treated this way. `Date` leads with a digit, and a leading `-` on `Amount` is a legitimate debit, not a formula.

## Errors

Every failure Tesouro detects before streaming begins is a clean JSON problem response, never a partial file. A failure that surfaces after streaming has started can't change the already-sent status code — see [Detecting an incomplete download](#detecting-an-incomplete-download).

| Status | Error code                    | Cause                                                                                                                                                                        |
| :----- | :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `400`  | `VALIDATION_ERROR`            | A required date is missing, or `endDate` precedes `startDate`.                                                                                                               |
| `401`  | —                             | The request carried no valid token.                                                                                                                                          |
| `403`  | —                             | The token is valid but lacks the `bank_account:read:org` scope, or required disclosures haven't been accepted.                                                               |
| `404`  | `BANK_ACCOUNT_NOT_FOUND`      | The account doesn't exist, or the caller can't access it.                                                                                                                    |
| `500`  | `TRANSACTION_RETRIEVAL_ERROR` | An unexpected server-side fault — resolving account access or reading the account failed before streaming began.                                                             |
| `502`  | `TRANSACTION_PROVIDER_ERROR`  | The banking core was unavailable, or the export was refused — for example, an account whose transactions span more than one currency. The response carries a correlation ID. |

An inaccessible account and an absent account both return `404`. Tesouro doesn't distinguish them, so a caller can't probe for the existence of accounts it can't read.

A `400` with error code `VALIDATION_ERROR` covers a missing or inverted range. A date that is present but unparseable (`startDate=2024-13-99`) is rejected earlier, during model binding, and returns a standard validation `400` — an `errors` object keyed by field, without the `VALIDATION_ERROR` code. Branch on the status, not the code, when a date may be malformed.

A `502` is not always transient. An outage is worth retrying with the correlation ID; a currency refusal is permanent and won't succeed on retry.

<Note>
  A single CSV can't represent more than one currency, so a mixed-currency account is refused rather than written with mixed amounts. If the banking core reports multiple currencies on the first page, that refusal is a clean `502` before streaming begins. If a later page introduces a new currency after streaming has started, the download can't become a `502` — it resets mid-stream like any [incomplete download](#detecting-an-incomplete-download).
</Note>

### Detecting an incomplete download

Because the response is chunked, a failure that happens *after* streaming begins can't change the status code — the `200` is already sent. When this happens Tesouro resets the stream instead of terminating it normally, so the transfer fails at the transport level rather than producing a silently truncated file.

Treat a transfer error mid-download as a failed download and retry. Never treat a partially received body as a complete statement.

## Triggering the download from a browser

The endpoint requires an `Authorization` header, so a plain `<a href="...">` or `window.open()` won't work — the browser sends those without your token and gets a `401`. Fetch the response, turn it into a blob, and drive the download from an object URL:

```js theme={null}
async function downloadTransactions(bankAccountId, startDate, endDate, accessToken) {
  const url = new URL(
    `/embedded-banking/v1/bank-accounts/${bankAccountId}/transactions/download`,
    "https://api.tesouro.com",
  );
  url.searchParams.set("startDate", startDate);
  url.searchParams.set("endDate", endDate);

  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${accessToken}` },
  });

  if (!response.ok) {
    // A 401 carries no body, so don't assume JSON — fall back to the status.
    const problem = await response.json().catch(() => null);
    throw new Error(problem?.detail ?? `Download failed with ${response.status}`);
  }

  // Rejects if the stream resets mid-download, so a truncated file never reaches the user.
  const blob = await response.blob();

  const objectUrl = URL.createObjectURL(blob);
  const anchor = document.createElement("a");
  anchor.href = objectUrl;
  anchor.download = filenameFrom(response.headers.get("Content-Disposition"));

  // Some browsers only fire a programmatic click on an anchor that's in the document.
  document.body.append(anchor);
  anchor.click();
  anchor.remove();

  // Defer the revoke so the browser has started the download before the URL goes away.
  setTimeout(() => URL.revokeObjectURL(objectUrl), 0);
}

function filenameFrom(contentDisposition) {
  const match = contentDisposition?.match(/filename="([^"]+)"/);
  return match?.[1] ?? "transactions.csv";
}
```

`await response.blob()` buffers the whole file in memory and rejects if the stream resets, which is what makes the truncation check above work. For very large ranges, stream `response.body` to the File System Access API or a service worker instead, and handle the reset yourself.

<Warning>
  Read the filename off `Content-Disposition` rather than rebuilding it client-side. The account's last four digits come from the server, and reconstructing the name means holding account details the front end may not otherwise need.
</Warning>

<Note>
  A cross-origin `fetch` can only read `Content-Disposition` if the response also carries `Access-Control-Expose-Headers: Content-Disposition`. Without it, `response.headers.get("Content-Disposition")` returns `null` and the snippet above falls back to `transactions.csv` — so keep the fallback, and confirm the header is exposed on your environment if you need the account/date-stamped name.
</Note>

## Auditing

Every download writes a `SENSITIVE_DATA_VIEWED` audit event before streaming starts, recording the actor, the account, the date range, and the number of records exposed. The event is written even if the business customer cancels the download halfway through — the data was already disclosed.

Standard logs carry metadata only: request ID, status, latency, pages fetched, and row counts. No transaction values, and the account is masked to its last four digits.
