Skip to main content
Version: Target · roadmap

Events

A fact is not an action​

questionwho decidesguards, selection, submitConvoy guarantees
an event"what happened?"no one: it happenedno: a fact cannot be refusedthat 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 ruleyes: proposed → reserved → executedthat 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​

eventwhere it comes fromwhere it is declared
inbounda tool's webhook, or another stationevents.inbound
internalCorenowhere: a fixed catalogue, cited by name
emittedthe station, from an internal or inbound eventevents.emits
consumerwhowhat it does
automationthe stationstart a playbook, remind a person, notify through a Convoy channel. Never a write in a tool, never a submit.
outbounda third partydelivers 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.

familyeventssubject (of)
roomroom.opened, room.closed—
actionaction.proposed, action.pending, action.reserved, action.refused, action.removed, action.executed, action.held, action.failedthe action's skill
playbookplaybook.opened, playbook.finalised, playbook.abandoned, playbook.superseded, task.opened, task.donea playbook
submitsubmit.started, submit.completeda playbook
policypolicy.evaluated (only when it restricts), policy.disputeda policy
budgetbudget.reacheda budget
peopleapproval.requested, approval.granted, approval.denied, approval.expiredan inbox
eventsautomation.fired, event.received, event.rejected, delivery.failedan 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​

keymeaning
froma webhook, or a station: {station: ticket-refund, event: refund.completed}
verifythe signature. A message that does not verify is event.rejected, never read.
idthe identifier that deduplicates
fieldstyped and classed, like those of a skill. An undeclared field is never read.
roomthe 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.

writtenprimitiveexample
room: {key: "$event.ticket_id", owner: {role: support}}the room, by a key you give it; opened if absent, with its ownera 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'sthe kitchen says "ready"; a refund fails late
(absent)the station onlya 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​

  1. In a station, a name designates a single event, inbound or emitted. A station never receives its own emitted fact.
  2. Internal prefixes are reserved: room., action., playbook., task., submit., policy., budget., approval., automation., event., delivery..
  3. 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​

  1. 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.
  2. An emitted fact obeys data classes. A confidential field does not go to an external outbound. 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 happensnaturewho carries the effect
1Zendesk sends the webhook, signedinbound event—
2ouvrir-sur-ticket: the billing tag is thereautomation: startConvoy opens the playbook
3reads, refund of €120, manager approval in Teams, reply to the customeractionsthe manager submits: createRefund, then sendReply
4playbook.finalisedinternal event—
5refund.completed, three chosen fieldsemitted event—
6the CRM receives the fact and adds it to its timelineoutbound: deliverythe CRM
7the Accounting station receives the fact and opens rapprocher-remboursementinbound event, then automationAccounting, 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 happensnature
1the customer submits: placeOrder, then createDeliveryactions
2the kitchen sends status: readyinbound event, attached to the room by the playbook's placeOrder call, which returned this identifier
3pret-au-comptoir notifies the customer on their portalautomation: notify, through a Convoy channel
4status: deliveredinbound event
5order.deliveredemitted event, from an inbound event
6the loyalty programme credits points on its sideoutbound

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 happensnature
1lucca.contract.signed; the salary is classed confidentialinbound event
2demarrer-onboarding: arrival in 12 daysautomation: start
3accounts, computer, welcome e-mailactions, reserved by HR, submitted by IT
4onboarding.completed, with employee_id and start_date, without the salaryemitted event. A salary added by mistake is refused at load time.
5the "Access and security" station opens "access review at D+30"inbound event from a station, then automation
6the tenant's HR directory receives the factoutbound

What loading checks​

writteneffect
an on: citing an event that is neither internal nor declarederror
an of that names nothing in its familyerror
two events with the same name in a station, or an internal prefixerror
an emitted field whose class exceeds the audience of an outbounderror
an inbound fact without verify or without iderror
a room opened by an event or an automation without ownererror: who: owner would designate no one
an emit no one listens toinformation: a consumer may come later
an event loop in the stationerror

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.