Bank accounts
A bank account (bank_account) is an account of a company in your workspace, as Scribee knows it. It carries two things: what you need to recognise it - the bank, the label, the masked IBAN, the balance - and what you need to use it in accounting. The second part is the one that concerns you: an account is of no use for posting entries until it is configured.
A bank connection is what grants the access (Bank connections), a sync is what uses it (Bank syncs), and accounts are what it brings back.
The thing to understand before anything else
A bank account is not created through this API, and it is not deleted here either. There is no POST and no DELETE on this resource. An account appears under a connection once a sync has run, or when a statement is imported. If you are looking for a create endpoint, there is none - that is deliberate, not a missing step.
What you do with an account is read it and configure it. There is exactly one write for that.
The endpoints
GET /api/v1/workspaces/{workspace_id}/companies/{company_id}/bank_accounts- list a company's accountsGET /api/v1/bank_accounts/{id}- read an accountPATCH /api/v1/bank_accounts/{id}- configure an account
As with bank connections, only the list is addressed per workspace and per company. The two endpoints carrying an id have no workspace_id in the path: the id is resolved across every workspace attached to your OAuth client. Both reads require the read scope, the PATCH requires the write scope.
An account carries nineteen fields, all present in every response: id, company_id, bank_connection_id, bank_name, account_name, currency_code, origin, iban_masked, iban_last4, balance, accounting_account_code, ledger_id, suspense_account_code, active, archived, last_synced_at, readiness, created_at and updated_at.
The full IBAN is published nowhere. Only iban_masked - the country code, stars, the last four characters - and iban_last4 are, on every surface and under every scope. No parameter, no scope and no header returns the whole value: do not go looking for the flag that unlocks it, there is none. The internal account number and the account's identifier at the aggregator are published nowhere either.
When the stored IBAN is shorter than eight characters, iban_masked stars the whole value and iban_last4 is null. The two fields share that threshold and are read together: at that length, four characters in the clear set beside a full mask would give the whole value back.
currency_code can be null too: Scribee stores the currency the provider reports only when it appears in the ISO 4217 list, so an account discovered before the provider fills it in - or carrying a code that list does not hold - reads with no currency until a more complete sync. The key itself is always there.
accounting_account_code, on the other hand, is published in full: it is a chart-of-accounts code, not a bank identifier.
origin is synchronized when a provider feeds the account, and manual otherwise. An account imported from a statement stores no provider, so it reads as manual.
Reading an account
A read token is enough.
curl https://app.scribee.tech/api/v1/bank_accounts/7001 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
"data": {
"id": 7001,
"company_id": 34,
"bank_connection_id": 501,
"bank_name": "Demo Bank",
"account_name": "Compte courant",
"currency_code": "EUR",
"origin": "synchronized",
"iban_masked": "FR*********************0189",
"iban_last4": "0189",
"balance": 12450.33,
"accounting_account_code": null,
"ledger_id": null,
"suspense_account_code": null,
"active": false,
"archived": false,
"last_synced_at": "2026-08-21T09:02:11+02:00",
"readiness": {
"ready": false,
"missing": [
"not_activated",
"missing_ledger",
"missing_bank_account_code",
"missing_suspense_account_code"
],
"unposted_operations_count": 12
},
"created_at": "2026-08-21T11:15:00+02:00",
"updated_at": "2026-08-21T11:15:00+02:00"
}
}
Knowing where an account stands: readiness
readiness is derived on every read and never stored, so it cannot go stale. It carries three keys:
ready- true only whenmissingis empty;missing- the conditions that are not met;unposted_operations_count- how many settled operations of this account are still waiting for an accounting entry.
missing lists every unmet condition at once, never just the first one. That is deliberate: you fix everything in one pass, instead of discovering the next condition at each refusal.
missing | What it means | How to clear it |
|---|---|---|
not_activated | The account is not turned on for posting | PATCH with active set to true |
missing_ledger | No accounting ledger is attached | PATCH with ledger_id |
missing_bank_account_code | No accounting account code is set | PATCH with accounting_account_code |
missing_suspense_account_code | No suspense account is set | PATCH with suspense_account_code |
provider_unavailable | The provider no longer gives us this account | No field clears it - see below |
provider_unavailable is not fixable through the API. It means the provider withdrew the account, or the end user revoked data sharing. None of the four attributes the PATCH accepts changes anything about it: recovery goes through a new consent session, described in Bank connections. A manual account has no provider, so this condition never applies to it.
unposted_operations_count is reported alongside readiness, not as part of it. A perfectly ready account can still have a backlog: ready set to true says nothing about what is left waiting.
As long as missing is not empty, no operation of that account is posted. Those conditions are the posting guard, not merely an indicator: no accounting account is guessed in place of the ones you have not set, so settled operations stay unposted and keep counting towards unposted_operations_count. The backlog is picked up as it stands at the next synchronisation, once the last condition is cleared: there is nothing for you to replay.
There is a second guard, independent of this one. An operation read from an imported statement is posted only after a human validates it. An operation can be posted when no statement carries it - a line fetched from the provider, or entered by hand - or when the statement it came from has been validated by an operator. Until that validation happens, the lines read from the file stay counted in unposted_operations_count, including on an account whose ready is true. Validation happens in Scribee: the API does not expose it, and there is no statement resource. An account fed by a bank connection is unaffected - its operations come from no statement and are posted as soon as it is ready.
There is a third guard, and it targets exactly those accounts. An operation fetched from the provider that describes the same movement as a line from an imported statement - same date, same amount, same currency - is held, and is not counted in unposted_operations_count. Posting both would record the same amount twice. It is not lost: once an operator has corrected or removed the duplicate statement line, the hold lifts at the next bank synchronisation and the operation is counted again.
Making an account usable
An account needs four things for ready to become true: active set to true, a ledger_id, an accounting_account_code and a suspense_account_code. Those are exactly the four attributes the PATCH accepts, and nothing else. All four are optional, so one call is enough to set them all.
A token carrying the write scope is required. The Idempotency-Key header is accepted but optional: the write converges on the values you send, so a replay changes nothing. Send one anyway and the stored response is returned instead of a second write.
curl -X PATCH https://app.scribee.tech/api/v1/bank_accounts/7001 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"active": true,
"ledger_id": 88,
"accounting_account_code": "512999",
"suspense_account_code": "471000"
}'
The 200 response carries the account in its new state, readiness included: one round trip is enough to know whether it is now usable.
Any other field in the body is ignored, not refused - so sending back an account you just read is safe.
Four rules to know about the values:
accounting_account_codemust be a 512 account of the French chart of accounts:512followed by zero to seventeen digits,512000or512999for instance. The whole prefix is required, not just class 5 -531000(cash) is refused, as are a401supplier code and a706revenue code. This field is not a label: it becomes the bank leg of every entry projected from the account, so a cash code would post bank movements as cash, silently. On an account whoseoriginismanual, that code is also mandatory: no provider can supply it, so the account has to carry one.suspense_account_codeis the counterpart: that code, and no other, carries the suspense leg of every entry projected from the account. No default value stands in for it, and while it is null the account projects nothing. No format is imposed on it - unlikeaccounting_account_code, it is not narrowed to a chart-of-accounts class - but it is capped at 255 characters: a longer value is refused with422validation_failedunderdetails.suspense_account_code, nothing is written, and it is never truncated to fit.ledger_idmust name a ledger of the same company as the account. A ledger belonging to another company and an id that exists nowhere get the same answer, underdetails.ledger: the response never tells you whether an id beyond your reach exists. Faced with a422on this field, do not go looking for a malformed value - the id is outside the account's company, or it does not exist.activeaccepts onlytrueandfalse.
A body carrying none of those four attributes is refused, with a 422 validation_failed under details.base, not treated as a write that changed nothing. Supplying a field with the value it already holds is a valid request, on the other hand: it names a target and converges on it, so it answers 200.
Sending an empty string on suspense_account_code, or on the accounting_account_code of a synchronized account, clears the value: the field goes back to null and the matching condition reappears in readiness.missing. The same empty string on the accounting_account_code of a manual account is refused, with a 422 validation_failed under details.accounting_account_code, by the requirement above, and nothing is written. A string of spaces reads exactly as an empty string: it is reduced to the null value before being judged.
A write that changes something emits a bank_account.updated event to the workspace's webhook endpoints subscribed to that type (Webhooks) - this PATCH as well as activation done from the Scribee interface. A write that changes nothing emits nothing. The synchronisation of a connection emits the same event on its own side, on the discovery of an account as well as on the transitions the provider imposes - so you do not have to poll the list to see an account appear.
Listing a company's accounts
curl "https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/companies/YOUR_COMPANY_ID/bank_accounts?ready=false&active=true" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
The list is returned newest account first and paginates with page and per_page. Five filters, all optional and combinable:
| Filter | Values | What it keeps |
|---|---|---|
origin | manual, synchronized | The accounts a provider feeds, or the accounts none does |
active | true, false | The accounts turned on for posting, or all the others |
archived | true, false | Archived accounts, or live ones |
ready | true, false | The accounts that can be posted from, or those that cannot |
bank_connection_id | an integer | The accounts that arrived under one connection |
Any value outside these lists simply matches nothing - it is not an error.
Without archived, archived and live accounts are both returned: archiving is not a filter applied on your behalf. A freshly discovered account nobody has answered for yet falls on the false side of active, exactly as a disabled one does.
ready applies the same derivation the readiness object publishes, evaluated in the database: the two cannot disagree.
include=bank_connection adds to each account the connection it depends on, with its whole payload - or null when the account has none. Without that parameter, the key is absent.
Errors
| Status | code | When |
|---|---|---|
422 | validation_failed | The accounting_account_code is not a 512 account, or it is cleared on an account whose origin is manual; the suspense_account_code is longer than 255 characters; the ledger_id names a ledger of another company, or one that does not exist; the body carries none of the four configuration attributes |
422 | invalid_argument | active is something other than true or false |
409 | idempotency_key_reuse | The same Idempotency-Key has already served a different body |
409 | idempotency_request_in_progress | An earlier call carrying that key is still running |
403 | - | Your OAuth client holds no grant for this workspace |
404 | - | The company or the account is not reachable by your grants |
On a 422, details names the offending field - accounting_account_code, suspense_account_code, ledger, active, or base when the whole body asked for nothing - and nothing was written.
An account belonging to a workspace your grants do not cover answers 404, exactly as an account that does not exist: the response cannot be used to guess at its existence.