Aller au contenu principal

Webhooks

Une facture fournisseur arrive par le réseau, un statut change chez votre client : votre système doit le savoir sans délai. Plutôt que d'interroger les listes en boucle, enregistrez une URL https de votre serveur : Scribee y pousse chaque événement par un POST signé, à l'instant où il survient. C'est le canal qui complète l'interrogation de la liste décrite dans Factures fournisseurs et le suivi des statuts du cycle de vie.

Ce que Scribee fait pour vous​

  • Signe chaque livraison en HMAC-SHA256 (X-Scribee-Signature) : vous authentifiez l'origine sans placer de secret dans votre URL.
  • Livre au moins une fois et réessaie automatiquement en cas d'échec, à intervalles croissants de 30 secondes à 24 heures, pendant 7 jours au maximum.
  • Fige le contenu au moment de l'événement : une livraison retentée reflète l'objet tel qu'il était à l'événement, même modifié ou supprimé depuis.
  • Joint aux payloads de facture des liens de téléchargement prêts à l'emploi vers les quatre formats (pdf, facturx, ubl, cii).
  • Cible les livraisons quand vous le voulez : un endpoint écoute tout le workspace, ou une seule catégorie de factures.
  • Désactive un endpoint après 20 livraisons consécutives en échec, pour arrêter les envois vers un serveur qui ne répond plus ; vous le réactivez par un appel.

Les huit événements​

event_typeSe déclenche quand
invoice.createdUne facture est créée dans le workspace, quelle que soit son origine - créée par l'API, arrivée par le réseau, saisie dans l'interface Scribee, issue de la conversion d'un devis - et quel que soit son sens (sales ou purchases).
invoice.lifecycle_event.createdUn événement de cycle de vie est ajouté à une facture (Cycle de vie).
bank_account.updatedL'activation d'un compte bancaire, sa configuration comptable ou la disponibilité qui en découle change (Comptes bancaires). Trois sources l'émettent : le PATCH partenaire, l'activation faite depuis l'interface Scribee, et la synchronisation d'une connexion bancaire - qui l'émet à la découverte d'un compte, à son archivage quand le fournisseur cesse de le lister, à sa restauration quand il le liste de nouveau, et quand l'accès aux données du fournisseur change.
bank_operation.reconciledUne affectation passe à confirmed sur une opération bancaire (Affectations et rapprochement). Un événement par transition, et non par appel : une convergence qui confirme trois affectations en émet trois.
bank_operation.unreconciledUne affectation confirmée est annulée (Affectations et rapprochement). Un événement par affectation retirée : un appel qui en retire trois en émet trois, et une opération qui perd sa dernière affectation émet ce même événement.
bank_rule.appliedUne règle d'imputation a déterminé la projection comptable d'une opération, à l'instant où cette projection est écrite (Règles d'imputation bancaires).
payment_batch.updatedUn lot de paiement change d'état (Lots de paiement). Un événement par changement d'état : la création d'un lot, la modification de ses instructions et l'engagement d'une remise qui laisse le lot approved n'en émettent aucun.
payment_instruction.updatedLe status publié d'une instruction de paiement change (Lots de paiement). Un événement par changement de statut publié : la création d'une instruction n'en émet aucun.

Ces huit événements existent aujourd'hui. Tout autre changement - devis, paiements, annuaire, e-reporting, connexions et synchronisations bancaires - s'observe par lecture de l'API (Conventions de l'API).

Une écriture qui ne change rien n'émet rien. bank_account.updated est émis derrière la garde qui décide déjà s'il y a écriture : rejouer un PATCH déjà appliqué ne produit aucune livraison, et ne touche même pas updated_at. La synchronisation obéit à la même règle sur son propre axe : une passe qui rafraîchit un solde, un nom ou une date sans produire l'une des transitions ci-dessus n'émet rien, faute de quoi chaque compte vous vaudrait une livraison à chaque passe.

Un endpoint s'abonne à un seul type d'événement, choisi à la création et non modifiable ensuite. Un event_type envoyé dans un PATCH est ignoré, y compris quand il est la seule clé du corps : l'appel répond alors 200 avec l'endpoint inchangé. Ne lisez pas ce 200 comme une confirmation de changement. Pour recevoir plusieurs types, créez un endpoint par type - la même URL peut servir aux sept.

Cibler une catégorie​

Par défaut, un endpoint reçoit tous les événements de son type dans le workspace. category_id restreint ce périmètre à une seule catégorie (Catégories) : l'endpoint ne reçoit plus que les événements des factures rattachées à cette catégorie. Le champ est optionnel à la création, modifiable ensuite, et vaut null par défaut - un endpoint sans category_id se comporte exactement comme avant.

category_id de l'endpointFacture sans catégorieFacture de la catégorie cibléeFacture d'une autre catégorie
nullLivréeLivréeLivrée
12Non livréeLivréeNon livrée

