Events
CRM — Events
Every record mutation emits an internal domain event (via EventEmitter2,
from RecordEventsService). These are server-side — audit, real-time,
workflow, and webhook listeners consume them. To receive them in your own
app, subscribe to a webhook (the public record.*
events map from these).
Record events
Names are defined in shared/events/data.events.ts:
| Event | Fires when | Key payload fields |
|---|---|---|
data.created | A record is created | objectSlug, dataSlug, recordId, newData, enrichedRecord |
data.updated | A record is updated | oldData, newData, changedRelations |
data.deleted | A record is deleted | objectSlug, dataSlug, recordId |
data.bulk_deleted | Bulk delete | affectedSlugs, affectedCount |
data.bulk_updated | Bulk update | affectedSlugs, affectedCount |
data.bulk_assigned | Bulk reassign | affectedSlugs, affectedCount |
Every payload also carries tenant/workspace context (tenantDatabaseName,
workspaceId), the actor (adminId, adminName), and a sourceType.
sourceType — who caused the write
manual (a human in the UI) · automation (a workflow) · system · api (an
API key or OAuth token) · ai (the assistant). Credential-originated writes are
auto-tagged api and stamped with sourceApiKeyId / sourceOauthClientId, so
the webhook fanout can skip echoing an event back to the credential that
made it (loop prevention).
oldData / newData carry only the changed fields — a listener treats an
absent key as "unchanged" (the webhook tracked-fields logic relies on this).
enrichedRecord is the fully-transformed row (same shape as the HTTP response)
so the real-time listener can push a ready-to-render record without a refetch.
Consumers (server-side)
| Listener | Reacts to | Effect |
|---|---|---|
| Audit changelog | all data.* (unless cacheOnly / skipChangelog) | Writes before/after changelog |
DataEventsGateway (records/realtime/) | all data.* | Pushes the live record to the workspace room |
| Cache observers | all data.* | Invalidate record/list caches |
| Workflow engine | data.created / data.updated | Fire record-created / field-changed triggers |
| Webhook fanout | data.* → record.* | Deliver to subscribed external endpoints |
Realtime (to the UI)
The DataEventsGateway broadcasts record changes over Socket.IO to the
workspace/tenant room, so a list or board updates live. Subscribe there instead
of polling GET /object/data/:objectSlug.
Besides the data.* events above, two events exist only on the socket. Both
answer the same problem — a write on object A moves a value on object B, whose
watchers nothing else would tell — and they differ in what they can carry:
| Event | Fires when | Carries |
|---|---|---|
data.lookup_stale | A record that other objects mirror was written | objectSlug, staleFields — slugs only |
data.rollup_updated | A child write caused a rollup recompute to settle | objectSlug, records[{ slug, values }] — values included |
A lookup value is per-reader: it mirrors a field on another object, so it is resolved through the reader's own A/G/M/D rights there and cannot go to a shared room — the client re-reads the named slugs. A rollup is aggregated once and stored on the parent's own column, so every reader of that record gets the identical number from the HTTP read path; broadcasting it is parity with the read contract, and the client patches the cell with no refetch.
data.rollup_updated fires only for the child-changed path. A rollup back-fill
(new field over existing data) rewrites every record and is deliberately silent.
Next
- Get events in your app → Webhooks
- The Receive webhooks recipe