Bank operations
A bank operation (bank_operation) is one line of a bank account: a movement, with its amount, its date, the bank's own label, what it has been reconciled against and the accounting entry it produced. This is the resource that carries a movement's whole life, from arrival to posting.
An operation arrives from a sync (Bank syncs) or from the extraction of an uploaded statement (Bank statements) - at extraction, before any commit: committing the statement posts its lines, it does not create them. It is booked against an account (Bank accounts), and what it produces in accounting is read on the entry (Bank accounting entries).
The thing to understand before anything else
The list applies no reconciliation status filter. Every status is returned unless you ask for one in particular, and that matters most for partially_matched: a half-allocated line stays listed until its remainder is placed or dismissed. A reconciliation queue built on this endpoint therefore cannot silently lose a half-settled movement.
Archiving is not an exception either. Archived lines are returned too: an absent archived narrows nothing. ?archived=false is what asks for the active lines, and ?archived=true for the archived ones only. The bank accounts resource applies exactly the same rule.
Put another way, beyond the company in the path this endpoint narrows nothing on your behalf: what you did not ask for, you get.
Read-only
This resource is never written. There is no POST, no PATCH and no DELETE. Nothing on this surface creates an operation, corrects one or archives one:
- to correct a line that came from a statement - its amount, its date, its label - go through the review of the statement that produced it (
PATCH /api/v1/bank_statements/{id}/lines); - a line that came from a sync is what the bank transmitted, and it is not corrected;
- to change what an operation produces in accounting, change the reconciliation behind it. The entry follows: confirming a match rewrites its lines in place, other paths destroy it and re-project it. Either way what you change is the reconciliation, never the entry.
No call on this page is a write, so the Idempotency-Key header has no purpose here.
Two sub-resources are written, though - and they are the only paths by which an operation changes reconciliation state: bank_operations/{id}/allocations drafts an allocation, and bank_operations/{id}/reconciliation settles it. Both are described in Allocations and reconciliation.
The endpoints
GET /api/v1/workspaces/{workspace_id}/companies/{company_id}/bank_operations- list a company's operationsGET /api/v1/bank_operations/{id}- read one operation
As on the other banking resources, only the list is addressed by workspace and by company. The endpoint carrying an id has no workspace_id in its path: the id is resolved across every workspace your OAuth client holds a grant on. Both reads require the read scope.
The list is ordered by operation_date descending, newest first, and ties are broken by descending id - so pagination neither repeats nor skips a line when a bank sends several movements on the same day.
The fields
An operation carries twenty-two fields, all present in every response.
| Field | What it carries |
|---|---|
bank_account_id | The bank account the movement happened on |
statement_id | The statement the line was read out of, or null when it came from a sync. Also null on a statement line whose file has since been deleted: deleting a statement nullifies the statement_id of every line it could not take with it, leaving an orphan that keeps origin: "statement" and is deliberately no longer postable. So a null here does not mean "came from a sync" - read origin for that |
amount | The signed amount, published as a string at four decimals. See the section below |
currency_code | The movement's currency, ISO 4217 |
direction | incoming or outgoing. This is the sign of amount stated in words: the two are never in disagreement |
operation_date | The date the bank booked the movement. This is what the list is sorted by |
value_date | The value date, when the source supplies one. Often null |
description | The bank's own label, with any IBAN it quotes masked - see below |
clean_description | The normalised counterparty name, when one could be derived from the label. Same masking as description |
category | The data provider's category, passed through unchanged. null when none was supplied |
operation_type | The instrument behind the movement when the source names one: card, deferred_debit_card, transfer, direct_debit, check, withdrawal, deposit, open_banking or unknown |
pending | The bank still reports the movement as provisional - an authorisation that may still be withdrawn. While it is, no entry is created or re-projected for this line. It does not follow that projection.posted is false: an entry it already carries is left untouched - see projection |
reconciliation_status | unmatched, proposed, partially_matched, matched or rejected. This is the field the list is filtered on |
origin | synchronized for a line from a sync, statement for a line from a statement. There is no manually created operation. A statement line exists as soon as the extraction read it, whether or not that statement has been committed - so this field says nothing about projection.posted |
archived | The line has been taken out of the working set. The list does not exclude them for that - see the archived filter below. Same projection rule as a pending line: nothing is created or re-projected for it, and what it already carries is preserved |
projection | Where the line landed in accounting. See below |
reconciliation | What the confirmed allocations settled, and what is left. See below |
version | The optimistic-locking token that POST /api/v1/bank_operations/{id}/reconciliation requires back as expected_version. An integer, and it moves on every change to the allocation set, including the ones that leave reconciliation_status where it was. See Allocations and reconciliation |
id, company_id, created_at and updated_at complete the list.
An IBAN quoted in a label is never published in full. Banks often copy the counterparty's account into the text of a transfer or a direct debit. In description and clean_description, an IBAN written in one piece, or grouped in blocks as long as its check digits are valid, is replaced by the same form as iban_masked: FR7630006000011234567890189 becomes FR*********************0189. The rest of the label is returned as the bank sent it. The same rule applies to the label of the entry embedded by include=bank_accounting_entry.
amount is a string, and that is deliberate
"amount": "300.0000"
Four decimals, and the JSON type is a string, not a number. The reason is arithmetic: the column accepts nineteen significant digits, which a double-precision float cannot carry without altering them. Publishing a JSON number would bring the largest accepted value back out as 1.0e15.
Two consequences for you:
- parse it as a decimal, never as a float, using your language's decimal type;
- the same line read elsewhere carries exactly the same string.
GET /api/v1/bank_statements/{id}/linespublishes this column in the same format, and that is the format a statement review lets you write it at.
The sign follows the direction: an incoming operation is positive, an outgoing one is negative. You never have to re-derive the sign from direction.
The two amounts under reconciliation are genuine JSON numbers at two decimals: both are computed values, neither is a stored column, and neither is ever written back.
reconciliation: what is settled, and what is left
"reconciliation": {
"status": "partially_matched",
"allocated_amount": 200.00,
"unallocated_remainder": 100.00,
"allocations_count": 1
}
Only confirmed allocations count. A proposal nobody has confirmed counts for neither amount, and a rejected one does not either: neither moved any money. allocations_count counts the same confirmed allocations that allocated_amount adds up.
The two amounts sum to the absolute value of amount, rounded to two decimals. That is the relationship to remember: allocated_amount is what the allocations settled, unallocated_remainder is what is not yet placed anywhere - and, on a posted line, it is exactly the amount its entry's suspense line carries.
The rounding is part of the relationship, not a degradation of it. Both figures are computed at the two-decimal accounting quantum while amount publishes four: on an operation of 300.0001 with nothing allocated, the pair sums to 300.00, not 300.0001. The missing hundredth of a cent is not lost money - it is a precision no ledger line can carry. So do not expect equality at amount's four decimals: check it after rounding to two.
What you have to do to make these amounts move is described in Allocations and reconciliation: only a confirmed reconciliation shifts them.
status repeats reconciliation_status. The field is published in both places because it is the one the list is filtered on, and a reader working inside reconciliation should not have to climb a level to read it.
projection: whether the line reached the ledger
"projection": {
"bank_accounting_entry_id": 44120,
"posted": true,
"exported": false,
"incident_code": null
}
An entry exists only where posting succeeded, so posted and the presence of bank_accounting_entry_id are one and the same fact. There is no state in which an operation carries an entry without having been posted.
exported says whether that entry has left the books - exported to an accounting package, or delivered to a firm. It is false and not null on an unposted operation: a line that never entered the books has certainly not left them. posted and exported are derived on every read, never stored. incident_code, on the other hand, is recorded when each attempt runs - see below.
posted describes the ENTRY, never the line's current eligibility to be posted. A pending or archived line has no entry created or re-projected for it - but one it already carried is preserved untouched. So posted: true alongside pending: true or archived: true is readable, and it reads like this: the entry was written while the line was settled and active, and it is not being refreshed now. Treat it as a record of what was posted, not as a live projection of the line. When such a line resumes being projected is outside the scope of this contract. It may also carry skipped_pending or skipped_archived: its last attempt was skipped, and the older entry is preserved.
A line that came from a statement is also only postable once that statement has been committed.
incident_code: what the last attempt ran into
incident_code says why the last posting attempt of this line did not succeed, or is null. It is recorded when the attempt runs, then returned as is: it is never inferred at read time. The key is always present.
- An attempt that is refused or skipped records its code.
- A successful attempt resets it to
null, including when there was nothing to rewrite. - When two attempts overlap, the most recent one wins: an older attempt that finishes late never overwrites a newer result.
- An attempt interrupted by an unexpected error records nothing and leaves the previous value.
- A line with no attempt since this field was published carries
null: no history is reconstructed.
incident_code | What the last attempt ran into |
|---|---|
provider_unavailable | The provider no longer gave access to the bank account |
not_activated | The bank account was not activated for posting |
missing_ledger | No accounting journal was attached to the bank account |
missing_bank_account_code | No accounting account code was set on the bank account |
missing_suspense_account_code | No suspense account was set on the bank account |
skipped_archived | The line was archived: nothing was created or re-projected |
skipped_pending | The line was still provisional: nothing was created or re-projected |
skipped_cross_source_held | The line is held because a statement line and a synchronized line may describe the same movement. It awaits a human choice, and nothing is posted twice |
unsupported_currency | The currency is not the euro |
direction_amount_mismatch | The sign of amount and direction disagreed. A stored operation cannot carry that disagreement (see direction), so this code is never recorded |
zero_amount | The amount is zero |
missing_party_account | A confirmed reconciliation designates no party, or the party has no auxiliary account |
invalid_entry | The entry could not be saved; nothing changed |
When several conditions are missing on the bank account, only the first is recorded, in the order of the table; readiness.missing lists them all.
This is not the account's current state. incident_code describes a past attempt, whereas the bank account's posting conditions are computed on every read in readiness (Bank accounts). The two can diverge: a missing_bank_account_code stays on the line after you have set the account code, until the next attempt. To know whether an account can post now, read readiness.
posted: true alongside an incident_code is readable too. The entry comes from an earlier successful attempt, and the last attempt did not succeed: the existing entry was not touched. An entry that has already left the books is, moreover, never rewritten: if its re-imputation finds no party account, the attempt records missing_party_account and the entry stays as it was.
A line that was not attempted records nothing. It keeps null, or the code of its last attempt, and nothing is inferred at read time:
- the synchronization does not attempt a line whose account has no journal, is not activated or is no longer provided by the provider - the account's
readinessthen says why it is not posted. Nor does it attempt a line whose bank connection isdisconnected, or an archived line; - a synchronized line on an account missing only an account code is attempted, and records
missing_bank_account_codeormissing_suspense_account_code; - committing a statement attempts each line it posts, in order, and stops at the first refusal: the refused line records its code, the whole commit is rolled back (
posting_failed), and the other lines keep their previous value. If the account has no journal, is not activated or is no longer provided, the commit is refused (account_not_ready) before any attempt, and no line records a code.
missing_ledger, not_activated and provider_unavailable therefore only appear if the account changed state while an attempt was under way.
Read one operation
A read token is enough.
curl https://app.scribee.tech/api/v1/bank_operations/30144 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
"data": {
"id": 30144,
"company_id": 34,
"bank_account_id": 811,
"statement_id": 9001,
"amount": "300.0000",
"currency_code": "EUR",
"direction": "incoming",
"operation_date": "2026-07-14",
"value_date": "2026-07-15",
"description": "VIR SEPA ACME CORP",
"clean_description": "ACME CORP",
"category": "income",
"operation_type": "transfer",
"pending": false,
"reconciliation_status": "partially_matched",
"origin": "statement",
"archived": false,
"projection": {
"bank_accounting_entry_id": 44120,
"posted": true,
"exported": false,
"incident_code": null
},
"reconciliation": {
"status": "partially_matched",
"allocated_amount": 200.00,
"unallocated_remainder": 100.00,
"allocations_count": 1
},
"version": 6,
"created_at": "2026-07-14T06:12:04+02:00",
"updated_at": "2026-07-14T06:12:04+02:00"
}
}
Filtering the list
Nine filters, all optional and all combinable.
| Parameter | What it narrows to |
|---|---|
origin | synchronized or statement |
bank_account_id | One bank account of the company |
direction | incoming or outgoing |
reconciliation_status | One reconciliation status |
operation_date_from, operation_date_to | A window on operation_date, both bounds inclusive |
pending | true for provisional lines only, false for settled ones only |
statement_id | The lines read out of one statement |
archived | true for archived lines only, false for active ones only. Absent, both are returned - it is not a synonym for false |
An unrecognised value returns nothing, and does not raise an error. ?direction=sideways returns an empty page, not a 400. That is the convention on this surface: the result is the set of operations whose direction is sideways, which is empty.
Both date bounds are strictly ISO 8601 (YYYY-MM-DD). A value in any other shape matches nothing rather than being guessed at: a day-first date will never be read as a month-first one, so you will never receive a silently different window from the one you asked for.
page and per_page paginate as everywhere else (20 by default, 100 maximum).
The includes
Four associations can be embedded, comma-separated: ?include=bank_account,bank_statement,bank_accounting_entry,allocations.
| Include | What you receive |
|---|---|
bank_account | The bank account's whole payload, readiness included - the same shape the bank account endpoints return |
bank_statement | The statement the line was read out of. null on a synced line, which has no file behind it |
bank_accounting_entry | The accounting entry, without its lines: read the accounting entries resource for those. null on an unposted line, that is whenever projection.posted is false |
allocations | Every allocation of the operation, oldest first - not only the confirmed ones. It is the same set GET /api/v1/bank_operations/{id}/allocations returns with no filter. The two amounts under reconciliation do stay confirmed-only: those are money figures, and a proposal moved none |
A value that is none of the four is ignored. Without include, none of these keys is present in the response - they are not published as null, they are absent.
Errors
| Code | When |
|---|---|
400 | page is not an integer greater than or equal to 1, or it names a page beyond the last one. An unreadable per_page does not raise: it falls back to the default |
401 | No token, or a token that is invalid or expired |
403 | Three causes. Your token does not carry the read scope (a write-only or destroy-only token is valid and is refused here); your application holds no grant on this workspace; or bank reconciliation is switched off for it |
404 | On the list: no company with this id in the workspace named in the path. On the read: no operation with this id reachable by your token |
A 404 never tells you that a resource exists elsewhere. A company of another workspace answers like a company that does not exist - even when your token also holds a grant on that other workspace - and an operation outside your grants answers like an operation that does not exist. Neither response can be used to probe for an id.
On the read by id, reachability is settled before the feature: an operation your token cannot read answers 404, never 403. A 403 on that endpoint therefore never confirms that an id exists.
The scope, though, is checked before everything else - including before the operation exists. That is the thing to know when diagnosing a 403 on this resource: a token without read is refused before the id in the path is resolved at all, so you get 403 on a nonexistent id exactly as on a valid one. If a 403 surprises you, check your token's scopes first - otherwise you will hunt for a permissions problem on a record that was never the issue.