Une facture sans catégorie n'atteint donc que les endpoints sans category_id : aucun endpoint ciblé ne la reçoit. Une facture d'achat arrivée sans catégorie peut toutefois recevoir celle de son fournisseur (Catégories) : elle atteint alors les endpoints ciblés sur cette catégorie. Pour couvrir à la fois une catégorie et le reste du workspace, gardez un endpoint non ciblé à côté du ciblé.

Les deux événements de facture ne lisent pas la catégorie au même instant. invoice.created retient celle que porte la facture au moment de l'événement, figée avec le reste du corps. invoice.lifecycle_event.created lit la catégorie courante de la facture au moment où l'événement est réparti : rattacher une facture à une autre catégorie change donc les endpoints qui recevront ses événements de cycle de vie suivants.

Seuls les événements de facture portent une catégorie. Le ciblage ne vaut donc que pour invoice.created et invoice.lifecycle_event.created, et un category_id sur un endpoint bank_account.updated, bank_operation.reconciled, bank_operation.unreconciled, bank_rule.applied, payment_batch.updated ou payment_instruction.updated est refusé en 422, à la création comme à la modification. Le refus est délibéré : la répartition ne dérive aucune catégorie pour un compte bancaire, une opération, une règle, un lot ni une instruction de paiement, un tel abonnement serait donc créé pour ne jamais rien recevoir. Laissez category_id à null sur ces six types d'endpoint.

La catégorie doit appartenir au workspace de l'endpoint et ne pas être archivée, sinon l'appel répond 422. Dans l'autre sens, tant qu'un endpoint est rattaché à une catégorie, DELETE /api/v1/categories/{id} refuse de l'archiver : détachez ou supprimez ces endpoints d'abord (Catégories). Le refus compte tous les endpoints du workspace rattachés à cette catégorie, y compris ceux d'une autre application et ceux gérés par le workspace - or vos écritures ne portent que sur les vôtres. Un endpoint que vous ne possédez pas se détache donc depuis l'interface Scribee, ou par l'application qui l'a enregistré.

Le parcours d'une livraison​

Étape 1 : créer un endpoint​

Cet appel enregistre un abonnement ; il n'envoie rien vers l'extérieur au moment de la création. Les livraisons commencent au prochain événement correspondant dans le workspace, et un DELETE y met fin à tout moment. Aucun événement de test n'existe : pour une répétition maîtrisée, créez une facture (Émettre une facture) - sa création déclenche une livraison invoice.created réelle. Cette facture reste ensuite dans le workspace : dès qu'elle porte un numéro, y compris le numéro provisoire TEMP-... que Scribee attribue quand vous n'en fournissez pas, DELETE /api/v1/invoices/{id} répond 403. Pour un endpoint bank_account.updated, la répétition est moins engageante : un PATCH qui change au moins une valeur sur un compte bancaire produit une livraison réelle, et vous revenez à l'état de départ en renvoyant les valeurs précédentes (Comptes bancaires).

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"]
}
}'

Réponse 201, abrégée aux champs utiles ici :

{
"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"
}
}

Stockez signing_secret immédiatement : il n'apparaît que dans cette réponse et dans celle de la rotation - jamais dans les lectures. event_type et url sont obligatoires ; le scope write est requis (Authentification). L'URL doit être en https : une adresse http, une adresse IP littérale privée ou réservée, ou un nom d'hôte interne (localhost, suffixes .localhost, .local, .internal, .home.arpa) sont refusés à l'enregistrement avec un 422.

L'enregistrement ne teste pas la joignabilité de l'URL : le nom d'hôte n'est résolu qu'au moment de la livraison. Une résolution vers une adresse privée ou réservée fait échouer la livraison définitivement, sans retentative.

includes sélectionne les collections associées embarquées dans le payload invoice.created, parmi les mêmes valeurs que le paramètre include de la liste des factures : lines, payment_means, tax_subtotals, lifecycle_events, allowance_charges, item_notes, invoice_references, payments, early_payment_discounts. Sans includes, le payload porte l'en-tête de la facture et les liens de téléchargement. Aucun autre type n'admet d'include - ni invoice.lifecycle_event.created, ni bank_account.updated, ni les cinq événements bancaires décrits à l'étape 4 : leur payload est livré tel quel, et tout includes non vide y répond 422. Le refus porte sur le changement de valeur, non sur la valeur déjà stockée, parce que l'endpoint est réécrit après chaque livraison : un abonnement existant continue donc de fonctionner, se renomme, se désactive ou se repointe librement, et renvoyer ses includes actuels à l'identique passe. Seule l'acquisition d'un include est refusée, et vider includes est la façon de le remettre en règle.

category_id est également accepté ici pour créer l'endpoint déjà ciblé sur une catégorie, selon les règles de la section précédente. Omettez-le pour un endpoint qui écoute tout le workspace.

Étape 2 : recevoir et acquitter​

Chaque événement est livré, par un POST JSON, à chaque endpoint actif abonné à son type dont le category_id est null ou correspond à la catégorie de la facture :

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...

Répondez un statut 2xx pour acquitter, avant tout traitement long : votre serveur dispose de 10 secondes pour répondre (5 secondes pour établir la connexion). Tout autre statut compte comme un échec - y compris une redirection 3xx, jamais suivie.

