Bank imputation rules
A bank imputation rule (bank_rule) answers one question: when a bank operation looks like this, which ledger account is its remainder posted to? It is what keeps everything left unreconciled from landing in the suspense account.
The bank operation is the source, the accounting entry is the result (Bank accounting entries), and a rule is what sits between the two. Exclusions, which take one company out of a workspace rule's reach, have their own page (Bank rule exclusions).
A rule imputes a remainder, it never settles an invoice
A rule names ONE ledger account, for the remainder, and nothing else. It marks no invoice as paid, creates no reconciliation and produces no payment: its payload carries no settlement flag, no payment id and no reconciliation id. Imputing and reconciling are different acts, and confusing them is how a suspense line silently becomes a payment.
The order in which the remainder's account is chosen is confirmed reconciliation, then company rule, then workspace rule, then suspense account. A rule therefore only ever speaks about what the confirmed reconciliations left behind.
Scope beats priority. A company rule beats every workspace rule whatever its priority number: priority only separates rules of the same scope. A company rule of priority 900 comes before a workspace rule of priority 0.
The path sets the scope, permanently
scope is tenant for a workspace rule - it applies to every company of the workspace, except those excluded from it - and company for a rule bound to a single company. company_id is null exactly when scope is tenant.
The creation path is what sets the scope, and it is not editable afterwards. A scope or a company_id placed in the request body is ignored, on create as on update: changing a rule's scope would change its precedence level.
The endpoints
GET /api/v1/workspaces/{workspace_id}/bank_rules- list a workspace's rulesPOST /api/v1/workspaces/{workspace_id}/bank_rules- create a workspace ruleGET /api/v1/workspaces/{workspace_id}/companies/{company_id}/bank_rules- list the rules governing a companyPOST /api/v1/workspaces/{workspace_id}/companies/{company_id}/bank_rules- create a company ruleGET /api/v1/bank_rules/{id}- read a rulePATCH /api/v1/bank_rules/{id}- update a ruleDELETE /api/v1/bank_rules/{id}- delete a rulePOST /api/v1/bank_rules/{id}/preview- simulate a rule
As on the other banking resources, only the lists and the creations are addressed per workspace. The endpoints carrying an id have no workspace_id in the path: the id is resolved across every workspace attached to your OAuth client.
Reads require the read scope, create and update the write scope, delete the destroy or the write scope. The simulation is a POST that requires only read: it writes nothing, and forcing you to hold write in order to inspect a rule would be backwards.
The Idempotency-Key header is accepted and optional on every write on this page: they produce no third-party effect and are safely replayable.
A rule's fields
Fourteen fields, all present in every response.
| Field | What it carries |
|---|---|
id | The rule's id, the one the member endpoints carry |
workspace_id | The workspace the rule belongs to. Never null, on a company rule as much as on a workspace one - it is the only thing that identifies the owner of a workspace rule, whose company_id is null |
scope | tenant or company. Set by the creation path |
company_id | Null exactly when scope is tenant |
name | Unique within its workspace, its scope and its company. 255 characters at most |
priority | The lowest runs first. On a tie the ascending id decides, so the order is total. It is 0 when you omit it on create |
enabled | A disabled rule classifies nothing and is still listed - which is the point, "it is switched off" being the commonest answer to "why is nothing being classified". Enable and disable with a PATCH of this field; there is no dedicated endpoint |
valid_from | Start of the validity window. Null means no lower bound |
valid_until | End of the validity window. Null means no upper bound, and this date may not precede valid_from |
conditions | What an operation must look like. Only the keys actually set are returned |
action | Where the remainder is posted. All three keys are always returned |
excluded_company_ids | The companies taken out of this workspace rule's reach, by ascending id. Always empty on a company rule |
created_at, updated_at | ISO 8601 timestamps |
?include=bank_rule_exclusions adds the whole payload of its exclusions to each rule, under the bank_rule_exclusions key. Without that parameter the key is absent - not present and null. It is this resource's only include; any other value is ignored.
Creating a rule
A write token is enough. The path you choose sets the scope: this one creates a workspace rule.
curl -X POST https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/bank_rules \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6c2f9a31-4b7d-4e88-9f10-1d3b5e7c2a04" \
-d '{
"name": "Frais bancaires",
"priority": 20,
"conditions": { "direction": "outgoing", "operation_type": "direct_debit" },
"action": { "account_number": "627000" }
}'
{
"data": {
"id": 88,
"workspace_id": 12,
"scope": "tenant",
"company_id": null,
"name": "Frais bancaires",
"priority": 20,
"enabled": true,
"valid_from": null,
"valid_until": null,
"conditions": {
"direction": "outgoing",
"operation_type": "direct_debit"
},
"action": {
"account_number": "627000",
"tax_code": null,
"label_template": null
},
"excluded_company_ids": [],
"created_at": "2026-08-21T15:10:00+02:00",
"updated_at": "2026-08-21T15:10:00+02:00"
}
}
Only name and action.account_number are required. priority defaults to 0, enabled to true, both validity bounds are null, and omitting conditions gives you the legitimate catch-all rule at the bottom of the priority list.
conditions: the vocabulary is closed
Every key is optional and they are ANDed. An empty object matches every operation. The vocabulary is closed: a key that is not in it makes the rule refused with a 422 carrying the invalid_argument code, it is not ignored.
| Key | Expected value | What it tests |
|---|---|---|
direction | incoming or outgoing | The direction of the movement |
operation_type | card, deferred_debit_card, transfer, direct_debit, check, withdrawal, deposit, open_banking, unknown | The operation type |
amount_min | a number | The absolute amount is greater than or equal |
amount_max | a number | The absolute amount is less than or equal |
bank_account_id | an integer | The operation belongs to that bank account. The account must fall within the rule's scope - the rule's company for a company rule, a company of the workspace for a workspace rule |
description_matches | a non-empty string of at most 1024 characters | The description contains this fragment, ignoring accents and case |
Only the keys actually set are returned, and that is what makes the payload sendable back as it is. A key present with null is not the same as an absent key: {"direction": null} is refused, because null is not in direction's vocabulary. Filling in the unset keys with null would therefore produce a body you could not send back.
description_matches is a literal fragment, not a regular expression
This is the key an integration gets wrong. description_matches looks for a text fragment, accent- and case-insensitively, inside the operation's description - never a pattern. Accents (é, è, ç, ...) are ignored on both sides: prelevement finds PRÉLÈVEMENT, and Société finds SOCIETE. Ligatures are not expanded: coeur does not find CŒUR. A value longer than 1024 characters, or blank once its accents are removed (spaces only included), is refused with a 422 carrying the invalid_argument code. The fragment is looked for in both the raw description and the normalized one, so a rule written against either fires.
The fragment is compared with the label as the bank sent it, not with the one the API publishes to you. An IBAN quoted in a label is returned to you masked, FR*********************0189 (see bank operations), but the rule sees it in full. Two consequences: a fragment copied from the masked form matches nothing, since the original label carries no asterisks; and a rule meant to recognise the counterparty fires more reliably on its name or on the transfer reference than on an account number you never read in full.
A value that can only be a pattern is refused rather than accepted. Refused are the characters \, ^, $, |, ?, +, (, ), [, ], {, }, along with the sequences .* and .+. The refusal is deliberate: a rule carrying ^PRLV|^VIR would be accepted and would never fire, and every remainder it was meant to post would go to the suspense account in silence. Silence is the failure mode, and the validation is what turns it into a message.
A bare asterisk and a bare dot are accepted, because they are ordinary in a bank descriptor: ADOBE *SUBS is how card acquirers format their descriptors, and S.A.R.L or N.1234 are common literals. Refusing them would cost you a message nobody can act on.
One case this validation does not catch remains: a * used as a quantifier and on its own, COMMISSIONS*, is accepted and behaves as the literal COMMISSIONS* - which matches nothing. That is the price of accepting ADOBE *SUBS, the two being indistinguishable without parsing the pattern.
action: where the remainder is posted
All three keys are always returned, with null for the ones that are not set, so the object you read is an object you can send back unchanged.
| Key | What it carries |
|---|---|
account_number | The chart-of-accounts number the remainder is posted to. Required: a rule with no account has nothing to say. A non-empty string |
tax_code | Carried onto the simulated line. Send null to clear it |
label_template | Template for the posted line's label. Send null to clear it |
As for conditions, a key outside those three makes the rule refused with the invalid_argument code.
Priority, enabling and the validity window
priority is ascending: the lowest number is examined first, and the ascending id breaks ties. It only compares rules of the same scope - a company rule never disputes its place with a workspace rule.
enabled is written with a PATCH of the field; there is no enabling endpoint. A disabled rule goes on being listed.
valid_from and valid_until are ISO 8601 dates (YYYY-MM-DD), and a null bound is an open bound: a rule with no valid_from has been in force forever, a rule with no valid_until is in force indefinitely. A value Scribee cannot read as a date is stored as an absent bound rather than refused - read the rule back after writing it if you are unsure of your format.
Listing rules
curl "https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/bank_rules?enabled=true&scope=tenant" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
The two list paths do not answer the same question.
- Per workspace, you get every rule of the workspace, company rules included.
- Per company, you get the rules that govern that company: its own company rules, plus the workspace rules it is not excluded from. The workspace rules are there on purpose - they bind this company's operations and are named in the explanation the simulation publishes, so a list without them could not explain an imputation.
The list is returned by ascending priority then ascending id, and the data + meta envelope follows API conventions. The LIST order is not the PRECEDENCE order: a company rule beats a workspace rule whatever its priority, so use scope to tell the two levels apart.
Pagination is offset-based, with page and per_page - 20 by default, 100 at most - as on the other banking resources.
Three filters, all optional and combinable:
| Filter | Values | What it keeps |
|---|---|---|
enabled | true or false | The enabled rules, or the disabled ones |
scope | tenant or company | One precedence level |
effective_on | an ISO 8601 date | The rules in force on that date, a null bound being an open bound on either side |
A value a filter cannot read matches nothing, and that is not an error - not a 422, and not a silently wider window. enabled=yes, scope=group and effective_on=14/07/2026 each answer a 200 with an empty collection. The first two accept only the values listed above, and effective_on accepts only ISO 8601: 14/07/2026 matches nothing rather than being guessed as 14 July or as 7 April.
An absent or empty parameter is not a filter: it restricts nothing.
Reading, updating and deleting a rule
GET /api/v1/bank_rules/{id} returns one rule. excluded_company_ids is recomputed on every read, so it reflects the rule as it is now.
PATCH /api/v1/bank_rules/{id} updates the rule in place. Every field is optional and an omitted one keeps its value, but a body carrying no accepted field is refused rather than answered: it asked for nothing.
{
"error": "unprocessable_entity",
"code": "validation_failed",
"message": "La validation a échoué",
"details": {
"base": ["Renseignez au moins un des champs name, priority, enabled, valid_from, valid_until, conditions ou action"]
}
}
conditions and action are REPLACED whole, never merged. That is the only way to remove a condition: send the object back without it. The accepted fields are name, priority, enabled, valid_from, valid_until, conditions and action; scope and company_id are not among them.
DELETE /api/v1/bank_rules/{id} deletes the rule and its exclusions. It stops FUTURE classifications and rewrites no past one: a rule owns neither an accounting entry nor a payment, so nothing already posted moves. The response body is the rule as it stood when you asked for it to go.
Simulating a rule before enabling it
POST /api/v1/bank_rules/{id}/preview confronts the rule with real operations and writes nothing: no reconciliation, no payment, no accounting entry. That is why a read token is enough.
Each row of the response says, for one operation, whether the rule would determine its imputation, the lines it would produce, and - through overridden_by - whether a higher-precedence source already wins it.
curl -X POST https://app.scribee.tech/api/v1/bank_rules/88/preview \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"operation_date_from": "2026-07-01",
"operation_date_to": "2026-07-31",
"direction": "outgoing"
}'
{
"data": [
{
"bank_operation_id": 30150,
"would_apply": true,
"overridden_by": null,
"explanation": {
"source": "tenant_rule",
"rule_id": 88,
"rule_name": "Frais bancaires",
"reason": "le sens est outgoing et le type d'opération est direct_debit"
},
"lines": [
{
"account_number": "627000",
"debit_amount": 12.5,
"credit_amount": 0.0,
"tax_code": null
}
]
}
],
"meta": {
"current_page": 1,
"per_page": 20,
"total_pages": 1,
"total_count": 1
}
}
The simulated set is bounded by the rule's scope: a company rule only ever reaches its own company's operations, a workspace rule reaches those of every company of the workspace your grants cover. Operations are returned from the most recent operation_date to the oldest, and on a tie from the largest id to the smallest.
Every body filter is optional, and an empty body simulates the rule against the whole reachable set: page, per_page, bank_account_id, statement_id, direction, reconciliation_status, origin (synchronized or statement), operation_date_from, operation_date_to, pending and archived. Both date bounds are inclusive and accept only ISO 8601; as on the lists, an unreadable value matches nothing.
Without pending and archived, pending and archived operations are part of the simulated set. They are never posted, so each answers would_apply: false and overridden_by: null - add "pending": false and "archived": false if you do not want to see them.
explanation is null as soon as would_apply is false, and lines is then empty. The reason is human text, not to be parsed: it is translated and its wording is not part of the contract.
A simulated line carries the rule's own movement only, never the bank line - that one belongs to the projection, not to the rule. Both amounts are non-negative and exactly one side is non-zero: read the side, not the sign. An outgoing operation debits the counterparty, an incoming one credits it.
Reading overridden_by
null does not mean "the rule applies". It means nothing beat it, which covers two opposite situations, and would_apply is what tells them apart.
would_apply | overridden_by | What it means |
|---|---|---|
true | null | The rule determines this operation's imputation |
false | null | The rule never ran at all. Its conditions do not match, or it is disabled, or the operation is outside its validity window, or the company is excluded from it, or the operation is archived or still pending. Nothing beat it: it was not in the running |
false | company_rule or tenant_rule | The rule was a candidate and matched, but a higher-precedence rule wins it |
false | allocation | The confirmed reconciliations leave no remainder to post |
allocation appears only when the remainder is nil. A partly reconciled operation is not overridden: a rule applies to the remainder, and a remainder surviving a reconciliation is the ordinary case, not the exception.
overridden_by draws on the same vocabulary as explanation.source, so you read the precedence chain directly. suspense never appears there: it is the chain's last resort and never competes with a rule. Nor does correction: it only marks the lines of a correction entry, outside the chain (Bank accounting entries). On this endpoint, explanation.source is therefore never anything but company_rule or tenant_rule.
The errors
| Status | error | code | When |
|---|---|---|---|
400 | bad_request | - | A page that is not an integer greater than or equal to 1, or a page beyond the last page of a non-empty collection |
403 | forbidden | - | Your OAuth client holds no grant on this workspace, or the token does not carry the required scope |
404 | not_found | - | The company or the rule is not reachable by your grants |
409 | - | idempotency_key_reuse | The same Idempotency-Key has already been used for a different body |
409 | - | idempotency_request_in_progress | An earlier call carrying that key is still in flight |
422 | unprocessable_entity | validation_failed | A missing or over-long name, a missing action.account_number, a valid_until earlier than valid_from, a name already taken at that scope, or an empty PATCH body |
422 | unprocessable_entity | invalid_argument | An unknown key in conditions or action, a value outside the vocabulary, a bank_account_id outside the rule's scope, a description_matches that can only be a regular expression, is longer than 1024 characters or is blank once its accents are removed, or a conditions / action that is not an object |
The two 422 codes are not fixed the same way. validation_failed says a value is missing or contradicts another; invalid_argument says you used a word that is not in the published vocabulary. The second is almost always a vocabulary mistake rather than a data one.
Refusing a pattern in description_matches carries the remedy in its message:
{
"error": "unprocessable_entity",
"code": "invalid_argument",
"message": "La validation a échoué",
"details": {
"conditions": ["déclare une expression régulière pour description_matches, alors que cette condition recherche un fragment littéral du libellé de l'opération et jamais un motif - supprimez le caractère de motif et indiquez le texte brut à rechercher"]
}
}
A resource out of reach answers 404, never 403. Reachability is settled before the question of rights: a rule in a workspace your grants do not cover and a rule that does not exist receive the same answer, so the response cannot be used to work out which of the two you met.
API reference
- API reference: list a workspace's bank rules
- API reference: create a workspace bank rule
- API reference: list a company's bank rules
- API reference: create a company bank rule
- API reference: read a bank rule
- API reference: update a bank rule
- API reference: delete a bank rule
- API reference: preview a bank rule