Skip to main content

Webhooks

A supplier invoice arrives over the network, a status changes for your customer: your system needs to know without delay. Rather than polling the lists in a loop, register an https URL on your server: Scribee pushes each event to it with a signed POST, the instant it happens. This is the channel that complements the list polling described in Receive supplier invoices and the status tracking in the invoice lifecycle.

What Scribee does for you​

  • Signs every delivery with HMAC-SHA256 (X-Scribee-Signature): you authenticate the origin without placing a secret in your URL.
  • Delivers at least once and automatically retries on failure, at increasing intervals from 30 seconds to 24 hours, for up to 7 days.
  • Freezes the content at the moment of the event: a retried delivery reflects the object as it was at the event, even if it has been modified or deleted since.
  • Attaches ready-to-use download links to the invoice payloads for the four formats (pdf, facturx, ubl, cii).
  • Targets deliveries whenever you want: an endpoint listens to the whole workspace, or to a single invoice category.
  • Deactivates an endpoint after 20 consecutive failed deliveries, to stop sending to a server that no longer responds; you reactivate it with a single call.

The eight events​

event_typeFires when
invoice.createdAn invoice is created in the workspace, whatever its origin - created through the API, arriving over the network, entered in the Scribee interface, converted from a quote - and whatever its direction (sales or purchases).
invoice.lifecycle_event.createdA lifecycle event is added to an invoice (The invoice lifecycle).
bank_account.updatedA bank account's activation, its accounting configuration, or the readiness those two derive changes (Bank accounts). Three sources emit it: the partner PATCH, activation done from the Scribee interface, and the synchronisation of a bank connection - which emits it on the discovery of an account, on its archiving when the provider stops listing it, on its restoration when the provider lists it again, and when the provider's data access changes.
bank_operation.reconciledAn allocation moves to confirmed on a bank operation (Allocations and reconciliation). One event per transition, not per call: a convergence confirming three allocations emits three of them.
bank_operation.unreconciledA confirmed allocation is undone (Allocations and reconciliation). One event per withdrawn allocation: a call withdrawing three emits three, and an operation losing its last allocation emits that same event.
bank_rule.appliedAn imputation rule determined an operation's accounting projection, at the instant that projection is written (Bank imputation rules).
payment_batch.updatedA payment batch changes state (Payment batches). One event per change of state: creating a batch, editing its instructions and starting a handover that leaves the batch approved emit none.
payment_instruction.updatedA payment instruction's published status changes (Payment batches). One event per change of published status: creating an instruction emits none.

These eight events exist today. Any other change - quotes, payments, the directory, e-reporting, bank connections and bank syncs - is observed by reading the API (API conventions).

A write that changes nothing emits nothing. bank_account.updated is emitted behind the same guard that decides whether there is a write at all: replaying a PATCH already applied produces no delivery, and does not even touch updated_at. Synchronisation follows the same rule on its own axis: a pass that refreshes a balance, a name or a date without producing one of the transitions above emits nothing - otherwise every account would cost you a delivery on every pass.

An endpoint subscribes to a single event type, chosen at creation and not modifiable afterward. An event_type sent in a PATCH is ignored, including when it is the body's only key: the call then answers 200 with the endpoint unchanged. Do not read that 200 as confirmation of a change. To receive several types, create one endpoint per type - the same URL can serve all seven.

Target a category​

By default, an endpoint receives every event of its type in the workspace. category_id narrows that scope to a single category (Categories): the endpoint then receives only the events of the invoices filed in that category. The field is optional at creation, modifiable afterward, and defaults to null - an endpoint without a category_id behaves exactly as before.

Endpoint category_idInvoice with no categoryInvoice in the targeted categoryInvoice in another category
nullDeliveredDeliveredDelivered
12Not deliveredDeliveredNot delivered

An invoice with no category therefore reaches only the endpoints without a category_id: no targeted endpoint receives it. A purchase invoice that arrives without a category may however receive its supplier's (Categories): it then reaches the endpoints targeted on that category. To cover both a category and the rest of the workspace, keep an untargeted endpoint alongside the targeted one.

The two invoice events do not read the category at the same moment. invoice.created keeps the one the invoice carried at the moment of the event, frozen with the rest of the body. invoice.lifecycle_event.created reads the invoice's current category at the moment the event is dispatched: filing an invoice under another category therefore changes which endpoints will receive its subsequent lifecycle events.

Only the invoice events carry a category. Targeting therefore applies to invoice.created and invoice.lifecycle_event.created only, and a category_id on a bank_account.updated, bank_operation.reconciled, bank_operation.unreconciled, bank_rule.applied, payment_batch.updated or payment_instruction.updated endpoint is refused with 422, on creation as well as on update. The refusal is deliberate: fan-out derives no category for a bank account, an operation, a rule, a payment batch or a payment instruction, so such a subscription would be created only to never receive anything. Leave category_id at null on those six types of endpoint.