La livraison est au moins une fois : la même livraison peut se représenter, avec le même X-Scribee-Delivery. Cet identifiant est votre clé de déduplication - un X-Scribee-Delivery déjà traité se ré-acquitte en 2xx sans retraitement. Deux livraisons d'un même événement vers deux endpoints portent des identifiants distincts. L'ordre d'arrivée n'est pas garanti (une retentative peut suivre des événements plus récents) : ordonnez sur occurred_at.

Étape 3 : vérifier la signature​

X-Scribee-Signature vaut t=<horodatage unix>,v1=<hex>, où v1 est le HMAC-SHA256 de la chaîne "<t>.<corps brut>" avec votre signing_secret comme clé. Calculez sur le corps brut de la requête, exactement tel que reçu, avant tout décodage JSON.

t horodate la tentative, pas l'événement : une retentative porte un t neuf et une signature neuve, sur un corps identique. Comparez-le à votre horloge avec la tolérance de votre choix - 300 secondes dans l'exemple ci-dessous - pour bloquer le rejeu d'une requête capturée.

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

Rejetez toute signature invalide par un statut non-2xx : la retentative resservira la même livraison plus tard, ce qui couvre le délai entre une rotation du secret et la mise à jour de votre vérificateur.

Étape 4 : lire le payload​

L'enveloppe est identique pour les huit événements : id (l'identifiant de livraison, la valeur de X-Scribee-Delivery), type, occurred_at (la date de l'événement lui-même), workspace_id et data.

occurred_at de l'enveloppe est en UTC, suffixé Z. Toutes les dates situées à l'intérieur de data - created_at, updated_at, l'occurred_at de l'objet event - sont en ISO 8601 avec le décalage du fuseau Europe/Paris (+01:00 ou +02:00). Les deux notations désignent le même instant.

Pour invoice.created, data est la facture telle que la retourne l'API, complétée des collections demandées dans includes et d'un objet download (extrait ci-dessous, la facture porte l'ensemble des champs du 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 est sérialisé une seule fois, à l'intérieur de la transaction qui achève le traitement de la facture - donc atomiquement avec son état final, une fois l'ensemble du traitement d'import terminé. Ce corps figé est celui que reçoivent tous les endpoints, toutes les livraisons et toutes les retentatives de l'événement ; le filtrage par includes propre à chaque endpoint s'applique à ces mêmes octets. Il décrit la facture telle que GET /api/v1/invoices/{id} la renvoyait à cet instant : upload_source porte le canal d'arrivée réel (api pour un dépôt par l'API), lifecycle_state, lifecycle_status_code et lifecycle_available_transitions portent l'état réellement atteint - y compris celui que vous avez demandé au dépôt - les parties rapprochées sont celles retenues, et lifecycle_events (si vous l'avez demandé dans includes) contient les évènements déjà écrits. Les états ultérieurs vous parviennent par invoice.lifecycle_event.created, et l'état courant se relit par GET /api/v1/invoices/{id}.

Le corps n'est jamais resérialisé : toute livraison de l'événement - vers un second endpoint, ou après retentative - renvoie exactement les mêmes octets. Trois conséquences. occurred_at et le corps sont temporellement cohérents : le corps décrit la facture telle qu'elle était à l'instant de l'événement, donc ordonner vos traitements sur occurred_at reste juste même quand une livraison arrive en retard ou après retentative. Une modification postérieure à l'événement - changement de numéro, transition de cycle de vie - ne change rien à ce que livre un webhook déjà émis : vous recevez l'état à l'instant de l'événement, jamais un mélange. Et une facture supprimée depuis est quand même livrée, depuis ce même instantané figé : le corps décrit ce qu'elle était une fois le traitement d'import terminé - vous êtes informé de son existence et de son état à cet instant, pas de ce qu'elle est devenue depuis.

download porte, pour chacun des quatre formats, l'URL absolue de téléchargement de la facture - le format y est une extension de chemin, équivalente au paramètre format documenté dans Formats et téléchargements. Ces URL s'appellent avec votre token Bearer habituel et le scope read : rien à construire côté intégration.

Pour invoice.lifecycle_event.created, data référence la facture et détaille l'événement. L'exemple ci-dessous montre un événement reçu du réseau (status_code 205), d'où les champs sender_* renseignés :

{
"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",
"...": "..."
}
}
}

L'objet event est complet ci-dessus : il porte ces quatorze champs et pas d'autres. state est la valeur machine, stable quelle que soit la langue : c'est celle sur laquelle brancher votre logique. status_label est un libellé d'affichage - français pour un événement produit dans Scribee, mais repris verbatim du message de statut entrant quand le réseau en fournit un, donc dans la langue et la formulation de l'émetteur. Les trois champs sender_* ne décrivent pas tous la même partie. Pour un message de statut reçu du réseau, sender_role reprend le rôle de la partie émettrice du message, tandis que sender_id et sender_scheme_id identifient la plateforme destinataire retenue (la partie destinataire de rôle WK) - pas l'émetteur. Les trois valent null pour un événement produit dans Scribee. rejected à true marque un événement d'audit enregistré alors que le message de statut entrant a été refusé ; applied à false marque un événement qui n'a pas fait transitionner la facture.

