Events you emit
An event is a fact your app publishes so other apps can react to it without either app knowing about the other. Every event your app emits is declared in the manifest first. A declared event is published in one of three ways:
- Row events. The platform fires them when a row in one of your tables changes. You write no code.
- App-fired events. Your code fires them with
db.events.emit(), for a fact that isn't a row change: an order was placed, a stock level ran low, a recompute finished. - Scheduled events. A schedule in the manifest names the events it publishes in
schedules[].emits. Each one must also be declared inevents.emits.
platform.events.publish() and platform.events.on() do not exist. If you have seen them in an example, that example was wrong. The only publish call is db.events.emit(), and it accepts only events the manifest declares.
The declaration
"events": {
"emits": [
{ "name": "serial.registered", "entity": "device_serials", "on": ["insert"] },
{ "name": "serial.updated", "entity": "device_serials", "on": ["update"],
"payload": { "serial_id": "column:id", "status": "column:status" } },
{ "name": "serial.serviced", "entity": "service_events", "on": ["insert"], "channel": true },
{ "name": "serial.warranty_expired", "trigger": "app",
"data_schema": { "type": "object", "properties": { "serial_id": { "type": "string" } } },
"sample": { "serial_id": "7f3a9c1e-0000-4000-8000-000000000001" } },
"serial.audited"
]
}
| Field | Required | Description |
|---|---|---|
name | Yes | <entity>.<action>, exactly two dot-separated segments. Published on the bus as <vendor>.<app>.<name>. |
entity | No | An entity from your schema.json whose row changes emit this event. |
on | No | Which row operations fire it: insert, update, delete. |
trigger | No | "app" makes it an app-fired event, published by your code with db.events.emit(). Use it instead of entity/on. |
data_schema | No | JSON Schema of the payload your code sends. Recommended for an app-fired event. |
payload | No | Row events only. Map of output key to a column:<col> locator. Omit to send the changed row minus the injected columns. |
sample | With trigger: "app" | Example payload, shown in catalogs and used to prefill test runs. Optional for a row event, required for an app-fired one, because there is no row to derive it from. |
channel | No | true marks the event pushable to a realtime channel for subscribed browsers. Default false. |
Row events
With entity and on present, the platform generates the trigger that publishes the event when those row operations occur. That is the whole mechanism: a row goes in, the event goes out. You write no publish call.
Which leads to the thing worth internalising:
A row event is a property of a table, not a call site in your code.
If a row can change through a path you did not write — the Cockpit's own renderer, a mountCrud route, another capability, a bulk import — the event fires there too. That is usually exactly what you want, and it is the reason the model is declarative. But it means:
- You cannot conditionally emit. There is no "only if the status really changed".
- You cannot bind something that is not a row change. A computation, a decision, a state transition that spans several rows: none of those is one row change.
- You cannot emit twice for one write, or suppress the event for a migration script's writes.
When you need any of that, declare an app-fired event instead.
App-fired events
Declare the event with trigger: "app" and a sample, and preferably a data_schema. The manifest schema refuses an app-fired event without a sample, and refuses entity, on and payload on one. Then regenerate the data client (revenexx apps generate), which adds db.events for the events you declared. This needs @revenexx/app-sdk 0.8.0 or later.
await db.events.emit('serial.warranty_expired',
{ serial_id: serial.id },
{ topicId: serial.id, id: `evt_warranty_expired_${serial.id}` });
// → { id, topic: 'acme.serials.serial.warranty_expired' }
| Option | Description |
|---|---|
topicId | Not the topic: the id of the resource the event is about, so a realtime consumer can follow one row instead of a whole collection. An opaque segment matching [A-Za-z0-9][A-Za-z0-9_-]{0,63}; the platform refuses anything else rather than reshaping it. Omit it for an event about a collection. |
id | Idempotency key. A handler has no transaction around its write and its emit, so a process can die between the two. A second emit with an id the platform has already seen is a no-op, so a deterministic id makes a retry safe. Without an id, every emit is a new event. |
metadata | Free-form metadata. The platform adds source, vendor and app. |
What to know before you rely on it:
- Only declared names go out. Emitting a name the manifest doesn't declare with
trigger: "app"fails at the call. If the App declares no app-fired events,db.eventsdoesn't exist at all. - Emit after the write succeeds. The event is a claim that something happened. Fire it once the row change it describes is stored, and derive
idfrom what happened (the event name plus the row ids), so the retry of a failed request publishes nothing twice. - Keep
topicIdstable for one entity. A realtime channel is built from the event name's first segment plus thetopicId. So if everyorder.*event carries the order id, one subscription to that order's channel hears its whole life, whatever part of it changed. - In tests, read
db.events.emitted(). Themockadapter records every emit instead of publishing it, so a unit test can assert what the handler fired.
A row event and an app-fired event can describe the same moment: a row insert and the business fact it represents. Document which one a consumer should subscribe to. A consumer that takes both sees the moment twice.
Catalog-only entries
An entry that is a bare string, or an object with neither entity/on nor trigger: "app", is catalog-only: it declares the event's existence for anyone reading the catalog, and nothing publishes it.
"emits": ["serial.audited"]
That is useful for documenting an event you intend to exist, but be careful with it — a catalog entry nothing publishes looks exactly like a working event to somebody building a consumer against your catalog. Prefer binding it to an entity, declaring it app-fired, or leaving it out until it does something.
The name rule
Event names must match ^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$: exactly two dot-separated segments.
| Name | Valid |
|---|---|
serial.registered | Yes |
stock_level.adjusted | Yes |
serial.service.registered | No — three segments |
registered | No — one segment |
Serial.Registered | No — lowercase only |
The manifest schema catches this, and so does the scaffolder. It is also one of the rules that is checked again at publish, so a hand-edited manifest that slipped through locally still fails on the way out. See Validate.
On the bus, the name is prefixed with your vendor and app: serial.registered in the acme/serials app is published as acme.serials.serial.registered. App-fired events follow the same rule, and db.events.emit() answers with the full topic. So the two-segment rule applies to your half of the name, and the full name is unambiguous across every app on the platform.
payload locators
By default a row event carries the changed row minus the injected columns. payload narrows and renames it:
{
"name": "serial.updated",
"entity": "device_serials",
"on": ["update"],
"payload": {
"serial_id": "column:id",
"serial": "column:serial",
"status": "column:status"
}
}
Each value is a column:<col> locator naming a column on that entity. The key is the name the consumer sees.
Declare a payload for anything you would not want in an audit trail forever, because that is effectively what an event bus is. The default — the whole row — is convenient and also broad: a column holding a note, an address or a price ends up in every consumer's inbox. Narrow it to the identifiers and the state a consumer needs, and let them call your capability for the rest.
A sample alongside it is cheap and pays for itself the first time somebody builds a consumer against your event without asking you what it looks like.
channel
{ "name": "serial.serviced", "entity": "service_events", "on": ["insert"], "channel": true }
channel: true marks the event pushable to a realtime channel, so a subscribed browser can receive it — the mechanism behind a Cockpit list that updates without a refresh. Default false.
Set it for events an operator would want to see land live. Leave it off for high-frequency internal churn: every channel event is a push to every subscribed browser.
What we are not claiming
Delivery semantics for the event bus — ordering, at-least-once versus at-most-once, retry policy, dead-letter handling, and the exact shape of the delivered envelope — are not settled enough to document (as of 2026-09). This page will not invent them.
What that means for you in practice:
- Make consumers idempotent. Assume the same event may arrive twice, because that is the assumption you can afford to be wrong about in the safe direction.
- Do not treat an event as a transaction. An event that has not arrived yet and an event that will never arrive look the same from the other side.
- Use a capability call when you need an answer. Events are for something happened. If code needs a value before it can continue, it calls your capability.
Events, capabilities or a workflow?
| Use | When |
|---|---|
| A capability call | The caller needs an answer now — "is this in stock?", "what is this priced at?". Synchronous, and the caller declares a capability permission. |
| An event | Something happened and other apps may want to know. Asynchronous, and you do not know or care who reacts. |
| An Integration Studio workflow | The reaction involves an external system, or needs durable execution with retries and visibility. See Integration Studio. |
Next steps
- Events you receive — the other direction.
- Manifest reference —
eventsamong the rest of the file. - Webhooks — getting platform events out to your own systems.
- Integration Studio — durable reactions.