The category must belong to the endpoint's workspace and must not be archived, otherwise the call responds 422. The other way around, as long as an endpoint targets a category, DELETE /api/v1/categories/{id} refuses to archive it: unbind or delete those endpoints first (Categories). The refusal counts every endpoint of the workspace bound to that category, including those of another application and those managed by the workspace - while your writes only reach your own. An endpoint you do not own is therefore unbound from the Scribee interface, or by the application that registered it.

The path of a delivery​

Step 1: create an endpoint​

This call registers a subscription; it sends nothing outbound at creation time. Deliveries start at the next matching event in the workspace, and a DELETE ends them at any time. No test event exists: for a controlled rehearsal, create an invoice (Issue a sales invoice) - its creation triggers a real invoice.created delivery. That invoice then stays in the workspace: as soon as it carries a number, including the provisional TEMP-... number Scribee assigns when you supply none, DELETE /api/v1/invoices/{id} responds 403. For a bank_account.updated endpoint the rehearsal commits you to less: a PATCH changing at least one value on a bank account produces a real delivery, and you return to the starting state by sending the previous values back (Bank accounts).

curl -X POST https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/webhook_endpoints \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"webhook_endpoint": {
"event_type": "invoice.created",
"url": "https://api.example.com/webhooks/scribee",
"name": "Notifications factures",
"includes": ["lines"]
}
}'

201 response, trimmed to the fields useful here:

{
"data": {
"id": 3,
"event_type": "invoice.created",
"url": "https://api.example.com/webhooks/scribee",
"includes": ["lines"],
"active": true,
"name": "Notifications factures",
"signing_secret": "whsec_YOUR_SIGNING_SECRET"
}
}

Store signing_secret immediately: it appears only in this response and in the rotation response - never on reads. event_type and url are required; the write scope is required (Authentication). The URL must be https: an http address, a literal private or reserved IP address, or an internal hostname (localhost, .localhost, .local, .internal, .home.arpa suffixes) are refused at registration with a 422.

Registration does not test whether the URL is reachable: the hostname is only resolved at delivery time. A hostname resolving to a private or reserved address fails the delivery permanently, with no retry.

includes selects the associated collections embedded in the invoice.created payload, from the same values as the include parameter of the invoice list: lines, payment_means, tax_subtotals, lifecycle_events, allowance_charges, item_notes, invoice_references, payments, early_payment_discounts. Without includes, the payload carries the invoice header and the download links. No other type admits any include - neither invoice.lifecycle_event.created, nor bank_account.updated, nor the five banking events described in step 4: their payload is delivered as it is, and any non-empty includes there responds 422. The refusal is on the change of value, not on the value already stored, because the endpoint is written back after every delivery: an existing subscription therefore keeps working, can be renamed, deactivated, or repointed freely, and re-sending its current includes unchanged is accepted. Only acquiring an include is refused, and clearing includes is how you bring it back in line.

category_id is also accepted here, to create the endpoint already targeted at a category, under the rules of the previous section. Omit it for an endpoint that listens to the whole workspace.

Step 2: receive and acknowledge​

Each event is delivered, as a JSON POST, to every active endpoint subscribed to its type whose category_id is null or matches the invoice's category:

POST /webhooks/scribee HTTP/1.1
Content-Type: application/json
User-Agent: Scribee-Webhooks/1
X-Scribee-Event: invoice.created
X-Scribee-Delivery: 512
X-Scribee-Signature: t=1785489300,v1=6c7f9a2e8b1d4c5f...

Respond with a 2xx status to acknowledge, before any long processing: your server has 10 seconds to respond (5 seconds to establish the connection). Any other status counts as a failure - including a 3xx redirect, which is never followed.

Delivery is at least once: the same delivery can be redelivered, with the same X-Scribee-Delivery. This identifier is your deduplication key - an already-processed X-Scribee-Delivery is re-acknowledged with 2xx without reprocessing. Two deliveries of the same event to two endpoints carry distinct identifiers. Arrival order is not guaranteed (a retry can follow more recent events): order on occurred_at.

Step 3: verify the signature​

X-Scribee-Signature is t=<unix timestamp>,v1=<hex>, where v1 is the HMAC-SHA256 of the string "<t>.<raw body>" with your signing_secret as the key. Compute it on the raw request body, exactly as received, before any JSON decoding.

t timestamps the attempt, not the event: a retry carries a fresh t and a fresh signature over an identical body. Compare it against your clock with the tolerance of your choice - 300 seconds in the example below - to block replay of a captured request.

def valid_signature?(raw_body, header, secret, tolerance: 300)
parts = header.split(",").map { |kv| kv.strip.split("=", 2) }.to_h
return false if (Time.now.to_i - parts["t"].to_i).abs > tolerance

expected = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{parts['t']}.#{raw_body}")
Rack::Utils.secure_compare(expected, parts["v1"])
end