Un même événement peut être livré deux fois. Lorsque Scribee réapplique des statuts reçus et précédemment inscrits sans effet, chaque statut qui fait alors transitionner la facture émet une nouvelle livraison invoice.lifecycle_event.created pour le même événement : même event.id, applied désormais à true, mais un id de livraison distinct. La première livraison portait applied à false. Le passage ne va que dans ce sens : pour un même event.id, la livraison applied à true remplace celle à false, jamais l'inverse. occurred_at étant la date de l'événement, il est identique sur les deux livraisons et ne les départage pas. Relisez GET /api/v1/invoices/{id} pour l'état atteint par la facture.

Une réouverture n'est annoncée par aucune livraison. Une facture d'achat refusée (210) dont le refus n'a jamais quitté Scribee peut être rouverte depuis l'interface et repasser à available : aucun évènement de cycle de vie n'est créé, donc aucun invoice.lifecycle_event.created n'est émis, et la livraison du 210 reste celle qui portait terminal à true. Voir Rouvrir une facture d'achat refusée.

Le contenu de data est figé au moment de l'événement : une livraison retentée trois jours plus tard reflète la facture d'alors, même si elle a été modifiée ou supprimée depuis. Les URL de download, elles, pointent vers la facture vivante : elles servent son contenu courant et répondent 404 si elle a été supprimée. Pour l'état courant des champs, relisez GET /api/v1/invoices/{id}.

Pour bank_account.updated, data est le compte bancaire, réduit à douze clés et pas une de plus :

{
"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"
}
}

Ce corps est plus étroit que celui du GET, délibérément. Sept champs que la lecture publie n'y sont pas : active, archived, ledger_id, suspense_account_code, balance, last_synced_at et created_at. Ne les attendez pas dans une livraison - un abonné qui en a besoin lit le compte par GET /api/v1/bank_accounts/{id} (Comptes bancaires). Les douze clés ci-dessus portent en revanche exactement la même valeur et le même rendu que dans cette lecture, readiness comprise, dérivée au moment de l'événement.

iban_masked et iban_last4 y suivent la règle de masquage du GET, seuil des huit caractères compris : l'IBAN complet n'est publié sur aucune surface, webhook inclus.

Aucun objet download n'accompagne ce payload : un compte bancaire n'est pas un document. L'apparition d'un compte vous parvient par ce même événement - la synchronisation d'une connexion l'émet lorsqu'elle découvre un compte, et c'est ainsi qu'un abonné apprend qu'il en existe un nouveau, sans avoir à relire la liste. Seul un compte créé par l'import d'un relevé n'est annoncé par aucune livraison.

Pour bank_operation.reconciled, data porte l'opération telle que la confirmation l'a laissée : le jeu complet des affectations confirmées et les lignes de l'écriture qui en résulte.

L'événement se déclenche par transition, pas par appel. Il est émis là où une affectation passe à confirmed, donc une convergence qui en confirme trois émet trois livraisons, chacune portant le jeu d'affectations tel qu'il était à sa propre transition - et une confirmation faite depuis l'interface Scribee l'émet au même titre qu'un appel d'API. Un consommateur bâti sur l'hypothèse d'une livraison par appel se trompera. Comme partout ailleurs sur cette page, la livraison est au moins une fois et son ordre n'est pas garanti : dédupliquez sur l'id de l'enveloppe et relisez la ressource pour l'état courant (Opérations bancaires).

{
"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" }
}
]
}
}
}

Huit clés, et pas une de plus : id, company_id, amount, direction, reconciliation_status, version, allocations et accounting_entry. Les autres champs de l'opération - dates, libellé, origin, projection, reconciliation - ne sont pas dans ce corps ; relisez GET /api/v1/bank_operations/{id} si vous en avez besoin.

amount est une chaîne, allocated_amount et score sont des nombres, et ce n'est pas une incohérence. La règle est la même partout : un champ d'événement qui publie une colonne porte exactement le type et le rendu que /api/v1 publie pour cette même colonne, de sorte qu'un webhook et un GET ne puissent jamais donner deux valeurs pour une seule ligne. amount suit donc Opérations bancaires, où la colonne est publiée comme une chaîne à quatre décimales parce qu'un flottant double précision ne peut pas porter les dix-neuf chiffres significatifs qu'elle accepte ; allocated_amount et score suivent Affectations et rapprochement, où ce sont des nombres JSON à quatre décimales.

allocations est le jeu complet des affectations confirmées, jamais un delta, par identifiant croissant. Leur status vaut donc toujours confirmed : une proposition que personne n'a confirmée et une affectation annulée n'y figurent pas. Une opération de 300 ventilée 200/100 se lit ainsi comme deux affectations et trois lignes équilibrées, et non comme une ligne modifiée. Si vous voulez la variation entre deux événements, calculez-la ; le corps décrit un état.

