After-hooks, events and webhooks
React to a committed change: post the event to a webhook or send an email, know exactly what is delivered, how often it is retried, and where it may go.
Before you start
Section titled “Before you start”- The stack from Run your own descriptor, run from its
alvo-help-deskdirectory withCOMPOSE_FILEand that page’s secrets exported in this shell. This page uses theagentkey (rolesagent,authenticated). - An HTTPS endpoint you control, if you want to see a delivery arrive. The descriptor below points at
ops.example.com, which does not exist, so its deliveries fail and are retried.
1. Declare the endpoint and the template
Section titled “1. Declare the endpoint and the template”Every write appends an event, and an after-hook says which events to act on and what to do. This build runs two
actions: webhook, which posts the event to an endpoint, and email, which renders a template. Both targets are
declared once, at the top of the descriptor.
-
Start the stack over this page’s descriptor. The first command deletes the stack’s database:
Terminal docker compose down --volumescurl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/after-hooks-and-webhooks/01-webhook.alvo.jsondocker compose up --wait --wait-timeout 90 -
The descriptor declares the
ops-boardendpoint and theticket-closedtemplate, and two after-hooks use them:help-desk.alvo.json {"$schema": "https://alvo.dev/schema/v1/project.json","apiVersion": "alvo.dev/v1","name": "help-desk","auth": {"roles": ["admin", "agent"]},"webhooks": {"endpoints": {"ops-board": {"url": "https://ops.example.com/hooks/alvo","secretRef": "ops-board-signing-key"}}},"templates": {"ticket-closed": {"subject": "Ticket closed: {{new.title}}","body": "The ticket '{{new.title}}' was closed.\n\nResolution: {{new.resolution}}"}},"entities": {"tickets": {"audit": true,"fields": {"title": { "type": "string", "required": true, "maxLength": 120 },"priority": { "type": "enum", "values": ["low", "normal", "high"], "default": "normal" },"status": { "type": "enum", "values": ["open", "closed"], "default": "open" },"resolution": { "type": "text" }},"rules": {"list": "'authenticated' in @user.roles","get": "'authenticated' in @user.roles","create": "'agent' in @user.roles || 'admin' in @user.roles","update": "'agent' in @user.roles || 'admin' in @user.roles"},"hooks": {"afterCreate": [{"condition": "new.priority == 'high'","action": { "type": "webhook", "endpoint": "ops-board" }}],"afterUpdate": [{"condition": "changed(status) && new.status == 'closed'","action": { "type": "email", "template": "ticket-closed", "to": "support-lead@example.com" }}]}}}}- An endpoint’s
urlmust behttps; plainhttpis refused at apply, except for a loopback address such ashttp://127.0.0.1:5081/hook. The schema requiressecretRef, but this build does not read it: deliveries are not signed (see Not in this build below). - A template’s
subjectandbodytake{{…}}placeholders overnew.<field>,old.<field>,event.id,event.type,event.time,event.subjectand@user.id. A placeholder that names a field the entity does not have is refused at apply, never rendered empty. - A condition reads
new.,old.,changed()and@user.id.@user.idis the user who made the change.@user.rolesand@tenant.idare refused in an after-hook condition, because the event does not record them.
- An endpoint’s
2. Trigger it
Section titled “2. Trigger it”-
Create a high-priority ticket, then close it:
Request curl -sS -X POST http://localhost:8080/api/tickets \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"title":"Server room is flooding","priority":"high"}'201 Created
Response HTTP/1.1 201 CreatedContent-Type: application/json; charset=utf-8ETag: "639273155119029550"Location: /api/tickets/bf69649d-4067-41c6-a8da-1bb022eb191e{"id": "bf69649d-4067-41c6-a8da-1bb022eb191e","title": "Server room is flooding","priority": "high","status": "open"}Request curl -sS -X PATCH http://localhost:8080/api/tickets/bf69649d-4067-41c6-a8da-1bb022eb191e \-H "X-Alvo-Api-Key: agent.$ALVO_AGENT_KEY_SECRET" \-H "Content-Type: application/json" \-d '{"status":"closed","resolution":"Pumped out; the drain is cleared."}'200 OK
Response HTTP/1.1 200 OKContent-Type: application/json; charset=utf-8ETag: "639273155119452460"{"id": "bf69649d-4067-41c6-a8da-1bb022eb191e","status": "closed","resolution": "Pumped out; the drain is cleared."}Both writes answered at once. The after-hooks run later, after the commit, so nothing they do can change or delay the answer.
-
Read what happened in the log:
Terminal docker compose logs alvo | grep -E "ran after-hook|failed to deliver|development email provider"The mail is there: this build’s only mail provider writes each message to the log (recipient and subject; the body only at
Debug) instead of sending it. The webhook toops.example.comshows as failed to deliver … on attempt 1, then 2, and so on, until it reaches the attempt ceiling.
3. What the receiver gets
Section titled “3. What the receiver gets”A webhook’s body is the event, a CloudEvents 1.0 envelope in JSON. This one is pinned by the test suite; an update carries both images and the fields that moved:
{"specversion":"1.0","id":"019fc77e-be7b-72e8-b7fd-ffd6f6306e3e","source":"/alvo","type":"entity.vehicles.updated","time":"2026-08-03T09:30:00.0000000\u002B00:00","subject":"vehicles/3f2504e0-4f89-41d3-9a0c-0305e82c3301","datacontenttype":"application/json","partitionkey":"vehicles:3f2504e0-4f89-41d3-9a0c-0305e82c3301","payloadversion":1,"chaindepth":0,"authtype":"apikey","authid":"key-42","correlationid":"4bf92f3577b34da6a3ce929d0e0e4736","data":{"record":{"id":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","make":"vw","status":"approved","price":19.99},"old_record":{"id":"3f2504e0-4f89-41d3-9a0c-0305e82c3301","make":"vw","status":"draft","price":19.99},"changed":["status"]}}In this build authid is the id of the user the key acts as, not the key’s id: the sample’s key-42 is a
placeholder, and the design note’s wording is tracked in #344.
| Member | What it holds |
|---|---|
id | The event’s id. Delivery is at least once, so deduplicate on id: it is the one value that stays the same across retries. |
type | entity.<entity>.created, .updated or .deleted. |
subject, partitionkey | The row: <entity>/<id> and <entity>:<id>. |
time | The instant of the write, the same one its audit columns record, in UTC. |
authtype, authid | apikey, system or anon, and the id of the user who made the change (absent for an anonymous caller). |
correlationid | The trace id of the request that made the change, or the event’s own id when there was none. |
data.record, data.old_record | The row after and before the write. old_record is absent on a create, record on a delete. |
data.changed | The fields whose value moved. |
specversion, source, datacontenttype, payloadversion, chaindepth, causationid | CloudEvents and Alvo bookkeeping: 1.0, /alvo, application/json, the payload schema version, the hook-chain depth, and the causing event’s id when there is one. |
The record is complete. It includes fields that are hidden from callers, because the endpoint is declared by
the same author who declared the hidden rule. Treat the endpoint as a place that sees every field. A webhook’s
optional payload replaces
the envelope with a {{…}} template you write, which must be JSON around its placeholders.
4. Delivery and retries
Section titled “4. Delivery and retries”The event is written in the same transaction as the row, so a write that rolls back leaves no event, and a committed write always has one. One background dispatcher then claims events in order and runs each matching after-hook.
- Every failure is retried, whatever it was: a 500, a 404, a timeout, a name that does not resolve. A redirect is a failure too; the client follows none. An endpoint that answers 2xx is done.
- Each attempt waits at most 10 seconds for a 2xx; a slower endpoint counts as a failure. The body is sent as
application/json. - Retries back off linearly: the wait grows by one poll interval per attempt, so at the defaults ten attempts span
at least 45 seconds. After
Alvo:Events:MaxAttempts(10) the event is left alone: it stays in the outbox table, undelivered, and the log has an error line naming it. Nothing deletes it, and nothing redelivers it. - Retries are not in order. An event whose delivery failed is tried again later, after events that came behind it, including events for the same row: above, the close’s mail went out while the create’s webhook was still failing.
- Run one dispatcher. Two hosts draining one database break ordering silently
(#150). With several replicas, set
Alvo:Events:Enabledtofalseon all but one: writes still append events, and the one dispatcher delivers them.
Alvo’s own log lines never write an endpoint’s URL, because a URL can hold the only secret an unsigned endpoint has: a failed attempt names the event id and type, and the attached error names the endpoint by its descriptor name. Neither does the HTTP client’s: Alvo registers the webhook client without the default transport loggers, so no line carries the URL at any log level. An embedded host that adds its own logger to that client gets transport lines back, and owns their redaction.
5. Allow an internal network
Section titled “5. Allow an internal network”Webhook delivery refuses every address that is not public: private ranges, loopback, link-local (including the cloud
metadata endpoint), carrier-grade NAT, multicast and reserved ranges. It is judged on the address the name resolves
to, when the connection opens, so a public-looking name that points inside your network is refused too. Loopback is
allowed only when the URL names it literally, such as 127.0.0.1 or localhost.
To deliver to a service on your own network, list its network in CIDR notation under
Alvo:Events:WebhookAllowedNetworks, one entry per index:
Alvo__Events__WebhookAllowedNetworks__0=10.20.0.0/16. A listed network overrides every other refusal, so list the
narrowest one that works. An entry that is not CIDR notation is refused at startup. A refused address is an ordinary
failed delivery, retried like any other.
6. Publish your own event
Section titled “6. Publish your own event”An embedded host can put its own events on the same durable queue with IAlvoEvents:
Task PublishAsync( string type, string subject, IReadOnlyDictionary<string, object?>? data, AlvoContext context, CancellationToken cancellationToken = default);The type is two or more lower-case segments separated by dots, such as orders.approved, and never starts with
entity., auth. or storage.: those are Alvo’s own, and a name in one is refused. Values in data must be scalars.
Know what a custom event is not, before you build on it:
- Nothing subscribes to it yet. No after-hook can name it, so the dispatcher records it and runs nothing.
- It is not part of your transaction. It is appended on its own, so it can commit when your write does not.
- A retried publish appends a second event, with a new
id. - It carries no tenant.
If an email’s to is a placeholder such as {{new.contact_email}}, anyone who can write the row chooses who
receives the mail and its data. It is harmless with the log-only provider; decide it deliberately before you register
a real one.
What can go wrong
Section titled “What can go wrong”An after-hook never changes the answer to the write that triggered it, so it returns no problem type. What can go wrong shows up when the descriptor is applied, or in the log:
| Where | When | Fix |
|---|---|---|
| Apply | The endpoint’s URL is relative, or http to a host that is not loopback. | Use an absolute https URL. |
| Apply | A placeholder names a field the entity does not have, or a hook names an endpoint or a template that is not declared. | Correct the name; the refusal points at the hook. |
| Apply | The action is function, entity.update or http.call, or payload holds JSONata. | Use webhook or email, and a {{…}} template. |
| Apply | An after-hook condition reads @user.roles or @tenant.id. | Read the row’s own fields, or @user.id. |
| Log | failed to deliver … on attempt N | Check the endpoint; a non-public address needs Alvo:Events:WebhookAllowedNetworks. |
An apply refusal stops the container from coming back, and the reason is at the end of docker compose logs alvo.
In this build that refusal ends the process with exit code 139 instead of 78
(#340).
Reference
Section titled “Reference”- Descriptor keys:
webhooks,templates,afterCreate,afterUpdate,afterDelete. - Configuration:
Alvo:Events. - C#:
IAlvoEvents. - Design notes: the envelope, after-hooks and the attempt ceiling.
Audit row changes: record who created and last changed each row.