App Events
What It Is
App events let a Custom App react when records change anywhere in the tenant — in a form, through the REST API, by an automation, from another app or an AI agent. The app's manifest subscribes one of its routes to record events of a type; the platform calls that route with a description of what happened.
"routes": {
"POST /events/case-changed": { "handler": "server/events/case-changed.php", "auth": "internal" },
"POST /events/check-case": { "handler": "server/events/check-case.php", "auth": "internal" }
},
"events": [
{ "on": ["case.created", "case.updated"], "route": "POST /events/case-changed", "when": { "changed": ["status"] } },
{ "on": "case.creating", "route": "POST /events/check-case", "runAs": "actor" }
]
Why It Matters
Without events, an app sees only the changes made through its own screens. With them it can notify the right people when a case closes, keep a summary up to date, sync to another system, or refuse a change that breaks its rules — whoever made the change and wherever.
Events complement automations: an automation is configured in the tenant, an event subscription ships with the app's code and is versioned, reviewed and deployed with it.
Two Kinds Of Events
| After-events | Before-events | |
|---|---|---|
| Names | <type>.created, .updated, .deleted | <type>.creating, .updating, .deleting |
| When the route runs | after the change is saved, outside the request that made it | during the change, before it is saved |
| Can refuse the change | no | yes |
| If the route fails | retried later | the change is refused |
Use after-events for anything that follows from a change: notifications, derived data, syncs. Use before-events only to enforce rules the data model cannot express, and keep them fast — the person saving waits for them, for at most 5 seconds.
How After-Events Are Delivered
- A change that is not saved — a form that fails validation, a transaction that rolls back — produces no event.
- Events are delivered right after the response to whoever made the change, and the scheduler delivers anything left over.
- A delivery succeeds when the route answers with a status below 400. A failed one is tried again after 1, 5, 30, 120 and 360 minutes; after the sixth failure it is marked dead. Dead and failed events can be delivered again with
jetstack events --retryor from the app's page. - An event can arrive more than once (after a timeout, for example) and in a different order than the changes happened. Use the event's
idto do the work once.
What A Handler Receives
The route's handler gets the event as $event, and as its JSON body:
{
"id": "evt_4f1c…",
"name": "case.created,case.updated POST /events/case-changed",
"event": "case.updated",
"type": "case",
"record": { "id": 42, "uuid": "…", "data": { "title": "…", "status": "closed" } },
"changes": { "status": { "from": "open", "to": "closed" } },
"actor": { "id": 7 },
"time": "2026-09-29T10:15:03+02:00",
"depth": 0
}
changes— for updates: each changed field with its old and new value.record.data— with"include": ["record"]: the record as the handler's identity reads it through the REST API. For a deleted record, the record as it was just before.- Fields the handler's identity may not read are left out of both.
actor— the user who made the change, ornullfor anonymous changes.depth— how many event handlers led to this change (see below).
A before-event's handler gets data — the values being saved — instead of record.data, and record is null for creating. To refuse the change, answer with a status of 400 or more, or with {"reject": true, "message": "…"}; the message is shown to whoever made the change:
<?php
// server/events/check-case.php — "case.creating"
if (($event['data']['priority'] ?? '') === 'critical' && empty($event['data']['owner'])) {
$jetstack->sendJson(['reject' => true, 'message' => 'A critical case needs an owner.'], 422);
return;
}
$jetstack->sendJson(['ok' => true]);
A before-event route that crashes or times out also refuses the change: a rule that could not be checked does not let the change through.
Subscription Options
| Key | Meaning |
|---|---|
on | One event or a list, as <type system name>.<event>. Before- and after-events go in separate subscriptions. |
route | An internal route of the app, without path parameters. |
when | {"changed": ["field", …]} — deliver updates only when one of these fields changed. Creates and deletes of the same subscription always arrive. |
runAs | app (default) — the app's service principal, limited to its capabilities; actor — the user who made the change. |
include | ["record"] — add the record to the event. |
name | A name for the subscription; it defaults to its events and route, and shows in the event list. |
mode | Implied: async for after-events, sync for before-events. |
The route runs the app's live release. A subscription the live release no longer declares is not delivered.
Loops And Cascades
A handler that changes records causes events of its own. Two rules keep that finite:
- An app's own event handlers do not trigger that app's subscriptions again. A handler that updates the record it was told about does not hear about its own update.
- Events caused by event handlers are followed three levels deep; deeper ones are dropped and logged.
Watching Deliveries
jetstack events --env <env>lists the latest deliveries with their status, attempts and errors;--failedshows the ones waiting for a retry,--retry [<id>]delivers failed and dead events again.- The app's page in the tenant shows the same list under Events.
- Each delivery is also a line in the app's log (
jetstack logs), with the error id when the handler failed.
Tenant Settings
These keys are not on the Application settings screen; set them in the tenant configuration.
customAppsEvents
What it controls:
- whether apps'
eventssubscriptions receive record events
Default:
true
How to configure it:
- set it to
falseto stop all app event deliveries at once, for example while an app misbehaves on a production tenant
What it affects:
- while it is off, no events are recorded and before-events do not run; events recorded earlier wait and are delivered once it is on again
- it is independent of
automationHooksOnAllSurfaces, which governs automations