accounting_entry vaut null quand l'opération ne porte pas encore d'écriture - le compte bancaire n'était pas en conditions de comptabiliser, ou la projection n'a pas encore tourné. C'est un état atteignable, pas une anomalie. Quand elle est présente, l'écriture est réduite à id, entry_kind, exported et lines ; le reste se lit par la ressource (Écritures comptables bancaires).

Une ligne d'écriture n'a pas d'identifiant ici, délibérément : les lignes sont réécrites intégralement à chaque reprojection, donc un identifiant que vous auriez stocké désignerait une ligne qui n'existe plus. Elle porte account_number, label, debit_amount, credit_amount et explanation - cette dernière est propre à l'événement, là où la lecture de l'écriture publie tax_code en cinquième clé. Les deux montants sont positifs ou nuls, exactement un des deux côtés est non nul, et ils sont publiés comme des nombres à deux décimales. explanation.source puise dans le vocabulaire habituel - allocation, company_rule, tenant_rule, suspense - et son reason est du texte destiné à un humain, à ne pas analyser. Ce texte est rédigé en anglais quelle que soit la langue de la requête à l'origine de l'événement, et c'est celui que porte l'explanation de la même ligne sur l'écriture. Un événement émis avant que Scribee ne fixe cette langue peut porter, pour une ligne décidée par une règle, une phrase en français : les événements déjà émis ne sont pas réécrits, et une nouvelle livraison reprend leur corps d'origine.

Pour bank_operation.unreconciled, data dit quelle affectation a été retirée et lesquelles survivent.

Cet événement se déclenche par affectation, pas par opération. Un appel qui retire trois affectations émet trois livraisons, chacune nommant une affectation dans removed_allocation_ids et les survivantes dans allocations. Une opération qui perd sa dernière affectation émet ce même événement, avec une liste de survivantes vide - ce n'est pas un événement différent, et il n'en existe pas d'autre pour annoncer qu'une opération est redevenue entièrement non rapprochée.

{
"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" }
]
}
}

Sept clés : id, company_id, reconciliation_status, version, unreconciled_at, removed_allocation_ids et allocations. id est celui de l'opération ; l'affectation retirée est nommée dans removed_allocation_ids. unreconciled_at est l'instant où cette affectation a été annulée, au même rendu que son updated_at sur la ressource d'affectation.

Trois clés du corps de confirmation sont absentes ici - amount, direction et accounting_entry - et le resteront. Une annulation ne dit rien du montant ni du sens de l'opération, et l'écriture reprojetée se relit par Écritures comptables bancaires. Pour l'opération complète, relisez GET /api/v1/bank_operations/{id}.

removed_allocation_ids est un tableau qui ne porte aujourd'hui qu'un seul identifiant. C'est la conséquence de la granularité ci-dessus, pas une forme en attente d'être remplie : traitez-le comme un tableau et vous n'aurez rien à changer si un retrait multiple en une transition apparaît un jour. allocations porte les affectations survivantes, par identifiant croissant et au même rendu que sur l'événement de confirmation.

Pour bank_rule.applied, data décrit la règle, et non l'opération : id est l'identifiant de la règle, et l'opération dont elle a déterminé l'imputation voyage sous bank_operation_id.

L'événement est émis là où une projection est écrite, et seulement si la règle y a réellement produit une ligne. Une simulation (POST /api/v1/bank_rules/{id}/preview) n'émet rien - elle n'écrit rien. Une reprojection qui ne change rien n'émet rien non plus, et une opération dont les affectations confirmées consomment tout le mouvement ne laisse aucun reliquat à imputer, donc aucune règle à annoncer (Règles d'imputation bancaires).

{
"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"
}
}
}

Huit clés : id, company_id, scope, name, priority, bank_operation_id, account_number et explanation. company_id est celui de la règle : il est nul exactement quand scope vaut tenant, comme sur la ressource.

account_number est au premier niveau ici, alors que la lecture d'une règle le niche sous action. C'est la seule différence de forme entre les deux, et la valeur est la même.

Les conditions de la règle ne sont pas dans ce corps, ni sa fenêtre de validité, ni son activation, ni ses exclusions : un événement annonce l'imputation qui a été faite, pas la configuration qui l'a produite. Relisez GET /api/v1/bank_rules/{id} pour la règle complète. explanation reprend l'objet publié partout ailleurs sur les règles, et son reason est du texte destiné à un humain, à ne pas analyser. Il est rédigé en anglais quelle que soit la langue de la requête à l'origine de l'imputation : c'est la phrase que porte l'explanation de la ligne d'écriture que la règle a décidée. Un événement émis avant que Scribee ne fixe cette langue peut porter une phrase en français : les événements déjà émis ne sont pas réécrits, et une nouvelle livraison reprend leur corps d'origine.

Pour payment_batch.updated, data est le lot de paiement tel que la transition l'a laissé, accompagné de l'état qu'il vient de quitter.

L'événement se déclenche par changement d'état, et seulement là. Dix transitions l'émettent :