Reject any invalid signature with a non-2xx status: the retry will redeliver the same event later, which covers the gap between a secret rotation and your verifier's update.

Step 4: read the payload​

The envelope is identical for all eight events: id (the delivery identifier, the value of X-Scribee-Delivery), type, occurred_at (the date of the event itself), workspace_id, and data.

The envelope's occurred_at is UTC, suffixed with Z. Every date inside data - created_at, updated_at, the event object's occurred_at - is ISO 8601 with the Europe/Paris offset (+01:00 or +02:00). Both notations denote the same instant.

For invoice.created, data is the invoice as returned by the API, augmented with the collections requested in includes and a download object (excerpt below; the invoice carries every field of the serializer):

{
"id": 512,
"type": "invoice.created",
"occurred_at": "2026-07-31T09:15:00Z",
"workspace_id": 7,
"data": {
"id": 12345,
"invoice_number": "INV-2026-00042",
"direction": "purchases",
"proforma": false,
"lifecycle_state": "draft",
"lifecycle_available_transitions": ["..."],
"upload_source": "api",
"created_at": "2026-07-31T11:15:00+02:00",
"lines": ["..."],
"download": {
"pdf": "https://app.scribee.tech/api/v1/invoices/12345/download.pdf",
"facturx": "https://app.scribee.tech/api/v1/invoices/12345/download.facturx",
"ubl": "https://app.scribee.tech/api/v1/invoices/12345/download.ubl",
"cii": "https://app.scribee.tech/api/v1/invoices/12345/download.cii"
}
}
}

data is serialized exactly once, inside the transaction that completes the processing of the invoice - so atomically with its final state, once the whole import processing has finished. That frozen body is the one every endpoint, every delivery, and every retry of the event receives; each endpoint's own includes filtering applies to those same bytes. It describes the invoice as GET /api/v1/invoices/{id} returned it at that instant: upload_source carries the real arrival channel (api for a deposit through the API), lifecycle_state, lifecycle_status_code, and lifecycle_available_transitions carry the state actually reached - including the one you requested at deposit - the matched parties are the ones retained, and lifecycle_events (if you requested it in includes) holds the events already written. Later states reach you through invoice.lifecycle_event.created, and the current state is re-read with GET /api/v1/invoices/{id}.

The body is never re-serialized: every delivery of the event - to a second endpoint, or after a retry - returns exactly the same bytes. Three consequences. occurred_at and the body are temporally consistent: the body describes the invoice as it stood at the instant of the event, so ordering your processing on occurred_at stays correct even when a delivery arrives late or after a retry. A change made after the event - a new number, a lifecycle transition - alters nothing in what an already emitted webhook delivers: you receive the state at the instant of the event, never a mix. And an invoice deleted since is still delivered, from that same frozen snapshot: the body describes what it was once the import processing had finished - you are informed of its existence and of its state at that instant, not of what it became since.

download carries, for each of the four formats, the invoice's absolute download URL - the format there is a path extension, equivalent to the format parameter documented in Formats and downloads. These URLs are called with your usual Bearer token and the read scope: nothing to build on the integration side.

For invoice.lifecycle_event.created, data references the invoice and details the event. The example below shows an event received from the network (status_code 205), which is why the sender_* fields are populated:

{
"id": 513,
"type": "invoice.lifecycle_event.created",
"occurred_at": "2026-07-31T10:02:00Z",
"workspace_id": 7,
"data": {
"document_id": 12345,
"invoice_number": "INV-2026-00042",
"event": {
"id": 88,
"status_code": "205",
"status_label": "Approuvée",
"state": "approved",
"occurred_at": "2026-07-31T12:02:00+02:00",
"sender_role": "BY",
"sender_id": "12345678900017",
"sender_scheme_id": "0009",
"reason": null,
"reason_code": null,
"terminal": false,
"rejected": false,
"applied": true,
"created_at": "2026-07-31T12:02:03+02:00"
},
"download": {
"pdf": "https://app.scribee.tech/api/v1/invoices/12345/download.pdf",
"...": "..."
}
}
}

The event object above is complete: it carries these fourteen fields and no others. state is the machine value, stable whatever the language: that is the one to branch on. status_label is a display label - French for an event produced inside Scribee, but taken verbatim from the inbound status message when the network supplies one, so in the sender's own language and wording. The three sender_* fields do not all describe the same party. For a status message received from the network, sender_role repeats the role of the message's sending party, while sender_id and sender_scheme_id identify the selected receiving platform (the recipient party with role WK) - not the sender. All three are null for an event produced inside Scribee. rejected set to true marks an audit-only event recorded when an inbound status message was refused; applied set to false marks an event that did not transition the invoice.

