Events, Webhooks & Notifications
Grant records domain events as the shared backbone for external automation and in-app alerts. Services publish events in the same transaction as the data change and audit log. A durable outbox (event_log) then fans out to two independent consumers:
| Consumer | Purpose |
|---|---|
| Webhooks | POST a signed, redacted CloudEvents payload to project subscription URLs |
| Notifications | Resolve an audience, apply preferences, and create in-app (and optionally email) rows |
Audit logs remain the per-entity compliance trail. Domain events are the integration contract — do not treat audit tables as a webhook feed.
Catalog
Every emitible type lives in @grantjs/schema (EVENT_TYPES / EVENT_CATALOG). Each entry declares:
| Field | Meaning |
|---|---|
| type | Stable string such as role.updated or api_key.rotated |
| category | security | iam | membership | integrations (drives notification preferences) |
| deliveryClass | notification (preference-governed) or transactional (always deliver; used sparingly) |
| audienceRule | Primitives unioned to build recipient candidates (subject, owners, roleHolders, …) |
A unit coverage test scans service sources for events.publish({ type: '…' }) and asserts every emitted type is registered. New mutations should co-locate publish with the existing audit call.
Coverage includes IAM CRUD/assignments, API key lifecycle, CDM sync summaries, membership lifecycle (organization invitations, org members, project users), and security lifecycle (signing keys, MFA enable/disable/recovery codes, session revoke, password changed). Password-reset request remains email-only (sendPasswordReset); only completed password changes emit user.password_changed.
Publishing
Services inject IEventPublisher and call publish after a successful mutation:
- Create →
{ after } - Update →
{ before, after, delta } - Delete / revoke →
{ before }
Payloads include ids and display-friendly fields for renderers. Never put secrets, hashes, or raw tokens in data (webhook delivery also redacts sensitive keys as a second line of defense).
User-facing assign/revoke events set subjectUserId so the notification audience can include the affected user.
Relay latency vs durability
After a successful outbox insert, request-scoped publishers schedule a coalesced event-relay BullMQ job via scheduleAfterCommit so consumers run soon after commit. The scheduled event-relay-sweep job remains the durability guarantee if enqueue is lost or jobs are disabled. Background/createAppContext publishes without the hook rely on the sweep alone.
Integrator quirks (membership)
- Accepting an organization invitation emits two events in one transaction:
organization.member_added(membership write) thenorganization.invitation_accepted(invitation status transition). Filter by type if you only want one. organization.member_removedcan originate from admin remove (OrganizationMemberService) or cleanup paths (OrganizationUserService); both write sites emit the same type, never both in one call chain.
CDM import suppression
Bulk CDM import mutates many entities. Those entity-level events are suppressed for the duration of importProjectCdm (including replace teardown) via runWithEventSuppression, so imports do not flood webhooks or inboxes.
When the async sync job finishes, Grant emits a single summary:
project_sync.completedproject_sync.failed
See CDM Import & Export for the import/export job model. Export-only jobs also emit the same completed/failed summaries; entity events are not involved.
Webhooks
Project operators manage subscriptions under Project → Webhooks:
- Create a subscription (HTTPS URL, description, event type filters).
- Store the signing secret shown once at creation (or after rotate).
- Inspect deliveries and replay failed attempts from the subscription detail page.
Subscriptions are project-scoped today. Each delivery POSTs JSON and includes:
| Header | Value |
|---|---|
Content-Type | application/json |
Webhook-Id | Delivery / event correlation id |
Webhook-Timestamp | Unix seconds used in the signed string |
Webhook-Signature | v1,<base64 hmac-sha256> of "{timestamp}.{rawBody}" |
External envelope
Bodies follow CloudEvents 1.0 with Grant extensions:
{
"specversion": "1.0",
"id": "…",
"source": "https://your-grant-host",
"type": "role.updated",
"time": "2026-01-01T00:00:00.000Z",
"subject": "role/…",
"grantsequence": "42",
"grantscope": { "tenant": "organizationProject", "id": "orgId:projectId" },
"grantactor": { "userId": "…" },
"grantcategory": "iam",
"data": {
"after": { "id": "…", "name": "Developer" },
"delta": { "name": { "from": "Dev", "to": "Developer" } }
}
}External data drops internal before snapshots and redacts keys matching secret/token patterns (replaced with "[redacted]").
Delivery is best-effort / at-least-once with application-level retries (escalating delays, then dead). Grant does not guarantee per-subscription delivery ordering; endpoints should be idempotent on id / Webhook-Id. SSRF guards block private targets unless explicitly allowed in configuration (useful for local receivers).
For local dogfooding, node scripts/webhook-receiver.mjs listens on http://localhost:5000/webhooks/grant and appends NDJSON. With Docker/local API, enable HTTP and private targets via the webhook SSRF env flags when needed.
Operators manage subscriptions via the dashboard (Project → Webhooks) or the API (REST /api/webhook-subscriptions and GraphQL webhookSubscriptions / createWebhookSubscription / …). See Transport Layers and Swagger UI for the full surface.
Notifications
The notification generator:
- Loads the catalog
audienceRulefor the event type. - Resolves primitives to user ids (
subject,scopeMembers,owners,roleHolders, …). - Applies per-scope preferences (category × in-app / email). Security/
transactionalrows stay on where policy requires. - Upserts idempotent notification rows and optionally enqueues email.
Users see the notification center and preferences in the dashboard; the header bell shows unread counts. Copy is rendered from event type + display context (actor, scope, entity names) — not from raw webhook envelopes.
Audience notes: owners and roleHolders resolve to org/account administrative roles as implemented by the audience resolver. The watchers primitive remains reserved for a future subscribe / follow model: it currently contributes no recipients, is not a user preference, and catalog audienceRules do not list watchers until that model exists.
Inbox and preference management are available under /api/me/notifications* (REST) and GraphQL myNotifications / myNotificationPreferences / … (authenticated me lane).
Adding a new event type
Follow this order so catalog, emit, and UI stay aligned:
- Catalog — add the type and
EVENT_CATALOGentry inpackages/@grantjs/schema/src/events/event-catalog.ts; rebuild@grantjs/schema. - Publish — inject
IEventPublisherif needed; callevents.publishimmediately after the audit call in the mutating service method. - Renderer — add a title/body mapping in
apps/api/src/lib/notifications/notification-renderer.ts. - i18n — add
webhooks.events.types.<aggregate>.<verb>labels inapps/web/i18n/locales/en.jsonandde.jsonso webhook pickers show a human name. - Tests — extend catalog coverage (emitted set) and add a spot unit test with a mocked publisher when the path is non-trivial.
Prefer small batches of related types over one-off string literals. Skip high-volume, low-signal pivots (for example most tag attachments) unless a concrete subscriber needs them.
Related
- CDM Import & Export — import suppression and sync job summaries
- API Keys — credential lifecycle (create / rotate / revoke events)
- Audit Logging — per-entity compliance trail
- Job Scheduling — relay and delivery workers
- Email Service — email channel adapter used by notifications