Skip to content

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:

ConsumerPurpose
WebhooksPOST a signed, redacted CloudEvents payload to project subscription URLs
NotificationsResolve 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:

FieldMeaning
typeStable string such as role.updated or api_key.rotated
categorysecurity | iam | membership | integrations (drives notification preferences)
deliveryClassnotification (preference-governed) or transactional (always deliver; used sparingly)
audienceRulePrimitives 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) then organization.invitation_accepted (invitation status transition). Filter by type if you only want one.
  • organization.member_removed can 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.completed
  • project_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:

  1. Create a subscription (HTTPS URL, description, event type filters).
  2. Store the signing secret shown once at creation (or after rotate).
  3. Inspect deliveries and replay failed attempts from the subscription detail page.

Subscriptions are project-scoped today. Each delivery POSTs JSON and includes:

HeaderValue
Content-Typeapplication/json
Webhook-IdDelivery / event correlation id
Webhook-TimestampUnix seconds used in the signed string
Webhook-Signaturev1,<base64 hmac-sha256> of "{timestamp}.{rawBody}"

External envelope

Bodies follow CloudEvents 1.0 with Grant extensions:

json
{
  "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:

  1. Loads the catalog audienceRule for the event type.
  2. Resolves primitives to user ids (subject, scopeMembers, owners, roleHolders, …).
  3. Applies per-scope preferences (category × in-app / email). Security/transactional rows stay on where policy requires.
  4. 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:

  1. Catalog — add the type and EVENT_CATALOG entry in packages/@grantjs/schema/src/events/event-catalog.ts; rebuild @grantjs/schema.
  2. Publish — inject IEventPublisher if needed; call events.publish immediately after the audit call in the mutating service method.
  3. Renderer — add a title/body mapping in apps/api/src/lib/notifications/notification-renderer.ts.
  4. i18n — add webhooks.events.types.<aggregate>.<verb> labels in apps/web/i18n/locales/en.json and de.json so webhook pickers show a human name.
  5. 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.

Released under the MIT License.