The same event can be delivered twice. When Scribee re-applies received statuses previously recorded without effect, each status that then transitions the invoice emits a new invoice.lifecycle_event.created delivery for the same event: same event.id, applied now true, but a distinct delivery id. The first delivery carried applied set to false. The change only goes one way: for a given event.id, the delivery with applied set to true supersedes the one with false, never the reverse. Since occurred_at is the date of the event, it is identical on both deliveries and does not tell them apart. Read GET /api/v1/invoices/{id} for the state the invoice reached.

A reopening is announced by no delivery. A refused purchase invoice (210) whose refusal never left Scribee can be reopened from the interface and go back to available: no lifecycle event is created, so no invoice.lifecycle_event.created is emitted, and the 210 delivery remains the one that carried terminal set to true. See Reopening a refused purchase invoice.

The content of data is frozen at the moment of the event: a delivery retried three days later reflects the invoice as it was then, even if it has been modified or deleted since. The download URLs, by contrast, point at the live invoice: they serve its current content, and respond 404 if it has been deleted. For the current state of the fields, re-read GET /api/v1/invoices/{id}.

For bank_account.updated, data is the bank account, narrowed to twelve keys and not one more:

{
"id": 514,
"type": "bank_account.updated",
"occurred_at": "2026-08-21T09:20:00Z",
"workspace_id": 7,
"data": {
"id": 7001,
"company_id": 34,
"bank_connection_id": 501,
"account_name": "Compte courant",
"bank_name": "Demo Bank",
"origin": "synchronized",
"currency_code": "EUR",
"iban_masked": "FR*********************0189",
"iban_last4": "0189",
"accounting_account_code": "512999",
"readiness": {
"ready": false,
"missing": ["missing_ledger", "missing_suspense_account_code"],
"unposted_operations_count": 12
},
"updated_at": "2026-08-21T11:20:00+02:00"
}
}

That body is narrower than the GET's, deliberately. Seven fields the read publishes are not in it: active, archived, ledger_id, suspense_account_code, balance, last_synced_at and created_at. Do not wait for them in a delivery - a subscriber that needs them reads the account with GET /api/v1/bank_accounts/{id} (Bank accounts). The twelve keys above, on the other hand, carry exactly the same value and the same rendering as in that read, readiness included, derived at the moment of the event.

iban_masked and iban_last4 follow the GET's masking rule there, eight-character threshold included: the full IBAN is published on no surface, webhooks included.

No download object accompanies this payload: a bank account is not a document. The appearance of an account reaches you through this same event - the synchronisation of a connection emits it when it discovers an account, which is how a subscriber learns a new one exists, without re-reading the list. Only an account created by a statement import goes unannounced.

For bank_operation.reconciled, data carries the operation as the confirmation left it: the complete set of confirmed allocations and the resulting accounting entry lines.

The event fires per transition, not per call. It is emitted where an allocation moves to confirmed, so a convergence confirming three of them emits three deliveries, each carrying the allocation set as it stood at its own transition - and a confirmation made from the Scribee interface emits it just as an API call does. A consumer built on the assumption of one delivery per call will be wrong. As everywhere else on this page, delivery is at least once and its order is not guaranteed: deduplicate on the envelope's id and re-read the resource for the current state (Bank operations).

{
"id": 516,
"type": "bank_operation.reconciled",
"occurred_at": "2026-09-04T13:01:44Z",
"workspace_id": 7,
"data": {
"id": 550231,
"company_id": 87,
"amount": "-300.0000",
"direction": "outgoing",
"reconciliation_status": "matched",
"version": 4,
"allocations": [
{ "id": 8801, "invoice_document_id": 41207, "allocated_amount": 200.0, "score": 0.982, "status": "confirmed" },
{ "id": 8802, "invoice_document_id": 41219, "allocated_amount": 100.0, "score": 0.931, "status": "confirmed" }
],
"accounting_entry": {
"id": 990412,
"entry_kind": "bank_operation",
"exported": false,
"lines": [
{
"account_number": "401MARTIN",
"label": "Facture 41207",
"debit_amount": 200.0,
"credit_amount": 0.0,
"explanation": { "source": "allocation", "rule_id": null, "rule_name": null, "reason": "confirmed allocation 8801" }
},
{
"account_number": "401MARTIN",
"label": "Facture 41219",
"debit_amount": 100.0,
"credit_amount": 0.0,
"explanation": { "source": "allocation", "rule_id": null, "rule_name": null, "reason": "confirmed allocation 8802" }
},
{
"account_number": "512000",
"label": "Compte courant",
"debit_amount": 0.0,
"credit_amount": 300.0,
"explanation": { "source": "allocation", "rule_id": null, "rule_name": null, "reason": "bank account 512000" }
}
]
}
}
}

Eight keys, and not one more: id, company_id, amount, direction, reconciliation_status, version, allocations and accounting_entry. The operation's other fields - dates, description, origin, projection, reconciliation - are not in this body; re-read GET /api/v1/bank_operations/{id} if you need them.