previous_statestateCe qui s'est passé
draftpending_approvalLe lot a été soumis à approbation
pending_approvalapprovedLe lot a été approuvé
pending_approvaldraftL'approbation a été refusée ; le lot redevient modifiable
approvedsubmittedLe canal a pris le lot
approvedfailedLa remise a été refusée, par le prestataire ou par Scribee avant tout envoi ; cet état est définitif
submittedcompletedChaque instruction du lot est settled
submittedpartially_completedCertaines instructions du lot sont settled et la banque a refusé les autres
submittedrejectedLa banque a refusé chaque instruction du lot ; cet état est définitif
completedsubmittedUn règlement confirmé a été annulé sur une instruction dont la banque n'a pas signalé l'exécution
partially_completedsubmittedUn règlement confirmé a été annulé sur une instruction dont la banque n'a pas signalé l'exécution

state prend donc l'une des huit valeurs décrites dans Lots de paiement, et previous_state l'une des six qui ne sont pas définitives : draft, pending_approval, approved, submitted, completed ou partially_completed. Ces transitions sont faites depuis l'interface Scribee ou par l'API - soumission à approbation, approbation, refus, remise, confirmation ou annulation du règlement d'une instruction (Lots de paiement) -, par le traitement de la remise, ou à réception de la réponse de la banque sur les instructions du lot. Un lot ne passe à completed, partially_completed ou rejected que lorsque chacune de ses instructions a un statut définitif, et ces trois états n'enregistrent pas le paiement d'une facture. Rien d'autre n'émet cet événement - ni la création d'un lot, qui naît draft, ni la modification de ses instructions, ni l'engagement d'une remise, qui renseigne submitted_at mais laisse le lot approved : c'est le passage à submitted ou à failed qui vous en donne l'issue. Une approbation rejouée sur un lot déjà approuvé n'émet rien non plus, et deux traitements qui tentent la même transition n'en produisent qu'un événement.

{
"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"
}
}

Huit clés, et pas une de plus : id, company_id, state, previous_state, total_amount, currency_code, instructions_count et updated_at. id est celui du lot. Les sept autres que previous_state portent exactement la même valeur et le même rendu que GET /api/v1/payment_batches/{id} à l'instant de la transition - total_amount compris, un nombre JSON arrondi à deux décimales. updated_at est l'instant de la transition, le même que l'occurred_at de l'enveloppe dans l'autre notation.

Ce corps est plus étroit que celui du GET, délibérément. bank_account_id, name, channel, version, execution_date, approved_at, submitted_at, progress, error_code, error_message, retryable, started_at, finished_at et created_at n'y sont pas. Un lot passé à failed ne dit donc pas ici pourquoi ni qui l'a refusé : relisez GET /api/v1/payment_batches/{id} pour son error_message, son submission_refused_by et l'état courant (Lots de paiement). Un lot passé à partially_completed ne dit pas non plus quelles instructions la banque a exécutées : le status de chacune se lit sur GET /api/v1/payment_batches/{id}/instructions.

Pour payment_instruction.updated, data est l'instruction de paiement telle que le changement de statut l'a laissée, accompagnée du statut qu'elle vient de quitter.

L'événement se déclenche quand le status publié d'une instruction change, et seulement là. Il est émis une fois par changement, une fois ce changement enregistré. Six sources le produisent : l'engagement de la remise de son lot, qui la passe de draft à submitted, le refus de cette remise, qui la passe de submitted à submission_failed quand elle ne porte encore aucun statut de la banque, ou de submitted à rejected quand la réponse du prestataire qui refuse la remise la signale elle-même comme refusée, un statut communiqué par la banque sur l'instruction, la confirmation de son règlement, qui la passe à settled, l'annulation de ce règlement, qui la ramène de settled à pending, et la déclaration de sa non-exécution, qui la passe de pending à rejected (Lots de paiement). Une déclaration renvoyée à l'identique ne change rien et n'émet rien.

Rien d'autre ne l'émet :

  • la création d'une instruction, qui naît draft, ni le remplacement des lignes d'un brouillon ;
  • un appel de remise refusé sans engager de remise, ou rejoué : seul l'appel qui engage la remise - POST /api/v1/payment_batches/{id}/submit ou POST /api/v1/payment_batches/{id}/sepa_export - émet un événement par instruction encore draft, et une remise engagée ne se désengage jamais, si bien qu'aucune instruction ne revient à draft ;
  • une remise dont l'issue n'est pas connue - consentement en attente, fichier SEPA en cours de génération, réponse du canal perdue : l'instruction reste submitted. Le refus d'une remise n'émet qu'une fois par instruction, même lu plusieurs fois ;
  • un compte rendu de la banque qui laisse le status publié inchangé : deux étapes que la banque distingue mais que l'API publie toutes deux pending, ou un compte rendu reçu une seconde fois.
{
"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"
}
}

Onze clés, et pas une de plus : id, company_id, payment_batch_id, status, previous_status, amount, currency_code, reference, end_to_end_id, beneficiary_iban_masked et beneficiary_iban_last4. id est celui de l'instruction. Les dix autres que previous_status portent exactement la même valeur et le même rendu que GET /api/v1/payment_instructions/{id} à l'instant du changement - amount compris, un nombre JSON arrondi à deux décimales. status et previous_status prennent les valeurs que cette lecture publie : draft, submitted, submission_failed, pending, settled ou rejected. Le corps ne porte pas le motif d'un refus.

