Bank statements
A statement (bank_statement) is a file you upload into a company: PDF, JPEG, PNG or HEIC, 50 MB maximum. Scribee reads its header - IBAN, period, balances - then the operation lines, which you review and correct.
A bank account is what receives the file (Bank accounts). Statement upload serves the accounts no bank connection feeds: an account attached to a connection refuses the upload, because its operations already arrive through Bank syncs.
One file, one statement
The upload is multipart and accepts several parts. Each file becomes its own statement, with its own account, its own state and its own error. So the 202 answers a collection - one row per file, in request order - and you poll N ids rather than one. A file whose extraction fails never takes its siblings down with it.
A 202 never means the extraction finished. It means the files exist and they are yours.
What this surface publishes
Eleven operations, and nothing else on statements:
| Verb and path | What it does |
|---|---|
POST /api/v1/workspaces/{workspace_id}/companies/{company_id}/bank_statements | Uploads a batch of files |
GET /api/v1/workspaces/{workspace_id}/companies/{company_id}/bank_statements | Lists the company's statements |
GET /api/v1/bank_statements/{id} | Reads one statement |
DELETE /api/v1/bank_statements/{id} | Deletes a statement and its file |
POST /api/v1/bank_statements/{id}/retry | Re-runs an extraction |
GET /api/v1/bank_statements/{id}/lines | Reads the extracted lines |
PATCH /api/v1/bank_statements/{id}/lines | Corrects the lines and the balances |
POST /api/v1/bank_statements/{id}/commit | Commits the statement, and its whole upload with it |
POST /api/v1/bank_statements/{id}/route_account | Names the account of a statement whose routing is ambiguous |
POST /api/v1/bank_statements/{id}/exclude | Excludes a file from the commit of its upload |
POST /api/v1/bank_statements/{id}/include | Includes an excluded file back in the commit of its upload |
Committing goes through POST /api/v1/bank_statements/{id}/commit. There is, however, NO operation to confirm a review separately, and that is deliberate - the commit opens the review of the files it is about to post itself.
Two modes, chosen at upload
mode is extract by default: the statement is born pending and an extraction is requested. Requested, not guaranteed - it may fail to be queued, or never run at all because the feature is switched off for the company, and the statement stays pending either way.
mode: archive keeps the file and starts nothing: the statement is born archived. An archived statement has no lines to review, no extraction to re-run, and it cannot be deleted through the API.
Every part of one upload must carry the same mode: an upload is one batch, and parts that disagree describe two uploads.
Uploading statements
A token carrying the write scope is required, along with an Idempotency-Key header.
This call writes: it stores the files and, in extract mode, queues an extraction. Every statement it creates stays deletable as long as its extraction has persisted no operation - committed: false.
In extract mode, the file is read by a third party. The extraction hands it to Mistral AI, an OCR provider, as a signed URL to the stored file: Mistral fetches the document from that address. The URL is valid for 5 minutes, which bounds RE-FETCHING the file through that address - and nothing else. The extraction only happens if the feature is enabled for the company; when it is not, no call is made and the statement stays pending.
In archive mode nothing is queued and the file does not leave the platform.
curl -X POST https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/companies/YOUR_COMPANY_ID/bank_statements \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: 3f1c6d20-8a55-4d0e-9f3b-7c2e5a81b004" \
-F "statements[][file]=@releve-juillet.pdf" \
-F "statements[][bank_account_id]=771"
A 202 response, one row per file:
{
"data": [
{
"id": 8801,
"company_id": 34,
"bank_account_id": 771,
"mode": "extract",
"state": "pending",
"progress": null,
"error_code": null,
"error_message": null,
"retryable": true,
"started_at": null,
"finished_at": null,
"iban_masked": null,
"iban_last4": null,
"opening_balance": null,
"closing_balance": null,
"period_start": null,
"period_end": null,
"operations_count": 0,
"filename": "releve-juillet.pdf",
"byte_size": 184320,
"committed": false,
"excluded": false,
"excluded_at": null
}
]
}
Two refusals apply to the whole upload, and in both cases nothing is stored: a part naming an account outside this company, and a file whose format or size is refused. Those are malformed requests, not a processing outcome - unlike an extraction failure, which stays local to its file.
A partial enqueue answers 202, not 422
The files can be accepted and stored while at least one extraction could not be queued. That is not a refusal: the statements exist, so the response stays a 202, and meta.unqueued_statement_ids names the rows whose extraction never started.
{
"data": [
{ "id": 2559, "state": "pending", "filename": "releve-07.pdf" },
{ "id": 2560, "state": "pending", "filename": "releve-08.pdf" }
],
"meta": {
"unqueued_statement_ids": [2560]
}
}
The ids are integers, the same ones published as id in data.
The ABSENCE of meta is itself the signal. On an ordinary upload the key does not appear in the body at all - not null, not {}: absent. So you branch on its presence, with no empty array to inspect.
Check meta BEFORE you start polling. A 422 would have forced you to look; a 202 does not, and a client that ignores meta will poll a statement that never moves. In the example above, 2559 was queued and 2560 was not - and both read "state": "pending". Nothing but meta tells them apart.
The remedy is POST /api/v1/bank_statements/{id}/retry on each id in meta.unqueued_statement_ids, and exactly those.
- Do not upload the files again. The statements in
dataalready exist: a second upload creates duplicate statements, and their extraction will be refused as a duplicate anyway - see below. You gain nothing by it, and you leave behindfailedstatements that have nothing to do with the enqueue. - Do not re-drive the others. They are queued; re-driving them pays for a second extraction for nothing.
- Do not treat this
202as a failure. Replaying theIdempotency-Keyis safe and creates nothing - see below.
Replaying an idempotency key
If you never saw the response - a timeout, a dropped connection - send the SAME request with the SAME Idempotency-Key. You get the stored body back, byte for byte, meta included, and nothing is created: no second batch, no second extraction. The response then carries the Idempotency-Replayed: true header. HTTP header names are case-insensitive, so match it without regard to case rather than expecting that exact spelling.
This is the move to make after a timeout, and it is not an error condition.
A 409 idempotency_request_in_progress does not always mean a call is still running. It means this key is claimed and its outcome is not replayable - usually because a first call with it is still running, but also when a first call completed its effect and then failed while rendering its response. In that second case the key stays claimed until the 24-hour sweep, and waiting will change nothing.
So go and check what the first call did, rather than waiting for it to finish. List the company's statements: if they exist, the upload landed and all that is left is to re-drive the ones whose extraction is not running. Do not sit on the same key hoping it frees, and do not re-send the upload under a fresh key before you have looked - that would duplicate the statements already created.
The same file, already uploaded
A file whose bytes are identical to a file already uploaded into this company is not extracted a second time. The upload answers a perfectly ordinary 202, with no meta: the statement is created and its extraction is queued. It is that EXTRACTION which is refused, a moment later, when it runs. The statement turns failed, and its error_message names the statement that already owns this file.
{
"id": 8802,
"state": "failed",
"error_code": "operation_failed",
"error_message": "Ce fichier a déjà été importé (relevé 8801) ; il n'a pas été extrait une seconde fois.",
"retryable": true,
"operations_count": 0,
"committed": false
}
The identity compared is the one of the BYTES: the stored file's digest and its size, never the IBAN nor the period. Those two are PRODUCED by the extraction, so reading them would cost exactly the reading this refusal exists to avoid. Two files that describe the same period without being the same document are therefore both extracted, and the same document sent twice is extracted once.
The comparison never leaves the company. The same statement uploaded into two companies of one group is extracted twice, and one company's upload is never visible from another.
An archived statement blocks nothing, and a failed one blocks only when it already holds lines. What makes a statement the owner of a file is its LINES first and its state second: a statement carrying operations owns its bytes whatever its state - failed included - and whatever its id. An extraction can indeed fail AFTER writing its lines, which are persisted several steps before the statement is finalized, and that statement does refuse an identical re-upload. Re-uploading a document whose extraction had failed remains the normal remedy as long as that failure left nothing behind, and committed is the field that says so: committed: false on the failed statement and the same file is extracted again, committed: true and it is refused as a duplicate. Do not read operations_count for this: it is only written when the extraction finalizes, so a failure occurring after the lines were written never updates it. A file kept with mode: archive was never read and therefore holds no line, so a later upload of the same document with mode: extract is extracted.
When two simultaneous uploads carry the same file, exactly one of the two is extracted - the older of the two statements, by id - and the other is refused. The two never refuse each other.
This refusal is not an HTTP error and appears in no table of the Errors section: it happens AFTER the 202, on the statement, exactly like an ordinary extraction failure. Do not confuse it with the partial enqueue above: that one is read in the response itself, from the presence of meta, and leaves the statement pending until you re-drive it; this one is only ever read on the statement, after the fact, and leaves it failed. A statement refused as a duplicate never appears in meta.unqueued_statement_ids, since its extraction was indeed queued - it is that extraction which refuses.
For an integrator this is the intended outcome. After a timeout, the move is to replay the same Idempotency-Key, which creates nothing; a retry that re-uploads the same file under a fresh key gets a failed statement instead, not a second reading of the same document.
Following the extraction
A read token is enough. state is the field to poll.
A pending that does not move is not necessarily working. pending means stored and not yet reviewable - it does NOT promise an extraction is underway. A file waits at pending when its extraction has been queued, when the company has AI extraction switched off, and when the upload could not queue it (meta.unqueued_statement_ids), and the state alone does not tell those three apart. processing is the state that does mean work is running. So bound your polling: a statement that stays pending is one to re-drive with POST /api/v1/bank_statements/{id}/retry, not one to keep waiting on - when its retryable is true. A pending whose retryable is false belongs to a company with AI extraction switched off: no extraction is coming, and the retry is refused with operation_failed until the setting is turned back on in Scribee.
curl https://app.scribee.tech/api/v1/bank_statements/8801 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
state takes one of these eight values:
state | What it means |
|---|---|
pending | The file is stored and not yet reviewable - an extraction may be running, or not |
processing | The extraction is running |
completed | The extraction finished with a complete statement |
partial | It finished knowing its reading was truncated |
failed | It failed |
archived | The file was kept without extraction (mode: archive) |
reviewing | A human has opened the file's review |
posted | The statement was committed to the ledger and the review is over |
posted is produced by POST /api/v1/bank_statements/{id}/commit - see "Committing a statement" below.
reviewing, on the other hand, has no endpoint that produces it for its own sake. This API publishes no confirm-review call: the commit opens the review just before committing, within the same call. You can still READ reviewing, because opening the review is not undone by a refused commit: a refused batch leaves the files whose review it had opened at reviewing. Correcting a line of such a file returns it to the state the extraction left it in, as everywhere else.
started_at and finished_at are the extraction's own clocks. started_at is the instant a pass CLAIMED the file and began, the one where the statement moved to processing: neither the upload nor the moment the work was queued. finished_at is the instant that pass STOPPED. You can build a duration on these two fields, subject to the four reservations below.
A null is an answer, not a gap. A pending statement has not been claimed by any pass yet, so it carries neither a start nor an end; an archived one never will, mode: archive running no extraction at all; a processing one publishes a start and no end, which is the normal case and not the sign of a stuck file. A statement older than these two clocks does not carry them either.
finished_at is not evidence of success. It is written on EVERY terminal outcome, and not only the good ones: a completed or partial reading, a file refused as a duplicate, and the attempt that exhausted its retries all write it. state is the field that says what happened, and it remains so.
A retry resets the stopwatch. POST /api/v1/bank_statements/{id}/retry clears both values - the statement goes back to pending with started_at and finished_at at null - and the new pass then writes its own started_at as it claims the file. A duration computed after a retry therefore measures the LAST attempt, never the whole history.
finished_at never moves afterwards, and it is not updated_at. Correcting a balance through PATCH /api/v1/bank_statements/{id}/lines writes to the statement without moving the end of the extraction that produced it. So never substitute one for the other: updated_at is the last write, finished_at is the end of the reading.
partial is not a rounding of completed. The extraction finished knowing it had not read everything: its lines are worth reviewing, and a retry is worth running.
error_code is operation_failed on any terminal failure, and null otherwise; error_message says why, in words. progress is always null: nothing produces a percentage for an extraction, and an invented figure would be worse than none.
The IBAN read off the document never leaves whole. iban_masked shows only its country code and last four characters - FR*********************0189 - and iban_last4 repeats those four characters so a human recognises the account. Below eight characters the value is starred out entirely and iban_last4 is null.
committed turns true once the statement's operations were persisted; operations_count gives their number. The two are not the same clock: committed is read off THE LINES THEMSELVES, while the extraction writes the counter a few steps later - so a statement mid-extraction can report committed: true while operations_count still reads 0. Read committed, never the counter. This is the boundary the deletion enforces: a statement at true can no longer be deleted, and reconciliation does not move this key.
account_detection tells you HOW the statement's account was resolved, and it is the key to read before committing. auto: the IBAN read matched one account and only one. manual: the account was named, through the Scribee interface or through POST /api/v1/bank_statements/{id}/route_account. ambiguous: the IBAN matched no account, or several - and every commit is refused as long as an ambiguous statement is part of it. Read this key after the extraction rather than discovering the refusal at commit time.
excluded tells you whether the file is excluded from the commit of its upload, and excluded_at since when - null as long as it takes part. POST /api/v1/bank_statements/{id}/exclude writes them and POST /api/v1/bank_statements/{id}/include clears them: see "Excluding a file from its upload" below.
The list and its six filters
curl "https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/companies/YOUR_COMPANY_ID/bank_statements?state=completed&per_page=50" \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Statements come back newest first, paginated (meta carries current_page, per_page, total_pages and total_count).
| Filter | What it narrows to |
|---|---|
state | One extraction state |
mode | extract or archive |
bank_account_id | The statements routed to one account |
period_start_from | Statements whose period STARTS on or after this date (ISO 8601) |
period_end_to | Statements whose period ENDS on or before this date (ISO 8601) |
committed | true or false, depending on whether the operations were persisted |
A value outside the expected set raises no error: it simply matches nothing and the page comes back empty. A statement whose period the extraction could not read carries no date and is kept by neither period filter.
Reading the extracted lines
curl https://app.scribee.tech/api/v1/bank_statements/8801/lines \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
Lines come out in statement order - operation_date ascending, then id - and are paginated like any collection.
{
"data": [
{
"id": 55021,
"operation_date": "2026-07-14",
"value_date": null,
"label": "VIR SEPA ACME",
"amount": "300.0000",
"direction": "incoming",
"operation_type": "transfer",
"corrected": false
}
],
"meta": { "current_page": 1, "per_page": 20, "total_pages": 1, "total_count": 1 }
}
amount is signed: positive incoming, negative outgoing, and never in disagreement with direction. corrected says an operator has already touched a field of this line. value_date and operation_type are published and are not correctable.
label never publishes an IBAN in full. An IBAN quoted in the label is returned in the form of iban_masked - FR*********************0189 - under the rule described for bank operations, and the description of each operation embedded by include=bank_operations follows the same rule. A label you correct is stored as you send it, then read back in that same masked form.
Two lines of the same day and the same amount that differ only by their direction are two distinct lines, each with its own id: an incoming and an outgoing of the same amount on the same day both come out. Identify a line by its id, never by the operation_date + amount pair.
amount is a decimal STRING, at its column's own scale: four decimals, always four. The column is a DECIMAL(19, 4), so nineteen significant digits, where the double JSON.parse reads a JSON number into holds about sixteen: an amount near the top of the range would reach you already rounded. The string is what survives the trip intact. Parse it with your language's decimal type, never with a Float - a parseFloat on "300.0000" puts you straight back on the number the string exists to avoid.
The days a bank connection already holds
The same physical account can exist twice in a company: the account that receives your statements, and an account fed by a bank connection that carries the same IBAN. On that physical account, the first source to write a day keeps it.
- A line whose
operation_datefalls on a day where the connected account already carries operations is not created, whatever its figures. It is absent fromGET /api/v1/bank_statements/{id}/lines: for that day, the account's operations are the bank connection's. - A day on which the statement's account already carries statement lines stays with the statement, even if the bank connection has imported operations for that day since.
- A correction does not move a line onto a day the bank connection holds. A
PATCH /api/v1/bank_statements/{id}/lineswhoseoperation_datefalls on a day the connected account carries operations for is refused whole with a422validation_failedwhosemessagenames the day; nothing is written. - A line set aside this way is not a reading defect: it does not move the statement to
partialand writes nothing intoerror_message. - The rule needs an IBAN on both sides. It only applies between accounts of the same company whose IBANs are equal; if either account has no IBAN, no day is set aside on this ground. Nor does it apply when the IBAN read on the document differs from the statement account's.
- The days set aside are shown in the Scribee interface: the statement's review screen lists them to the operator. The API does not publish them.
The rule works in both directions. A bank connection sync does not import a new operation on a day a statement already holds with its lines: for that day, the account's operations are the statement's. The operations the connection had already imported for a day keep being updated, unless the bank moves one onto a day a statement holds: it is then removed, like a deleted operation. When a statement gives a day back - the statement deleted, routed to another account, its bank account deleted, or a line's date corrected - the next sync re-reads the account from the start and imports the operations of the days no statement line holds any more.
Reviewing: the lines and the balances in one call
The statement resource itself accepts no PATCH: correcting a balance is the same review act as correcting a line, so both travel together on PATCH /api/v1/bank_statements/{id}/lines. A write token is required; the Idempotency-Key header is accepted without being required, since the correction converges on the values you send.
The submission is atomic. One malformed figure refuses all of it and writes nothing: you never end up with a half-edited statement behind a 422.
curl -X PATCH https://app.scribee.tech/api/v1/bank_statements/8801/lines \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"opening_balance": "1000.00",
"closing_balance": "1300.00",
"lines": [
{ "id": 55021, "label": "VIR SEPA ACME SARL", "amount": "300.00", "direction": "incoming" }
]
}'
Four rules govern the body:
- Every
linesentry carries theidof a line of THIS statement. A missing id, or one belonging to another statement, refuses the whole submission. amountis a magnitude, never a signed figure: the sign is derived fromdirection, so the two cannot contradict each other. Correctingdirectionalone therefore rewrites the amount too.- An absent balance means "leave it alone", which is not the same act as clearing it. Both balances are written as plain decimals, with no thousands separator and at most four decimal places.
- One submission carries at most 500 lines. Beyond that, the whole submission is refused with a
422and nothing is written; split the review across several calls.
Only opening_balance, closing_balance, and on a line operation_date, label, amount and direction are correctable. A line already reconciled or posted to the ledger is refused.
The three states that accept a review
completed, partial and reviewing: the extraction is over and the file is still open. The other five refuse, each for its own reason.
posted- the commit is done, and correcting a line afterwards would invalidate a ledger entry that already exists.pending- the file is stored and not yet reviewable. That says nothing about an extraction running: as above, apendingis not evidence that work is underway.processing- the extraction is running, so there is nothing settled to correct.failed- the latest extraction failed. That does not mean the statement has no lines: a retry restarts from apartialorreviewingstatement without touching the previous pass's lines, so a failure after a retry leaves those lines in place.GET /api/v1/bank_statements/{id}/linesstill serves them; it is the review that is closed, not the read. They become correctable again when a successful extraction returns the file tocompletedorpartial.archived- the file was stored without any extraction running.
Anything outside those three is refused with a 422 carrying statement_not_reviewable. Opening the review narrows nothing: in reviewing just as in completed or partial, both corrections stay fully authorized - any of the statement's lines that is not already reconciled or posted to the ledger, and either of the two balances.
The response is paginated, and carries three more keys
The reviewed lines come back paginated like the GET - correcting one line of a 400-line statement does not return 400 - and meta carries, alongside the pagination block, opening_balance, closing_balance and reconciliation_errors.
{
"data": [
{
"id": 55021,
"operation_date": "2026-07-14",
"value_date": null,
"label": "VIR SEPA ACME SARL",
"amount": "300.0000",
"direction": "incoming",
"operation_type": "transfer",
"corrected": true
}
],
"meta": {
"current_page": 1,
"per_page": 20,
"total_pages": 1,
"total_count": 1,
"opening_balance": "1000.0000",
"closing_balance": "1300.0000",
"reconciliation_errors": []
}
}
Both meta balances are decimal strings at four decimals, like a line's amount, and for the same reason: opening_balance and closing_balance are DECIMAL(19, 4) too. Both sides of opening_balance + the sum of the lines = closing_balance are therefore strings, which is what lets you re-check the equality with a decimal type without one side having lost digits the other kept. The scale is fixed and padded: what you send as "300.00" comes back as "300.0000" - the same number at the published scale, so compare in decimal rather than by string equality.
A non-empty reconciliation_errors is not a failure: it is a warning carried on a 200. The correction was applied, and this list tells you the statement no longer adds up - either opening_balance plus the sum of the period's movements does not give closing_balance, or the IBAN read does not match the account. It is recomputed on every review, so it never goes stale.
The movements of this equation are not only the lines /lines serves you. For each day the extraction set aside (see above, "The days a bank connection already holds"), the equation counts, in place of the missing lines, the net of the connected account's operations for that day, as it was at extraction time. That net is stored with the statement: later syncs of the bank connection do not change it, no other day of the connected account enters the equation, and period_start and period_end play no part in it. Only a new extraction recomputes it. Routing to another account (POST /api/v1/bank_statements/{id}/route_account) clears the days set aside: the equation then reports the missing lines, until a new extraction reads the file against the chosen account. When the statement has a day set aside, summing the lines of /lines is therefore not enough to redo the calculation. Committing checks the same balance, over the same amounts.
What a correction does to state
A correction moves state only if a review was open, that is, only if the statement was reviewing: it invalidates that review, and the statement then returns to the state the extraction left it in - partial if the extraction had reported what it had not managed to read, completed if it had reported nothing. Corrected from completed or partial, so with no review open, the statement keeps its state.
A correction never promotes a statement. It bears on the review, not on the extraction: correcting a line of a file that was read truncated does not declare it complete. error_message is what carries the distinction - the extraction writes it, the correction leaves it alone, and a statement carrying it returns to partial.
The PATCH answers the reviewed lines, not the statement: GET /api/v1/bank_statements/{id} is what gives you the new state.
retryable follows state, so a statement back on partial stays re-runnable - as long as AI extraction is switched on for the company. Correcting a line does not close the extraction retry on you.
Routing a statement to an account
Only worth reading if account_detection is ambiguous. The extractor reads the document's IBAN and matches it against the company's accounts; when it matches none, or several, the file comes out ambiguous - and every commit refuses it until the question is settled. This call settles it.
curl -X POST https://app.scribee.tech/api/v1/bank_statements/8801/route_account \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{"bank_account_id": 771}'
The 200 returns the statement on the chosen account, with account_detection now manual. The write scope is required.
The statement AND all of its lines are re-pointed at the named account, and any accounting already projected against the former account is removed - the commit will post against the new one. The extracted FIGURES are untouched: a review already confirmed stays confirmed, and no further confirmation is needed between this call and the commit.
The named account must belong to the statement's own company, must not be fed by a bank aggregation provider - a provider account already carries its own movements and is not a destination for a document you uploaded - and must not be archived.
No Idempotency-Key header is required: routing twice to the same account is the same file in the same place. Naming a DIFFERENT account the second time is, however, a second routing, and it will be performed - the header would not protect you from that, reading the response does.
The two families of refusal are told apart by details:
- without
details,statement_not_reviewable: the FILE is not open to routing. It is archived, still extracting, already committed, it was stored inmode: archive, or its routing was never ambiguous in the first place (account_detectionisauto). Nothing you send changes that: read the statement. - with
details.bank_account_id,validation_failed: the ACCOUNT you named is not a destination for this file - it belongs to another company, it is synced from an aggregation provider, it is archived, or the move was refused because this file's lines already carry reconciliation work. Resend with another account.
Committing a statement
This is the call that posts the extracted lines to the general ledger and closes the review. The 200 returns the statement at posted, with committed: true.
curl -X POST https://app.scribee.tech/api/v1/bank_statements/8801/commit \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: 3f1c8a90-2d47-4b6e-9c05-8ab21e7f4d33"
The write scope is required, and so is the Idempotency-Key header. The request carries no body.
Your POST is the confirmation, and there is no other one to make. Scribee does not post figures nobody has taken responsibility for, and this call is what carries that assertion: you have read the lines (GET /api/v1/bank_statements/{id}/lines), corrected what needed it (PATCH /api/v1/bank_statements/{id}/lines), and you are committing them. There is no "confirm the review" operation to call first - the commit opens the review of the files it is about to post itself, just before posting them.
The unit is the UPLOAD, not the file
Every statement born of one multipart upload belongs to one batch, and a batch is posted whole or not at all. Committing any one of its files therefore commits them ALL: a three-file upload is posted by a single call, on whichever id you like.
The counterpart is symmetric. A single sibling file that still owes work refuses the whole call, with a 422 carrying batch_unresolved, having written nothing. Commit once per upload, not once per file; a second call naming a sibling finds the work already done.
A file you have excluded does not count: it does not block the call and is not posted with the others. It is the intended way out for a file that will never be committable - see "Excluding a file from its upload" below.
Re-committing is safe
An already-committed statement answers 200, not a refusal, and posts nothing a second time: it comes back as it stands, at posted and committed: true. This is NOT a statement_not_reviewable - sending you off to poll a posted file would never end. A call replayed after a timeout is therefore always safe.
One 4xx is NOT a rollback
The whole commit runs in a single transaction, so each of the six refusals below leaves the ledger exactly as it was, and the same Idempotency-Key may be resent once you have fixed what was refused.
But a 4xx on its own does not prove nothing was written. If the platform fails while building the response, the commit itself has already landed - and you receive a 4xx over accounting that IS in the ledger.
That is what your Idempotency-Key closes, and it is the reason to send it. Replay the same key: a 409 carrying idempotency_key_consumed tells you the first call went all the way through and posted. That code is terminal - nothing is running, nothing will complete, so do not poll. Confirm with GET /api/v1/bank_statements/{id}: state: posted and committed: true mean the work is done.
idempotency_request_in_progress is the other half of the 409 and does not say the same thing: a first call is still running, or ended without its outcome being known. There too, read the statement rather than replaying the key in a loop. The key frees itself 24 hours after its first receipt.
idempotency_key_reuse is unreachable here: a commit carries no body, so its fingerprint can never differ from the first call's. And a first call that finished normally replays its stored 200 rather than conflicting.
The six refusals, and where the remedy is
Branch on code: the six are not interchangeable, and it is what tells you WHAT you have to change.
code | What blocks | What to change |
|---|---|---|
statement_not_reviewable | The file's own state: it is archived, or its extraction has not finished | Poll the statement until state moves. Correcting lines will not clear it. No details |
statement_excluded | The file you NAMED is excluded from its upload (excluded: true): the commit would leave it out, and it does not post its siblings in its name | Name another file of the upload, or include this one back through POST /api/v1/bank_statements/{id}/include and commit again. Polling the statement is pointless. No details |
account_not_ready | The target bank account cannot receive accounting: no ledger attached, the account deactivated, or your bank no longer sharing it | The remedy is on the ACCOUNT. Polling the statement is pointless |
batch_unresolved | ANOTHER file of the same upload still owes work | details names which, and why - see below |
validation_failed | THIS file's extraction is defective | details is keyed by the field to correct through PATCH /api/v1/bank_statements/{id}/lines |
posting_failed | The ledger refused the projection, or a confirmed match could not be imputed | Nothing was posted and the statement stays committable: this is the one refusal worth retrying unchanged, once the accounting configuration is fixed |
validation_failed also carries details.idempotency_key when the header is absent or outside 1-255 characters, which is refused before the operation is even entered.
Reading a batch_unresolved
The statement you named is committable: what blocks is one of its siblings. message says how many files block, and details says WHICH - keyed <statement_id>.<reason>, as arrays of strings like everywhere else.
{
"error": "unprocessable_entity",
"code": "batch_unresolved",
"details": {
"8802.not_reviewed": ["Personne n'a confirmé la revue de ce fichier : ouvrez sa revue, vérifiez les valeurs extraites et confirmez-la avant de comptabiliser le dépôt."]
}
}
Read the reason, not the sentence. The text above is the Scribee interface's, where the review is a human act with its own screen; on this API there is nothing to "open" and nothing to "confirm", since no confirm-review operation is published here. The suffix is what carries the remedy.
.not_reviewed means that file had nothing that could be confirmed, and that is read off ITS state: call GET /api/v1/bank_statements/8802. pending or processing means let the extraction finish; failed means retry it through its own retry - or, if it is not to be posted, exclude it. An excluded file never appears in details. A sibling that is merely unconfirmed does NOT block - the commit opens the review over the whole upload, so a completed file is confirmed by your call. What cannot be confirmed is a file that has no figures yet.
Any other suffix names a field of THAT file to correct through its own PATCH /api/v1/bank_statements/8802/lines. Then commit again, once.
Excluding a file from its upload
An upload is posted whole or not at all, so a failed file blocks its whole upload: named, the commit is refused with statement_not_reviewable; as a sibling of the named file, with batch_unresolved. Excluding it is the explicit way out: the commit no longer waits for it and leaves it aside. Conversely, a valid file you do not want in the books blocks nothing: the commit opens its review and posts it with the rest of the upload, unless you exclude it first.
curl -X POST https://app.scribee.tech/api/v1/bank_statements/8802/exclude \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
The 200 returns the statement with excluded: true and excluded_at set. POST /api/v1/bank_statements/{id}/include does the reverse: the 200 returns the statement with excluded: false and excluded_at at null. The write scope is required for both.
No Idempotency-Key header is required; it is accepted without being demanded. Excluding a file that is already excluded changes nothing and keeps the first excluded_at, and including a file that is not excluded changes nothing either.
Excluding only takes the file out of the commit. Its state and its lines are kept as they are: GET /api/v1/bank_statements/{id}/lines still serves them, and they keep holding their days against a bank connection (see "The days a bank connection already holds"). A retry does not lift the exclusion: only POST /api/v1/bank_statements/{id}/include brings the file back.
What the commit does next. Committing by naming ANOTHER file of the upload posts every file that is not excluded and keeps the excluded one aside: it is not posted, does not move to posted, and does not appear in the details of a batch_unresolved. Committing by naming the excluded file itself is refused by a 422 carrying statement_excluded, having written nothing. An upload whose every file is excluded therefore cannot be committed: whichever file you name, the answer is statement_excluded.
{
"error": "unprocessable_entity",
"code": "statement_excluded",
"message": "Ce fichier est exclu de son dépôt : il ne peut pas être revu. Réintégrez-le au dépôt pour le revoir."
}
Read the code, not the sentence: the text is the Scribee interface's, which speaks of a review; on this API it is the commit it refuses. The remedy is an act, never a wait - name another file of the upload, or include this one back.
Including a file back returns it as it was. Same state, same lines: a file that blocked the commit before its exclusion blocks it again until it is resolved.
A file still extracting, failed or already under review can be excluded. Two states refuse it: posted, whose entries are already in the general ledger, and archived, which never takes part in a commit. Both the exclusion and the inclusion are refused there by a 422 carrying statement_not_reviewable, with no details.
Re-running an extraction
Read retryable before calling: the affordance and the endpoint read the same rule. Four states are not re-run: archived, completed, posted and processing. And no statement is re-run while AI extraction is switched off for the company: retryable is then false in every state.
curl -X POST https://app.scribee.tech/api/v1/bank_statements/8801/retry \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Idempotency-Key: 7c9a1e44-5b02-4c81-a0d7-9e63f1b28a55"
The 202 returns the statement reset to pending, with the previous error cleared. The write scope is required, and so is the Idempotency-Key header.
A 422 operation_failed is not about the statement's state, and it has two causes. If AI extraction is switched off for the company, the statement is left exactly as it was and nothing was queued: turn the setting back on in Scribee before calling again. Otherwise, the extraction could not be queued: the statement has been reset to pending with nothing reading it, and this same call is the remedy - retry it. In both cases, waiting for the state to change would lead nowhere.
A statement refused because its file had already been uploaded reports retryable: true, and re-running does not unblock it. It does reset it to pending and restart the extraction, which applies the same rule again and brings it back to failed with the same message. As long as the statement that owns the file exists, the second one will not be extracted.
Deleting a statement
curl -X DELETE https://app.scribee.tech/api/v1/bank_statements/8801 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
The deletion is permanent and takes the stored file with it. The response body is the statement as it stood when you asked for it to go - it is serialized before the erasure, since afterwards its file no longer exists to describe.
It is possible only as long as the extraction has persisted no operation, and committed is where that boundary sits - not reconciliation. A statement reporting committed: true is refused with a 422 carrying statement_not_reviewable whatever its state - even if nobody has reconciled those lines - and that refusal is permanent: the statement will not become deletable again. An archived statement is refused too.
What stays deletable is therefore a statement whose extraction produced nothing, which is this endpoint's main use: cancelling a failed import. The destroy scope works, and so does write.
Errors
| Status | code | When |
|---|---|---|
422 | validation_failed | No part carries a file; details names file |
422 | validation_failed | The parts disagree about mode; details names mode |
422 | validation_failed | A part names an account outside this company; details names bank_account |
422 | validation_failed | A file's format or size is refused, or the target account is fed by a bank connection; details names base |
422 | validation_failed | lines is not a list; details names lines |
422 | validation_failed | A lines entry carries no id, or names a line of another statement |
422 | validation_failed | A malformed figure: thousands separator, more than four decimals, out-of-range value |
422 | validation_failed | A line already reconciled or posted to the ledger |
422 | statement_not_reviewable | The statement's state forbids the review, the retry or the commit |
422 | operation_failed | The retry is refused: AI extraction is switched off for the company, or the extraction could not be queued - see "Re-running an extraction" |
422 | statement_not_reviewable | The deletion is refused: the statement reports committed: true, or it is archived |
422 | statement_not_reviewable | The routing is refused: the file is not open to routing, or its routing was never ambiguous |
422 | statement_not_reviewable | The exclusion or the inclusion is refused: the statement is posted or archived |
422 | validation_failed | The routing names an account that is not a destination for this file; details names bank_account_id |
422 | account_not_ready | The account the commit targets cannot receive any accounting |
422 | statement_excluded | The commit names a file excluded from its upload |
422 | batch_unresolved | Another file of the same upload blocks the commit; details names which |
422 | posting_failed | The ledger refused the projection, or a confirmed match could not be imputed |
422 | validation_failed | The Idempotency-Key header is missing on the upload, the retry or the commit |
409 | idempotency_key_reuse | The same Idempotency-Key already served a DIFFERENT upload. Nothing was stored by this call: take a fresh key, or resend the ORIGINAL files under the same key to replay the stored 202 |
409 | idempotency_request_in_progress | The key is claimed and its outcome is not replayable - see below |
409 | idempotency_key_consumed | A first call bearing this key went all the way through, produced an effect, and was then refused. Terminal: do not poll, check what the first call did |
404 | - | The statement is not reachable by your grants |
A refusal about the statement's state rather than about a field carries no details: code is what to read.
Every 422 on this upload is validation_failed, and none of them stored anything: the batch is refused whole, so fix the named field and send the request again - under the same Idempotency-Key if you like. details values are always arrays of STRINGS, and message is always present.
{
"error": "unprocessable_entity",
"code": "validation_failed",
"message": "La validation a échoué",
"details": {
"base": ["Les comptes connectés via Bridge ne peuvent pas être utilisés pour les téléversements de relevés."]
}
}
{
"error": "unprocessable_entity",
"code": "statement_not_reviewable",
"message": "n'est pas dans un état qui autorise cette opération"
}
statement_not_reviewable names a STATE, not a passing error: read the statement again rather than calling the same endpoint back.
API reference
- API reference: upload statements
- API reference: list statements
- API reference: read a statement
- API reference: retry an extraction
- API reference: read the lines
- API reference: review the lines and balances
- API reference: commit a statement
- API reference: route a statement to an account
- API reference: exclude a statement from its upload
- API reference: include a statement back in its upload
- API reference: delete a statement