amount is a string while allocated_amount and score are numbers, and that is not an inconsistency. The rule is the same everywhere: an event field publishing a column carries exactly the type and the rendering /api/v1 publishes for that same column, so that a webhook and a GET can never give two values for one row. amount therefore follows Bank operations, where the column is published as a string with four decimals because a double-precision float cannot carry the nineteen significant digits it accepts; allocated_amount and score follow Allocations and reconciliation, where they are JSON numbers with four decimals.

allocations is the complete set of confirmed allocations, never a delta, by ascending identifier. Their status is therefore always confirmed: a proposal nobody confirmed and an allocation since undone do not appear in it. A 300 operation split 200/100 thus reads as two allocations and three balanced lines, not as one mutated line. If you want the change between two events, compute it; the body states a state.

accounting_entry is null when the operation carries no entry yet - the bank account was not ready to post, or the projection has not run. That is a reachable state, not an anomaly. When present, the entry is reduced to id, entry_kind, exported and lines; the rest is read from the resource (Bank accounting entries).

An entry line has no identifier here, deliberately: lines are rewritten wholesale on every re-projection, so an identifier you had stored would name a line that no longer exists. It carries account_number, label, debit_amount, credit_amount and explanation - the last of these is specific to the event, where reading the entry publishes tax_code as its fifth key. Both amounts are non-negative, exactly one of the two sides is non-zero, and they are published as numbers with two decimals. explanation.source draws on the usual vocabulary - allocation, company_rule, tenant_rule, suspense - and its reason is text meant for a human, not to be parsed. That text is written in English whatever the language of the request behind the event, and it is the one the explanation of the same line carries on the entry. An event emitted before Scribee fixed that language may carry, for a line a rule decided, a French sentence: events already emitted are not rewritten, and a new delivery replays their original body.

For bank_operation.unreconciled, data says which allocation was withdrawn and which ones survive.

This event fires per allocation, not per operation. A call withdrawing three allocations emits three deliveries, each naming one allocation in removed_allocation_ids and the survivors in allocations. An operation losing its last allocation emits that same event, with an empty survivor list - it is not a different event, and there is no other one announcing that an operation has become entirely unreconciled again.

{
"id": 517,
"type": "bank_operation.unreconciled",
"occurred_at": "2026-09-04T15:01:44Z",
"workspace_id": 7,
"data": {
"id": 550231,
"company_id": 87,
"reconciliation_status": "partially_matched",
"version": 5,
"unreconciled_at": "2026-09-04T17:01:44+02:00",
"removed_allocation_ids": [8802],
"allocations": [
{ "id": 8801, "invoice_document_id": 41207, "allocated_amount": 200.0, "score": 0.982, "status": "confirmed" }
]
}
}

Seven keys: id, company_id, reconciliation_status, version, unreconciled_at, removed_allocation_ids and allocations. id is the operation's; the withdrawn allocation is named in removed_allocation_ids. unreconciled_at is the instant that allocation was undone, rendered exactly as its updated_at on the allocation resource.

Three keys of the confirmation body are absent here - amount, direction and accounting_entry - and will stay absent. An undo says nothing about the operation's amount or direction, and the re-projected entry is read from Bank accounting entries. For the complete operation, re-read GET /api/v1/bank_operations/{id}.

removed_allocation_ids is an array carrying exactly one identifier today. That is a consequence of the grain above, not a shape waiting to be filled: treat it as an array and you will have nothing to change should a multiple withdrawal in one transition ever appear. allocations carries the surviving allocations, by ascending identifier and rendered exactly as on the confirmation event.

For bank_rule.applied, data describes the rule, not the operation: id is the rule's identifier, and the operation whose imputation it determined travels as bank_operation_id.

The event is emitted where a projection is written, and only if the rule actually produced a line in it. A simulation (POST /api/v1/bank_rules/{id}/preview) emits nothing - it writes nothing. A re-projection that changes nothing emits nothing either, and an operation whose confirmed allocations consume the whole movement leaves no remainder to impute, hence no rule to announce (Bank imputation rules).

{
"id": 518,
"type": "bank_rule.applied",
"occurred_at": "2026-09-04T06:12:04Z",
"workspace_id": 7,
"data": {
"id": 88,
"company_id": null,
"scope": "tenant",
"name": "Frais bancaires",
"priority": 20,
"bank_operation_id": 550240,
"account_number": "627000",
"explanation": {
"source": "tenant_rule",
"rule_id": 88,
"rule_name": "Frais bancaires",
"reason": "direction is outgoing and operation type is direct_debit"
}
}
}

Eight keys: id, company_id, scope, name, priority, bank_operation_id, account_number and explanation. company_id is the rule's: it is null exactly when scope is tenant, as on the resource.

account_number is at the top level here, where reading a rule nests it under action. That is the only shape difference between the two, and the value is the same.

