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
endDate must be on or after startDate.
The response
A successful download is a200 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 returns200 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 — the200 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 anAuthorization 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.
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 aSENSITIVE_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.