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-eventsBefore-events
Names<type>.created, .updated, .deleted<type>.creating, .updating, .deleting
When the route runsafter the change is saved, outside the request that made itduring the change, before it is saved
Can refuse the changenoyes
If the route failsretried laterthe 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 --retry or 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 id to 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, or null for 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

KeyMeaning
onOne event or a list, as <type system name>.<event>. Before- and after-events go in separate subscriptions.
routeAn 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.
runAsapp (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.
nameA name for the subscription; it defaults to its events and route, and shows in the event list.
modeImplied: 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; --failed shows 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' events subscriptions receive record events

Default:

  • true

How to configure it:

  • set it to false to 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