The rule's conditions are not in this body, nor its validity window, nor whether it is enabled, nor its exclusions: an event announces the imputation that was made, not the configuration that produced it. Re-read GET /api/v1/bank_rules/{id} for the complete rule. explanation is the object published everywhere else on rules, and its reason is text meant for a human, not to be parsed. It is written in English whatever the language of the request behind the imputation: it is the sentence the explanation of the entry line the rule decided carries. An event emitted before Scribee fixed that language may carry a French sentence: events already emitted are not rewritten, and a new delivery replays their original body.

For payment_batch.updated, data is the payment batch as the transition left it, together with the state it has just left.

The event fires per change of state, and only there. Ten transitions emit it:

previous_statestateWhat happened
draftpending_approvalThe batch was submitted for approval
pending_approvalapprovedThe batch was approved
pending_approvaldraftApproval was refused; the batch becomes editable again
approvedsubmittedThe channel took the batch
approvedfailedThe handover was refused, by the provider or by Scribee before anything was sent; this state is final
submittedcompletedEvery instruction of the batch is settled
submittedpartially_completedSome instructions of the batch are settled and the bank refused the others
submittedrejectedThe bank refused every instruction of the batch; this state is final
completedsubmittedA confirmed settlement was undone on an instruction the bank has not reported executing
partially_completedsubmittedA confirmed settlement was undone on an instruction the bank has not reported executing

state therefore takes one of the eight values described in Payment batches, and previous_state one of the six that are not final: draft, pending_approval, approved, submitted, completed or partially_completed. These transitions are made from the Scribee interface or through the API - submission for approval, approval, refusal, handover, confirming or undoing an instruction's settlement (Payment batches) -, by the processing of the handover, or on receipt of the bank's answer on the batch's instructions. A batch only moves to completed, partially_completed or rejected once every one of its instructions has a final status, and these three states do not record the payment of an invoice. Nothing else emits this event - neither the creation of a batch, which is born draft, nor editing its instructions, nor starting a handover, which fills in submitted_at but leaves the batch approved: it is the move to submitted or to failed that gives you its outcome. An approval replayed on an already-approved batch emits nothing either, and two processes attempting the same transition produce only one event.

{
"id": 519,
"type": "payment_batch.updated",
"occurred_at": "2026-08-27T08:02:11Z",
"workspace_id": 7,
"data": {
"id": 7301,
"company_id": 34,
"state": "completed",
"previous_state": "submitted",
"total_amount": 1250.5,
"currency_code": "EUR",
"instructions_count": 2,
"updated_at": "2026-08-27T10:02:11+02:00"
}
}

Eight keys, and not one more: id, company_id, state, previous_state, total_amount, currency_code, instructions_count and updated_at. id is the batch's. The seven other than previous_state carry exactly the same value and the same rendering as GET /api/v1/payment_batches/{id} at the instant of the transition - total_amount included, a JSON number rounded to two decimals. updated_at is the instant of the transition, the same as the envelope's occurred_at in the other notation.

This body is narrower than the GET's, deliberately. bank_account_id, name, channel, version, execution_date, approved_at, submitted_at, progress, error_code, error_message, retryable, started_at, finished_at and created_at are not in it. A batch that moved to failed therefore does not say here why, nor who refused it: re-read GET /api/v1/payment_batches/{id} for its error_message, its submission_refused_by and the current state (Payment batches). Nor does a batch that moved to partially_completed say which instructions the bank executed: the status of each is read on GET /api/v1/payment_batches/{id}/instructions.

For payment_instruction.updated, data is the payment instruction as the change of status left it, together with the status it has just left.

The event fires when an instruction's published status changes, and only then. It is emitted once per change, once that change is recorded. Six sources produce it: the start of its batch's handover, which moves it from draft to submitted, the refusal of that handover, which moves it from submitted to submission_failed when it does not yet carry any status from the bank, or from submitted to rejected when the provider's answer refusing the handover itself reports it as refused, a status reported by the bank on the instruction, the confirmation of its settlement, which moves it to settled, the undoing of that settlement, which brings it back from settled to pending, and the declaration that it was not executed, which moves it from pending to rejected (Payment batches). A declaration resent unchanged changes nothing and emits nothing.

Nothing else emits it:

  • the creation of an instruction, which is born draft, nor the replacement of a draft's lines;
  • a handover call that is refused without starting a handover, or replayed: only the call that starts the handover - POST /api/v1/payment_batches/{id}/submit or POST /api/v1/payment_batches/{id}/sepa_export - emits one event per instruction still draft, and a handover once started is never withdrawn, so no instruction ever returns to draft;
  • a handover whose outcome is not known - consent pending, SEPA file still being generated, channel answer lost: the instruction stays submitted. The refusal of a handover emits only once per instruction, even when it is read several times;
  • a report from the bank that leaves the published status unchanged: two stages the bank tells apart but the API publishes both as pending, or a report received a second time.
{
"id": 520,
"type": "payment_instruction.updated",
"occurred_at": "2026-08-27T08:02:11Z",
"workspace_id": 7,
"data": {
"id": 61004,
"company_id": 34,
"payment_batch_id": 7301,
"status": "settled",
"previous_status": "pending",
"amount": 830.0,
"currency_code": "EUR",
"reference": "FA-2026-0310",
"end_to_end_id": "SCB-7301-0001",
"beneficiary_iban_masked": "FR*********************0189",
"beneficiary_iban_last4": "0189"
}
}

