Skip to main content
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.
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

Both dates are required here. This differs from the JSON transactions endpoint, where each bound is optional and can be supplied independently. Omitting either date returns a 400.
endDate must be on or after startDate.

The response

A successful download is a 200 carrying the file itself: 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:
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.
The Amount column is signed, unlike the JSON endpoint’s amount field, which is unsigned and paired with a separate direction. See Record shape. Don’t carry the unsigned-plus-direction rule over to the CSV.

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. 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.
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

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:
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.
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.
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.

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.