L'IBAN complet du bénéficiaire n'y figure jamais : seulement beneficiary_iban_masked et beneficiary_iban_last4, comme dans la lecture. invoice_document_id, beneficiary_name, execution_date, created_at et updated_at n'y sont pas non plus : relisez GET /api/v1/payment_instructions/{id} pour les connaître.

Aucun de ces cinq événements n'admet d'include ni de catégorie. Leur corps est livré tel quel - il n'y a rien à restreindre - donc tout includes non vide est refusé en 422, et un category_id l'est aussi : ni une opération, ni une règle, ni un lot ou une instruction de paiement ne porte de catégorie, et un endpoint ciblé ne recevrait donc jamais rien.

Gérer vos endpoints​

La liste est paginée selon les conventions et ne contient jamais signing_secret ; le scope read suffit. Elle n'accepte ni tri ni filtre. Notez le chemin : contrairement aux factures, la lecture, la modification et la suppression d'un endpoint restent sous /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 }
}

La liste couvre le workspace entier, pas votre seule application : elle renvoie aussi les endpoints créés par les autres applications partenaires et ceux créés dans l'interface Scribee, URL comprises. oauth_application_id porte l'identifiant de l'application propriétaire, null pour un endpoint créé dans l'interface - un endpoint géré par le workspace.

Les écritures, elles, sont limitées à ce que vous possédez. PATCH, DELETE et la rotation du secret n'acceptent que les endpoints dont l'oauth_application_id est celui de votre application. Un endpoint géré par le workspace (oauth_application_id à null) n'appartient à aucune application et se gère uniquement depuis l'interface Scribee : modification, suppression et rotation du secret y répondent 403 pour toute application OAuth, quelle que soit celle qui appelle. Sur l'endpoint d'une autre application, ces appels répondent 403 sans rien modifier. Vous n'avez donc pas à filtrer sur oauth_application_id avant d'écrire : aucune application ne peut réécrire, supprimer ni faire tourner le secret d'un endpoint qu'une autre a enregistré, ni d'un endpoint géré par le workspace.

PATCH modifie url, name, description, includes, category_id et active (scope write). Toutes les lectures - GET unitaire, liste, création, rotation du secret - renvoient category_id, null pour un endpoint non ciblé.

category_id se change à tout moment sur un endpoint de facture : une autre catégorie du workspace déplace le ciblage, null le retire et rouvre l'endpoint aux événements de tout le workspace. Sur un endpoint bank_account.updated, seul null est accepté.

active à false n'est pas une mise en pause : les événements survenus pendant l'inactivité ne sont pas rejoués, et les livraisons déjà en file pour cet endpoint échouent définitivement à leur prochaine tentative - la réactivation ne les ramène pas. Repasser active à true remet le compteur d'échecs consécutifs à zéro et ouvre les événements suivants.

Changer url ne redirige pas les livraisons déjà en file : chacune conserve l'URL de destination enregistrée à sa mise en file et continue de viser l'ancienne adresse jusqu'à épuisement de sa fenêtre de 7 jours. La nouvelle URL ne sert qu'aux événements postérieurs à la modification.

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 sur le même chemin supprime l'endpoint définitivement - réponse 204, corps vide. Le scope write est requis.

Faire tourner le secret​

Cet appel remplace le secret de signature immédiatement et définitivement : dès la réponse, toute livraison - y compris les retentatives de livraisons déjà en file - est signée avec le nouveau secret, et l'ancien ne vérifie plus rien. Mettez votre vérificateur à jour dès réception ; les livraisons rejetées entre-temps reviennent par la retentative. Le scope write est requis, et l'endpoint doit appartenir à votre application : sur celui d'une autre application comme sur un endpoint géré par le workspace (oauth_application_id à null), l'appel répond 403 et le secret reste intact. Un endpoint géré par le workspace ne se tourne que depuis les réglages de l'interface Scribee - comme sa modification et sa suppression.

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

Réponse 200 : l'endpoint complet avec le nouveau signing_secret - montré cette seule fois, comme à la création.