Eleven keys, and not one more: id, company_id, payment_batch_id, status, previous_status, amount, currency_code, reference, end_to_end_id, beneficiary_iban_masked and beneficiary_iban_last4. id is the instruction's. The ten other than previous_status carry exactly the same value and the same rendering as GET /api/v1/payment_instructions/{id} at the instant of the change - amount included, a JSON number rounded to two decimals. status and previous_status take the values that read publishes: draft, submitted, submission_failed, pending, settled or rejected. The body does not carry the reason for a refusal.

The beneficiary's full IBAN is never in it: only beneficiary_iban_masked and beneficiary_iban_last4, as in the read. invoice_document_id, beneficiary_name, execution_date, created_at and updated_at are not in it either: re-read GET /api/v1/payment_instructions/{id} for them.

None of these five events accepts an include or a category. Their body is delivered as is - there is nothing to narrow - so any non-empty includes is refused with a 422, and so is a category_id: neither an operation, nor a rule, nor a payment batch or instruction carries a category, so a targeted endpoint would never receive anything.

Manage your endpoints​

The list is paginated per the API conventions and never contains signing_secret; the read scope is enough. It accepts neither sorting nor filtering. Note the path: unlike invoices, reading, modifying, and deleting an endpoint stay under /api/v1/workspaces/{workspace_id}/webhook_endpoints/{id}.

curl https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/webhook_endpoints \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"
{
"data": [
{
"id": 3,
"event_type": "invoice.created",
"url": "https://api.example.com/webhooks/scribee",
"includes": ["lines"],
"active": true,
"name": "Notifications factures",
"description": null,
"oauth_application_id": 42,
"category_id": null,
"created_at": "2026-07-31T11:15:00+02:00",
"updated_at": "2026-07-31T11:15:00+02:00"
}
],
"meta": { "current_page": 1, "per_page": 20, "total_pages": 1, "total_count": 1 }
}

The list covers the whole workspace, not only your application: it also returns the endpoints created by other partner applications and those created in the Scribee interface, target URLs included. oauth_application_id carries the identifier of the owning application, null for an endpoint created in the interface - a workspace-managed endpoint.

Writes, by contrast, are limited to what you own. PATCH, DELETE, and secret rotation only accept the endpoints whose oauth_application_id is your application's. A workspace-managed endpoint (oauth_application_id at null) belongs to no application and is managed from the Scribee interface only: update, delete, and secret rotation all respond 403 for every OAuth application, whichever one calls. On another application's endpoint, those calls respond 403 and change nothing. So you do not have to filter on oauth_application_id before writing: no application can rewrite, delete, or rotate the secret of an endpoint another one registered, nor of a workspace-managed endpoint.

PATCH modifies url, name, description, includes, category_id, and active (write scope). Every read - single GET, list, creation, secret rotation - returns category_id, null for an untargeted endpoint.

category_id can be changed at any time on an invoice endpoint: another category of the workspace moves the target, null removes it and reopens the endpoint to the events of the whole workspace. On a bank_account.updated endpoint, only null is accepted.

Setting active to false is not a pause: events that occur while it is inactive are not replayed, and the deliveries already queued for that endpoint fail permanently on their next attempt - reactivating does not bring them back. Setting active back to true resets the consecutive-failure counter to zero and opens the following events.

Changing url does not redirect the deliveries already queued: each keeps the destination URL recorded when it was queued and keeps targeting the old address until its 7-day window runs out. The new URL only serves events later than the change.

curl -X PATCH https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/webhook_endpoints/3 \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "webhook_endpoint": { "active": false } }'

DELETE on the same path deletes the endpoint permanently - 204 response, empty body. The write scope is required.

Rotate the secret​

This call replaces the signing secret immediately and permanently: from the response onward, every delivery - including retries of deliveries already queued - is signed with the new secret, and the old one no longer verifies anything. Update your verifier as soon as you receive the response; deliveries rejected in the meantime come back through the retry. The write scope is required, and the endpoint must belong to your application: on another application's endpoint as well as on a workspace-managed one (oauth_application_id at null), the call responds 403 and the secret is left intact. A workspace-managed endpoint is rotated from the Scribee interface settings only - as are its update and its deletion.

curl -X POST https://app.scribee.tech/api/v1/workspaces/YOUR_WORKSPACE_ID/webhook_endpoints/3/regenerate_secret \
-H "Authorization: Bearer YOUR_ACCESS_TOKEN"

200 response: the full endpoint with the new signing_secret - shown this one time only, as at creation.

