Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

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.

  • The stack from Run your own descriptor, run from its alvo-help-desk directory with COMPOSE_FILE and that page’s secrets exported in this shell. This page uses the agent key (roles agent, 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.

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.

  1. Start the stack over this page’s descriptor. The first command deletes the stack’s database:

    Terminal
    docker compose down --volumes
    curl -fsSL -o help-desk.alvo.json https://raw.githubusercontent.com/Burgyn/MMLib.Alvo/main/website/src/snippets/after-hooks-and-webhooks/01-webhook.alvo.json
    docker compose up --wait --wait-timeout 90
  2. The descriptor declares the ops-board endpoint and the ticket-closed template, 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 url must be https; plain http is refused at apply, except for a loopback address such as http://127.0.0.1:5081/hook. The schema requires secretRef, but this build does not read it: deliveries are not signed (see Not in this build below).
    • A template’s subject and body take {{…}} placeholders over new.<field>, old.<field>, event.id, event.type, event.time, event.subject and @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.id is the user who made the change. @user.roles and @tenant.id are refused in an after-hook condition, because the event does not record them.
  1. 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 Created
    Content-Type: application/json; charset=utf-8
    ETag: "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 OK
    Content-Type: application/json; charset=utf-8
    ETag: "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.

  2. 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 to ops.example.com shows as failed to deliver … on attempt 1, then 2, and so on, until it reaches the attempt ceiling.

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:

An entity.vehicles.updated event
{"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.

MemberWhat it holds
idThe event’s id. Delivery is at least once, so deduplicate on id: it is the one value that stays the same across retries.
typeentity.<entity>.created, .updated or .deleted.
subject, partitionkeyThe row: <entity>/<id> and <entity>:<id>.
timeThe instant of the write, the same one its audit columns record, in UTC.
authtype, authidapikey, system or anon, and the id of the user who made the change (absent for an anonymous caller).
correlationidThe trace id of the request that made the change, or the event’s own id when there was none.
data.record, data.old_recordThe row after and before the write. old_record is absent on a create, record on a delete.
data.changedThe fields whose value moved.
specversion, source, datacontenttype, payloadversion, chaindepth, causationidCloudEvents 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.

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:Enabled to false on 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.

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.

An embedded host can put its own events on the same durable queue with IAlvoEvents:

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

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:

WhereWhenFix
ApplyThe endpoint’s URL is relative, or http to a host that is not loopback.Use an absolute https URL.
ApplyA 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.
ApplyThe action is function, entity.update or http.call, or payload holds JSONata.Use webhook or email, and a {{…}} template.
ApplyAn after-hook condition reads @user.roles or @tenant.id.Read the row’s own fields, or @user.id.
Logfailed to deliver … on attempt NCheck 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).

Audit row changes: record who created and last changed each row.