Events
A fact is not an action
| question | who decides | guards, selection, submit | Convoy guarantees | |
|---|---|---|---|---|
| an event | "what happened?" | no one: it happened | no: a fact cannot be refused | that it is delivered: at least once, signed, with an identifier to deduplicate |
| an action | "what do we change, and in which tool?" | a person, or an agent allowed by a rule | yes: proposed → reserved → executed | that it is permitted, then executed only once |
Notifying the tenant's system is not an action: Convoy changes nothing on its side, it informs it, and the tenant decides what to do with it.
The sorting rule:
- The tenant only needs to know (its CRM, its dashboard): this is an outbound delivery.
- Something must be done in a tool (create the credit note in the ERP, write to the customer): this is an action. It goes through a skill, its policies, a reservation and a submit, even when an agent submits.
Three sources, two consumers
| event | where it comes from | where it is declared |
|---|---|---|
| inbound | a tool's webhook, or another station | events.inbound |
| internal | Core | nowhere: a fixed catalogue, cited by name |
| emitted | the station, from an internal or inbound event | events.emits |
| consumer | who | what it does |
|---|---|---|
| automation | the station | start a playbook, remind a person, notify through a Convoy channel. Never a write in a tool, never a submit. |
| outbound | a third party | delivers the fact to a URL. No business condition, no Convoy primitive. |
An automation decides to do something with Convoy's primitives. An outbound delivery decides
nothing: it carries. And notify goes through Convoy's channels — a role's inbox, a portal of the
agent, the room's customer. Anything that goes through a tool of the tenant is an action.
Automations have their own block: they act, while events only declares and carries.
Internal events
Set by Core, the same for all stations. You do not declare them.
| family | events | subject (of) |
|---|---|---|
| room | room.opened, room.closed | — |
| action | action.proposed, action.pending, action.reserved, action.refused, action.removed, action.executed, action.held, action.failed | the action's skill |
| playbook | playbook.opened, playbook.finalised, playbook.abandoned, playbook.superseded, task.opened, task.done | a playbook |
| submit | submit.started, submit.completed | a playbook |
| policy | policy.evaluated (only when it restricts), policy.disputed | a policy |
| budget | budget.reached | a budget |
| people | approval.requested, approval.granted, approval.denied, approval.expired | an inbox |
| events | automation.fired, event.received, event.rejected, delivery.failed | an automation, an event, an outbound |
of: the subject
Each family has only one kind of subject. So a single key is enough, of, whatever the family:
on: playbook.finalised # all playbooks
on: {event: playbook.finalised, of: ticket-refund} # only one
on: {event: action.reserved, of: refund} # the actions of the refund skill
on: {event: action.pending, of: refund, older_than: 2h} # still pending 2 h later
on: {event: policy.evaluated, of: fraude}
Loading checks that of exists in its family: a playbook for playbook.*, a skill for
action.*.
The events block
events:
inbound: # the facts RECEIVED
- name: zendesk.ticket.created
from: {webhook: /hooks/zendesk, verify: {hmac_sha256: {header: X-Zendesk-Signature, secret_env: ZD_SECRET}}}
id: "$payload.ticket.id" # a replayed webhook is ignored
fields:
ticket_id: {from: ticket.id, type: id}
tags: {from: ticket.tags, type: text}
requester: {from: ticket.requester.email, type: text, class: personal}
room: # this ticket's room, opened if absent
key: "$event.ticket_id"
owner: {role: support} # the support employee who takes it
concerns: "$event.requester" # who it is about: notified, never owner
emits: # the facts PUBLISHED: a stable contract, chosen fields
- 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"
# not the customer's e-mail: no one downstream needs it
outbound: # the DELIVERY of the published facts
- name: crm
on: [refund.completed]
to: https://crm.example.fr/hooks/convoy
audience: external
secret_env: CRM_HOOK_SECRET # each delivery is signed
retry: {max: 10, backoff: exponential}
from and to mirror each other. on has the same meaning everywhere — in an emit, an outbound,
an automation: on these events. An emit is not attached to a playbook: it can start from an
inbound fact (the kitchen says "delivered" → order.delivered).
Receiving: from, verify, id, fields
| key | meaning |
|---|---|
from | a webhook, or a station: {station: ticket-refund, event: refund.completed} |
verify | the signature. A message that does not verify is event.rejected, never read. |
id | the identifier that deduplicates |
fields | typed and classed, like those of a skill. An undeclared field is never read. |
room | the room the fact belongs to, and its owner if it is new |
Which room a received fact belongs to: room
A webhook arrives at the station level; room says for whom. The fact is then written in that
room's log, automations know $room and its receipt, and notify: customer knows whom to talk to.
| written | primitive | example |
|---|---|---|
room: {key: "$event.ticket_id", owner: {role: support}} | the room, by a key you give it; opened if absent, with its owner | a Zendesk ticket: one room per ticket |
room: {returned_by: [take-meal-order.order, group-lunch.order], id: "$event.order_id"} | the room where one of these calls returned this identifier (in its output): <playbook>.<call> for a playbook's call, <skill>.<call> for a skill's | the kitchen says "ready"; a refund fails late |
| (absent) | the station only | a contract signed in Lucca: the start … for: automation opens the room |
key is used when the tool's object opens the room (a ticket). returned_by is used for
what follows an action already executed: the object created at submit keeps living in the
tool — an order prepared then delivered, a refund that fails, a company registered, a computer
shipped, an envelope signed — and the fact comes back to the room the object left from. Because it
names calls, an id: 42 at Stripe is never taken for the id: 42 at Zendesk.
A room opened by an event has an owner, always a user (see
Human In The Loop). The person it is about is
concerned (concerns): they can be notified, they act on nothing.
A fact that matches no action is logged at the station level; automations fire anyway, with an
empty $room.
Names
- In a station, a name designates a single event, inbound or emitted. A station never receives its own emitted fact.
- Internal prefixes are reserved:
room.,action.,playbook.,task.,submit.,policy.,budget.,approval.,automation.,event.,delivery.. - Between two stations, the same name deliberately designates the same fact. The receiving station does not declare it again: it cites the contract, and the name and fields come from it.
events: # the Accounting station
inbound:
- from: {station: ticket-refund, event: refund.completed}
A convention helps reading: an inbound fact from a tool carries the tool's name
(zendesk.ticket.created), a published fact carries a business name (refund.completed).
What goes out
- Only emitted facts leave the station. Internal events serve automations, the log and the console. An emitted fact is a stable contract, named by you: the log can evolve without breaking someone's system.
- An emitted fact obeys data classes. A
confidentialfield does not go to anexternaloutbound. It is a policy like any other: it appears in the registry, under Output.
If the CRM does not answer, Convoy retries, then the delivery moves to delivery.failed in the
console. Nothing is cancelled: the refund took place, it is a fact.
End to end
Process — a ticket becomes a refund, then accounting and the CRM follow
| # | what happens | nature | who carries the effect |
|---|---|---|---|
| 1 | Zendesk sends the webhook, signed | inbound event | — |
| 2 | ouvrir-sur-ticket: the billing tag is there | automation: start | Convoy opens the playbook |
| 3 | reads, refund of €120, manager approval in Teams, reply to the customer | actions | the manager submits: createRefund, then sendReply |
| 4 | playbook.finalised | internal event | — |
| 5 | refund.completed, three chosen fields | emitted event | — |
| 6 | the CRM receives the fact and adds it to its timeline | outbound: delivery | the CRM |
| 7 | the Accounting station receives the fact and opens rapprocher-remboursement | inbound event, then automation | Accounting, under its own rules |
"After the refund, create the credit note in the ERP" is not a delivery: it is a change in a
tool. Either a second call of the same submit (submit: [pay, ledger]), if the credit note is part
of the same decision; or a playbook of the Accounting station, opened by the fact — step 7.
Product — an order, from the submit to the customer
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"} # both playbooks place the order
emits:
- name: order.delivered
on: kitchen.order.status_changed # an inbound fact, not a 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: "Your order is ready at the counter."}
| # | what happens | nature |
|---|---|---|
| 1 | the customer submits: placeOrder, then createDelivery | actions |
| 2 | the kitchen sends status: ready | inbound event, attached to the room by the playbook's placeOrder call, which returned this identifier |
| 3 | pret-au-comptoir notifies the customer on their portal | automation: notify, through a Convoy channel |
| 4 | status: delivered | inbound event |
| 5 | order.delivered | emitted event, from an inbound event |
| 6 | the loyalty programme credits points on its side | outbound |
If the kitchen replays the same webhook, the order_id:status identifier deduplicates it: the
customer is not notified twice.
Process — from an employee's arrival to the access review
| # | what happens | nature |
|---|---|---|
| 1 | lucca.contract.signed; the salary is classed confidential | inbound event |
| 2 | demarrer-onboarding: arrival in 12 days | automation: start |
| 3 | accounts, computer, welcome e-mail | actions, reserved by HR, submitted by IT |
| 4 | onboarding.completed, with employee_id and start_date, without the salary | emitted event. A salary added by mistake is refused at load time. |
| 5 | the "Access and security" station opens "access review at D+30" | inbound event from a station, then automation |
| 6 | the tenant's HR directory receives the fact | outbound |
What loading checks
| written | effect |
|---|---|
an on: citing an event that is neither internal nor declared | error |
an of that names nothing in its family | error |
| two events with the same name in a station, or an internal prefix | error |
| an emitted field whose class exceeds the audience of an outbound | error |
an inbound fact without verify or without id | error |
a room opened by an event or an automation without owner | error: who: owner would designate no one |
| an emit no one listens to | information: a consumer may come later |
| an event loop in the station | error |
Between stations, no config sees the whole loop: each event carries the identifier of the journey that caused it, and an automation does not fire on an event caused by its own journey.