What happens next​

  • A 2xx status acknowledges the delivery; it is not redelivered.
  • Any other outcome - a non-2xx status, a redirect, a response timeout (10 seconds), a connection or TLS error - triggers a retry: 30 seconds after the first failure, then 1, 2, 5, 15, and 30 minutes, 1, 2, 6, 12, and 24 hours, then every 24 hours. Past 7 days, the delivery is abandoned.
  • Two cases are never retried and fail permanently on the first attempt: a URL refused by the anti-SSRF check, and a hostname that resolves to no address at all or resolves to a private or reserved address. A DNS outage at your host falls into the second case: the delivery is lost, not deferred. Make sure your endpoint's hostname resolves publicly before registering it.
  • After 20 consecutive failed deliveries, the endpoint is deactivated: active becomes false, readable through GET and visible in the Scribee interface's settings. The counter increments once per delivery, not once per attempt: the retries of a single delivery count only once. Reactivate the endpoint with PATCH and "active": true once your server is back up - from the Scribee interface settings if it is workspace-managed. The counter resets to zero, and events that occurred during deactivation are not replayed.
  • A success resets the failure counter to zero: only a continuous outage deactivates the endpoint.
  • If the workspace revokes your application's access, deliveries stop at once and those already queued fail permanently, with no retry. active stays true and the endpoint is not deactivated, but every call you make on that workspace responds 403: you can no longer read it. Restoring access resumes the following events; nothing is replayed.

Errors and edge cases​

422: URL refused​

An http URL, a private IP address, or an internal host at creation:

{
"error": "unprocessable_entity",
"code": "validation_failed",
"message": "La validation a échoué",
"details": {
"base": ["Url doit être une URL https valide"]
}
}

On update, the same error is indexed by field: details.url is ["doit être une URL https valide"]. Fix the URL and replay the call.

422: duplicate endpoint​

A workspace accepts only one endpoint per event-type + URL + category triple. The same URL can therefore subscribe to the same event type once per category, plus once with no category. Recreating the same triple responds 422 with the message "Url digest un endpoint webhook existe déjà pour ce type d'évènement, cette URL et cette catégorie" in details.base. Re-read the list to find the existing endpoint, vary the URL, or target another category.

422: category refused​

Any category_id the workspace cannot legitimately bind - unknown, or belonging to another workspace whether archived there or not - is refused in the 422 envelope above, at creation as at update: details.base holds ["Category doit exister"] at creation, details.category holds ["doit exister"] at update. Those three cases answer identically on purpose, so the endpoint cannot be used to guess which category identifiers exist in other workspaces, nor whether they are archived there. An archived category of your own workspace keeps its own reason instead: details.base holds ["Category est archivée"] at creation, details.category holds ["est archivée"] at update. Re-read the workspace's category list to find a valid identifier (Categories).

A category_id on a bank_account.updated, bank_operation.reconciled, bank_operation.unreconciled, bank_rule.applied, payment_batch.updated or payment_instruction.updated endpoint is refused for an entirely different reason, and carries its own message: fan-out derives no category for those events. The message names the type concerned, here bank_account.updated: details.base holds ["Category doit être omise - les notifications bank_account.updated ne sont pas rattachées à une catégorie, un endpoint lié à une catégorie n'en recevrait donc aucune"] at creation, and details.category holds the same message without its prefix at update. Here the category is not to be corrected but removed.

422: unknown includes​

A value outside the accepted list responds 422 with the message "contient des valeurs non prises en charge : ..." followed by the refused values. That list depends on the endpoint's event type: only invoice.created accepts the nine collections from step 1; the six other types accept none, so any non-empty includes there receives this refusal. The check only fires on a change of includes: an endpoint that keeps its own unchanged is never refused on this ground, and clearing them always passes. The message is indexed under details.base, prefixed with Includes, at creation, and under details.includes at update.

404 Not Found​

The endpoint does not exist, or its id belongs to a workspace different from the one indicated in the path:

{
"error": "not_found",
"message": "La ressource demandée est introuvable"
}

Check the identifier and the path's workspace_id (Your first call).

403 Forbidden: insufficient scope​

Reads on this page require read. Creation, modification, and secret rotation require write; deletion accepts destroy or write:

{
"error": "forbidden",
"message": "Vous n'êtes pas autorisé à effectuer cette action"
}

Request a new token with the scopes you need (Authentication).

403 Forbidden: another application's endpoint, or a workspace-managed one​

PATCH, DELETE, and secret rotation require, on top of the scope, that the endpoint's oauth_application_id be your application's. A workspace-managed endpoint (oauth_application_id at null) belongs to no application: those three calls respond 403 on it for every OAuth application, whichever one calls. In both cases the response is the 403 above, word for word: the body does not say who owns the endpoint. A GET on that same endpoint still responds 200 - only writes are restricted. Check the oauth_application_id returned by the list to tell this case apart from a missing scope.