Événements
Un fait n'est pas une action
| question | qui décide | gardes, sélection, submit | Convoy garantit | |
|---|---|---|---|---|
| un événement | « qu'est-ce qui s'est passé ? » | personne : c'est arrivé | non : un fait ne se refuse pas | qu'il est livré : au moins une fois, signé, avec un identifiant pour dédoublonner |
| une action | « qu'est-ce qu'on change, et dans quel outil ? » | une personne, ou un agent autorisé par une règle | oui : proposée → réservée → exécutée | qu'elle est permise, puis exécutée une seule fois |
Prévenir le système du tenant n'est pas une action : Convoy ne change rien chez lui, il l'informe, et le tenant décide quoi en faire.
La règle de tri :
- Le tenant a seulement besoin de savoir (son CRM, son tableau de bord) : c'est une livraison outbound.
- Quelque chose doit être fait dans un outil (créer l'avoir dans l'ERP, écrire au client) : c'est une action. Elle passe par une skill, ses politiques, une réservation et un submit, même quand c'est un agent qui soumet.
Trois sources, deux consommateurs
| événement | d'où il vient | où on le déclare |
|---|---|---|
| entrant | le webhook d'un outil, ou une autre station | events.inbound |
| interne | Core | nulle part : un catalogue fixe, qu'on cite par son nom |
| émis | la station, à partir d'un événement interne ou entrant | events.emits |
| consommateur | qui | ce qu'il fait |
|---|---|---|
| automatisation | la station | start un playbook, remind une personne, notify par un canal Convoy. Jamais une écriture dans un outil, jamais un submit. |
| outbound | un tiers | livre le fait à une URL. Pas de condition métier, pas de primitive Convoy. |
Une automatisation décide de faire quelque chose avec les primitives de Convoy. Une livraison
outbound ne décide rien : elle transporte. Et notify passe par les canaux de Convoy — la boîte
d'un rôle, un portail de l'agent, le client de la room. Tout ce qui passe par un outil du tenant est
une action.
Les automatisations ont leur propre bloc : elles agissent, alors que events ne fait que déclarer et
transporter.
Les événements internes
Fixés par Core, les mêmes pour toutes les stations. On ne les déclare pas.
| famille | événements | sujet (of) |
|---|---|---|
| room | room.opened, room.closed | — |
| action | action.proposed, action.pending, action.reserved, action.refused, action.removed, action.executed, action.held, action.failed | la skill de l'action |
| playbook | playbook.opened, playbook.finalised, playbook.abandoned, playbook.superseded, task.opened, task.done | un playbook |
| submit | submit.started, submit.completed | un playbook |
| politique | policy.evaluated (seulement quand elle restreint), policy.disputed | une politique |
| budget | budget.reached | un budget |
| personnes | approval.requested, approval.granted, approval.denied, approval.expired | une boîte |
| événements | automation.fired, event.received, event.rejected, delivery.failed | une automatisation, un événement, un outbound |
of : le sujet
Chaque famille n'a qu'un genre de sujet. Une seule clé suffit donc, of, quelle que soit la famille :
on: playbook.finalised # tous les playbooks
on: {event: playbook.finalised, of: ticket-refund} # un seul
on: {event: action.reserved, of: refund} # les actions de la skill refund
on: {event: action.pending, of: refund, older_than: 2h} # toujours en attente 2 h après
on: {event: policy.evaluated, of: fraude}
Le chargement vérifie que of existe dans sa famille : un playbook pour playbook.*, une skill pour
action.*.
Le bloc events
events:
inbound: # les faits REÇUS
- name: zendesk.ticket.created
from: {webhook: /hooks/zendesk, verify: {hmac_sha256: {header: X-Zendesk-Signature, secret_env: ZD_SECRET}}}
id: "$payload.ticket.id" # un webhook rejoué est ignoré
fields:
ticket_id: {from: ticket.id, type: id}
tags: {from: ticket.tags, type: text}
requester: {from: ticket.requester.email, type: text, class: personal}
room: # la room de ce ticket, ouverte si absente
key: "$event.ticket_id"
owner: {role: support} # l'employé du support qui le prend
concerns: "$event.requester" # de qui il s'agit : prévenu, jamais propriétaire
emits: # les faits PUBLIÉS : un contrat stable, des champs choisis
- name: refund.completed
on: {event: playbook.finalised, of: ticket-refund}
fields:
ticket_id: "$room.id"
amount: {value: "$submitted[geste.pay].amount", type: price}
refund_id: "$submitted[geste.pay].id"
# pas l'e-mail du client : personne n'en a besoin en aval
outbound: # la LIVRAISON des faits publiés
- name: crm
on: [refund.completed]
to: https://crm.example.fr/hooks/convoy
audience: external
secret_env: CRM_HOOK_SECRET # chaque livraison est signée
retry: {max: 10, backoff: exponential}
from et to se répondent. on a le même sens partout — dans un emit, un outbound, une
automatisation : sur ces événements. Un emit n'est pas attaché à un playbook : il peut partir d'un
fait entrant (la cuisine dit « livrée » → order.delivered).
Recevoir : from, verify, id, fields
| clé | sens |
|---|---|
from | un webhook, ou une station : {station: ticket-refund, event: refund.completed} |
verify | la signature. Un message qui ne se vérifie pas est event.rejected, jamais lu. |
id | l'identifiant qui dédoublonne |
fields | typés et classés, comme ceux d'une skill. Un champ non déclaré n'est jamais lu. |
room | la room à laquelle le fait se rattache, et son propriétaire si elle est nouvelle |
À quelle room se rattache un fait reçu : room
Un webhook arrive au niveau de la station ; room dit pour qui. Le fait s'écrit alors dans le
journal de cette room, les automatisations connaissent $room et son reçu, et notify: customer
sait à qui parler.
| écrit | primitive | exemple |
|---|---|---|
room: {key: "$event.ticket_id", owner: {role: support}} | la room, par une clé qu'on lui donne ; ouverte si absente, avec son propriétaire | un ticket Zendesk : une room par ticket |
room: {returned_by: [take-meal-order.order, group-lunch.order], id: "$event.order_id"} | la room où l'un de ces appels a renvoyé cet identifiant (dans son output) : <playbook>.<appel> pour un appel du playbook, <skill>.<appel> pour celui d'une skill | la cuisine dit « prête » ; un remboursement échoue tard |
| (absent) | la station seule | un contrat signé dans Lucca : c'est l'automatisation start … for: qui ouvre la room |
key sert quand l'objet de l'outil ouvre la room (un ticket). returned_by sert à la suite
d'une action déjà exécutée : l'objet créé au submit continue de vivre dans l'outil — une commande
préparée puis livrée, un remboursement qui échoue, une société immatriculée, un ordinateur expédié,
une enveloppe signée — et le fait revient vers la room d'où l'objet est parti. Comme elle nomme des
appels, un id: 42 chez Stripe n'est jamais pris pour l'id: 42 chez Zendesk.
Une room ouverte par un événement a un propriétaire, toujours un user (voir
Human In The Loop). La personne dont il s'agit est concernée
(concerns) : on peut la prévenir, elle n'agit sur rien.
Un fait qui ne correspond à aucune action est journalisé au niveau
de la station ; les automatisations se déclenchent quand même, avec un $room vide.
Les noms
- Dans une station, un nom désigne un seul événement, entrant ou émis. Une station ne reçoit jamais son propre fait émis.
- Les préfixes internes sont réservés :
room.,action.,playbook.,task.,submit.,policy.,budget.,approval.,automation.,event.,delivery.. - Entre deux stations, le même nom désigne volontairement le même fait. La station qui reçoit ne le redéclare pas : elle cite le contrat, et le nom et les champs en viennent.
events: # la station Comptabilité
inbound:
- from: {station: ticket-refund, event: refund.completed}
Une convention aide à lire : un fait entrant d'un outil porte le nom de l'outil
(zendesk.ticket.created), un fait publié porte un nom métier (refund.completed).
Ce qui sort
- Seuls les faits émis sortent de la station. Les événements internes servent aux automatisations, au journal et à la console. Un fait émis est un contrat stable, nommé par vous : le journal peut évoluer sans casser le système de quelqu'un.
- Un fait émis obéit aux classes de données. Un champ
confidentialne part pas vers un outboundexternal. C'est une politique comme les autres : elle apparaît dans le registre, sous Sortie.
Si le CRM ne répond pas, Convoy réessaie, puis la livraison passe en delivery.failed dans la
console. Rien n'est annulé : le remboursement a eu lieu, c'est un fait.
De bout en bout
Process — un ticket devient un remboursement, puis la compta et le CRM suivent
| # | ce qui arrive | nature | qui porte l'effet |
|---|---|---|---|
| 1 | Zendesk envoie le webhook, signé | événement entrant | — |
| 2 | ouvrir-sur-ticket : le tag billing est là | automatisation : start | Convoy ouvre le playbook |
| 3 | lectures, remboursement de 120 €, approbation du manager dans Teams, réponse au client | actions | le manager soumet : createRefund, puis sendReply |
| 4 | playbook.finalised | événement interne | — |
| 5 | refund.completed, trois champs choisis | événement émis | — |
| 6 | le CRM reçoit le fait et l'ajoute à sa timeline | outbound : livraison | le CRM |
| 7 | la station Comptabilité reçoit le fait et ouvre rapprocher-remboursement | événement entrant, puis automatisation | la Comptabilité, sous ses propres règles |
« Après le remboursement, créer l'avoir dans l'ERP » n'est pas une livraison : c'est un
changement dans un outil. Soit un deuxième appel du même submit (submit: [pay, ledger]), si
l'avoir fait partie de la même décision ; soit un playbook de la station Comptabilité, ouvert par
le fait — l'étape 7.
Product — une commande, du submit au client
events:
inbound:
- name: kitchen.order.status_changed
from: {webhook: /hooks/kitchen, verify: {hmac_sha256: {header: X-Kitchen-Signature, secret_env: KITCHEN_SECRET}}}
id: "$payload.order_id + ':' + $payload.status"
fields:
order_id: {from: order_id, type: id}
status: {from: status, type: text} # accepted | ready | out_for_delivery | delivered
room: {returned_by: [take-meal-order.order, group-lunch.order], id: "$event.order_id"} # les deux playbooks passent la commande
emits:
- name: order.delivered
on: kitchen.order.status_changed # un fait entrant, pas un playbook
if: "$event.status == 'delivered'"
fields: {order_id: "$event.order_id", total: {value: "$receipt.total", type: price}}
outbound:
- {name: fidelite, on: [order.delivered], to: https://loyalty.example.fr/hooks,
audience: external, secret_env: LOYALTY_SECRET}
automations:
- name: pret-au-comptoir
on: kitchen.order.status_changed
if: "$event.status == 'ready' and $receipt.kind == 'pickup'"
then: {notify: customer, message: "Votre commande est prête au comptoir."}
| # | ce qui arrive | nature |
|---|---|---|
| 1 | le client soumet : placeOrder, puis createDelivery | actions |
| 2 | la cuisine envoie status: ready | événement entrant, rattaché à la room par l'appel placeOrder du playbook, qui a renvoyé cet identifiant |
| 3 | pret-au-comptoir prévient le client sur son portail | automatisation : notify, par un canal Convoy |
| 4 | status: delivered | événement entrant |
| 5 | order.delivered | événement émis, à partir d'un événement entrant |
| 6 | le programme de fidélité crédite des points chez lui | outbound |
Si la cuisine rejoue le même webhook, l'identifiant order_id:status le dédoublonne : le client
n'est pas prévenu deux fois.
Process — de l'arrivée d'un salarié à la revue des droits
| # | ce qui arrive | nature |
|---|---|---|
| 1 | lucca.contract.signed ; le salaire est classé confidential | événement entrant |
| 2 | demarrer-onboarding : arrivée dans 12 jours | automatisation : start |
| 3 | comptes, ordinateur, e-mail d'accueil | actions, réservées par les RH, soumises par l'IT |
| 4 | onboarding.completed, avec employee_id et start_date, sans le salaire | événement émis. Un salary ajouté par erreur est refusé au chargement. |
| 5 | la station « Accès et sécurité » ouvre « revue des droits à J+30 » | événement entrant d'une station, puis automatisation |
| 6 | l'annuaire RH du tenant reçoit le fait | outbound |
Ce que le chargement vérifie
| écrit | effet |
|---|---|
un on: qui cite un événement ni interne ni déclaré | erreur |
un of qui ne nomme rien de sa famille | erreur |
| deux événements du même nom dans une station, ou un préfixe interne | erreur |
| un champ émis dont la classe dépasse l'audience d'un outbound | erreur |
un fait entrant sans verify ou sans id | erreur |
une room ouverte par un événement ou une automatisation sans owner | erreur : who: owner ne désignerait personne |
| un emit que personne n'écoute | information : un consommateur peut venir plus tard |
| une boucle d'événements dans la station | erreur |
Entre stations, aucune config ne voit toute la boucle : chaque événement porte l'identifiant du parcours qui l'a causé, et une automatisation ne se déclenche pas sur un événement causé par son propre parcours.