Ce qui se passe ensuite​

  • Un statut 2xx acquitte la livraison ; elle ne se représente plus.
  • Toute autre issue - statut hors 2xx, redirection, délai de réponse dépassé (10 secondes), erreur de connexion ou de TLS - déclenche une retentative : 30 secondes après le premier échec, puis 1, 2, 5, 15 et 30 minutes, 1, 2, 6, 12 et 24 heures, puis toutes les 24 heures. Passé 7 jours, la livraison est abandonnée.
  • Deux cas ne sont jamais retentés et échouent définitivement dès la première tentative : une URL refusée par le contrôle anti-SSRF, et un nom d'hôte qui ne résout vers aucune adresse ou qui résout vers une adresse privée ou réservée. Une panne DNS chez votre hébergeur tombe dans le second cas : la livraison est perdue, pas différée. Vérifiez que le nom d'hôte de votre endpoint résout publiquement avant de l'enregistrer.
  • Après 20 livraisons consécutives en échec, l'endpoint est désactivé : active passe à false, lisible par GET et visible dans les réglages de l'interface Scribee. Le compteur s'incrémente une fois par livraison, pas une fois par tentative : les retentatives d'une même livraison ne comptent que pour une. Réactivez l'endpoint par PATCH avec "active": true une fois votre serveur rétabli - depuis les réglages de l'interface Scribee s'il est géré par le workspace. Le compteur repart à zéro, et les événements survenus pendant la désactivation ne sont pas rejoués.
  • Un succès remet le compteur d'échecs à zéro : seule une panne continue désactive l'endpoint.
  • Si le workspace retire l'accès à votre application, les livraisons s'arrêtent immédiatement et celles déjà en file échouent définitivement, sans retentative. active reste à true et l'endpoint n'est pas désactivé, mais tous vos appels sur ce workspace répondent 403 : vous ne pouvez plus le lire. Rétablir l'accès reprend les événements suivants ; rien n'est rejoué.

Erreurs et cas limites​

422 : URL refusée​

Une URL http, une adresse IP privée ou un hôte interne à la création :

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

En modification, la même erreur est indexée par champ : details.url vaut ["doit être une URL https valide"]. Corrigez l'URL et rejouez l'appel.

422 : endpoint en double​

Un workspace n'accepte qu'un endpoint par triplet type d'événement + URL + catégorie. La même URL peut donc s'abonner au même type d'événement une fois par catégorie, plus une fois sans catégorie. Recréer le même triplet répond 422 avec le message "Url digest un endpoint webhook existe déjà pour ce type d'évènement, cette URL et cette catégorie" dans details.base. Relisez la liste pour retrouver l'endpoint existant, variez l'URL, ou ciblez une autre catégorie.

422 : catégorie refusée​

Tout category_id que le workspace ne peut pas légitimement rattacher - inconnu, ou appartenant à un autre workspace qu'il y soit archivé ou non - est refusé dans l'enveloppe 422 ci-dessus, à la création comme à la modification : details.base vaut ["Category doit exister"] à la création, details.category vaut ["doit exister"] en modification. Ces trois cas répondent de façon volontairement indiscernable, pour que l'endpoint ne permette pas de deviner quels identifiants de catégorie existent dans d'autres workspaces, ni s'ils y sont archivés. Une catégorie archivée de votre propre workspace conserve en revanche son motif : details.base vaut ["Category est archivée"] à la création, details.category vaut ["est archivée"] en modification. Relisez la liste des catégories du workspace pour retrouver un identifiant valide (Catégories).

Un category_id sur un endpoint bank_account.updated, bank_operation.reconciled, bank_operation.unreconciled, bank_rule.applied, payment_batch.updated ou payment_instruction.updated est refusé pour une tout autre raison, et porte son propre motif : la répartition ne dérive aucune catégorie pour ces événements. Le motif nomme le type concerné, ici bank_account.updated : details.base vaut ["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"] à la création, et details.category porte le même motif sans son préfixe en modification. Ici la catégorie n'est pas à corriger mais à retirer.

422 : includes inconnus​

Une valeur hors de la liste admise répond 422 avec le message "contient des valeurs non prises en charge : ..." suivi des valeurs refusées. Cette liste dépend du type d'événement de l'endpoint : seul invoice.created accepte les neuf collections de l'étape 1 ; les six autres types n'en acceptent aucune, si bien que tout includes non vide y reçoit ce refus. Le contrôle ne se déclenche qu'à un changement d'includes : un endpoint qui conserve les siens à l'identique n'est jamais refusé sur ce motif, et les vider passe toujours. Le motif est indexé sous details.base, préfixé de Includes, à la création, et sous details.includes en modification.

404 Not Found​

L'endpoint n'existe pas, ou son id appartient à un workspace différent de celui indiqué dans le chemin :

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

Vérifiez l'identifiant et le workspace_id du chemin (Votre premier appel).

403 Forbidden : scope insuffisant​

Les lectures de cette page exigent read. La création, la modification et la rotation du secret exigent write ; la suppression accepte destroy ou write :

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

Redemandez un token avec les scopes voulus (Authentification).

403 Forbidden : endpoint d'une autre application ou géré par le workspace​

PATCH, DELETE et la rotation du secret exigent, en plus du scope, que l'oauth_application_id de l'endpoint soit celui de votre application. Un endpoint géré par le workspace (oauth_application_id à null) n'appartient à aucune application : ces trois appels y répondent 403 pour toute application OAuth, quelle que soit celle qui appelle. Dans les deux cas, la réponse est le 403 ci-dessus, mot pour mot : le corps ne dit pas à qui appartient l'endpoint. Le GET sur ce même endpoint continue de répondre 200 - seules les écritures sont restreintes. Vérifiez l'oauth_application_id renvoyé par la liste pour distinguer ce cas d'un scope manquant.

Pages liées​