Events
:::info Source
Sourced from services/notification-service/EVENT_SCHEMAS.md in the documentation repo.
:::
Event Envelope (platform standard)
All events follow the CloudEvents 1.0.2 binary mode plus platform extensions:
{
"specversion": "1.0",
"id": "01HXYZABC...",
"source": "/services/notification-service",
"type": "notification.sent.v1",
"datacontenttype": "application/json",
"time": "2026-04-15T10:00:00.123Z",
"subject": "notification/01HXYZ...",
"traceparent": "00-<trace>-<span>-01",
"tenantid": "01HT...",
"correlationid": "assign-window-...",
"causationid": "01HW...",
"dataschema": "https://schemas.ghasi.dev/notification.sent.v1.json",
"data": { ... }
}
NATS JetStream Topology
Streams owned by notification-service
| Stream | Subjects | Retention | Max Age | Replication |
|---|---|---|---|---|
NOTIF_EVENTS | notification.> | limits | 30 days | R3 |
NOTIF_OUTBOUND | notif.outbound.> | interest | 24h | R3 |
NOTIF_PROVIDER_RECEIPTS | providers.receipts.> | limits | 7 days | R3 |
NOTIF_DLQ | notif.dlq.> | limits | 90 days | R3 |
Consumers owned by notification-service (reading other services)
Pulls from the global DOMAIN_EVENTS stream with subject filters (see "Events Consumed" below).
Events Published
notification.deferred.v1 (EP-10)
Emitted when one or more channels are deferred because the user is in quiet hours (non-critical categories). Email/SMS/push are scheduled; in-app/webhook traffic is not deferred by this path.
{
"type": "notification.deferred.v1",
"data": {
"tenantId": "01HT...",
"userId": "usr_...",
"templateKey": "assignment.window.opened.email",
"channels": ["email", "sms"],
"deferredUntil": "2026-04-22T00:30:00.000Z",
"reason": "quiet_hours"
}
}
notification.queued.v1 (EP-10)
Emitted when a notification row is written and channel dispatch begins (outbox pattern alongside per-channel attempts).
{
"type": "notification.queued.v1",
"data": {
"tenantId": "01HT...",
"userId": "usr_...",
"notificationId": "550e8400-e29b-41d4-a716-446655440000",
"templateKey": "enrollment.created.inapp",
"channels": ["inapp", "email"],
"correlationId": "optional-corr",
"queuedAt": "2026-04-15T10:00:00.123Z"
}
}
notification.sent.v1
Published when the outbound worker receives an accept/queued response from the channel provider.
{
"type": "notification.sent.v1",
"data": {
"notificationId": "01HX1...",
"tenantId": "01HT...",
"userId": "01HU...",
"channel": "email",
"templateKey": "enrollment.created.email",
"templateVersion": "1.3.0",
"category": "academic",
"providerName": "ses",
"providerMessageId": "0100018f3d7e-4abc-4def-9876-543210fedcba",
"sentAt": "2026-04-15T10:00:00.123Z",
"attemptNumber": 1,
"sourceEvent": {
"type": "enrollment.created.v1",
"id": "01HW..."
}
}
}
JSON Schema (excerpt):
required: [notificationId, tenantId, userId, channel, templateKey, templateVersion, category, providerName, sentAt, attemptNumber]
properties:
channel: {enum: [email, sms, push, inapp, webhook]}
category: {enum: [academic, billing, marketing, security, social, system, compliance]}
attemptNumber: {type: integer, minimum: 1, maximum: 6}
notification.receipt.requested.v1 (EP-6 US-29)
Emitted to outbox when enrollment-service (or tests) calls POST /api/v1/internal/v1/commerce/receipt-requested with the notification internal secret. Downstream worker maps templateKey → email/in-app send.
{
"templateKey": "commerce.order.receipt",
"orderId": "ord_...",
"userId": "usr_...",
"courseId": "crs_...",
"currency": "USD",
"amountMicros": 1000000,
"paymentRef": "pay_..."
}
(Envelope + tenantId are stored per standard outbox row; event_type on the row is notification.receipt.requested.v1.)
notification.delivered.v1
{
"type": "notification.delivered.v1",
"data": {
"notificationId": "01HX1...",
"tenantId": "01HT...",
"userId": "01HU...",
"channel": "email",
"deliveredAt": "2026-04-15T10:00:35.456Z",
"providerMessageId": "0100018f...",
"latencyMs": 35333
}
}
notification.failed.v1
{
"type": "notification.failed.v1",
"data": {
"notificationId": "01HX1...",
"tenantId": "01HT...",
"userId": "01HU...",
"channel": "sms",
"reason": "provider_rejected_permanent",
"attempts": 6,
"lastError": {
"code": "30003",
"message": "Unreachable destination handset"
},
"failedAt": "2026-04-15T10:05:00Z"
}
}
notification.bounced.v1
{
"type": "notification.bounced.v1",
"data": {
"notificationId": "01HX1...",
"tenantId": "01HT...",
"userId": "01HU...",
"channel": "email",
"bounceType": "hard",
"bounceSubType": "general",
"addressHash": "sha256:abc...",
"bouncedAt": "2026-04-15T10:01:12Z",
"providerRaw": { "smtpStatusCode": "550" }
}
}
notification.opened.v1
notification.clicked.v1
Email tracking. Included in S1.
{
"type": "notification.opened.v1",
"data": {
"notificationId": "01HX1...",
"tenantId": "01HT...",
"userId": "01HU...",
"openedAt": "2026-04-15T11:23:00Z",
"userAgentFamily": "Gmail",
"ipHash": "sha256:..."
}
}
notification.preference.updated.v1
{
"type": "notification.preference.updated.v1",
"data": {
"tenantId": "01HT...",
"userId": "01HU...",
"changedFields": ["channels.marketing.email", "quietHours.startLocal"],
"priorVersion": 13,
"newVersion": 14,
"updatedBy": "01HU...",
"updatedAt": "2026-04-15T09:00:00Z"
}
}
notification.template.published.v1
{
"type": "notification.template.published.v1",
"data": {
"templateId": "01HT...",
"tenantId": null,
"key": "enrollment.created.email",
"version": "1.3.0",
"channel": "email",
"category": "academic",
"locales": ["en", "fr", "sw"],
"publishedBy": "01HU...",
"publishedAt": "2026-04-15T08:00:00Z",
"supersedes": "1.2.1"
}
}
notification.webhook.delivery_failed.v1
Published after exhausting retries (implementation: max 6 attempts, then DLQ).
{
"type": "notification.webhook.delivery_failed.v1",
"data": {
"subscriptionId": "01HS...",
"tenantId": "01HT...",
"eventType": "notification.sent.v1",
"attempts": 6,
"lastStatusCode": 500,
"lastError": "...response body or status line...",
"failedAt": "2026-04-15T10:17:00.000Z"
}
}
notification.digest.sent.v1
Emitted when a digest (≥2 items) is delivered.
{
"type": "notification.digest.sent.v1",
"data": {
"digestNotificationId": "01HX9...",
"tenantId": "01HT...",
"userId": "01HU...",
"channel": "email",
"category": "academic",
"rolledUpIds": ["01HX1...", "01HX2...", "01HX3..."],
"itemCount": 3,
"windowStart": "2026-04-15T10:00:00Z",
"windowEnd": "2026-04-15T10:15:00Z"
}
}
Events Consumed
Every domain event that could trigger a notification is consumed. Router maps event type → template key(s).
Trigger Map (S0 baseline)
| Event (consumed) | Template key(s) produced |
|---|---|
identity.user.created.v1 | identity.welcome.email, identity.welcome.inapp |
identity.password.reset_requested.v1 | identity.password_reset.email (security, critical) |
identity.mfa.enrolled.v1 | identity.mfa_confirmed.email |
identity.session.suspicious_login.v1 | identity.suspicious_login.email, .sms |
enrollment.created.v1 | enrollment.created.email, .inapp |
enrollment.cancelled.v1 | enrollment.cancelled.email, .inapp |
enrollment.seat_waitlisted.v1 | enrollment.waitlisted.inapp |
assignment.window.opened.v1 | assignment.available.inapp, .push |
assignment.window.closed.v1 | (nothing - graded event drives that) |
assignment.submission.submitted.v1 | assignment.submitted_receipt.email |
assignment.submission.graded.v1 | assignment.graded.inapp, .email |
assignment.overdue.v1 | assignment.overdue.email, .push |
progress.milestone.achieved.v1 | progress.milestone.inapp |
certification.certificate.issued.v1 | certification.issued.email, .inapp |
certification.certificate.revoked.v1 | certification.revoked.email (compliance, critical) |
billing.payment.succeeded.v1 | billing.receipt.email |
billing.payment.failed.v1 | billing.payment_failed.email (billing, high) |
billing.subscription.canceled.v1 | billing.subscription_canceled.email |
marketplace.order.placed.v1 | marketplace.order_placed.email |
marketplace.order.fulfilled.v1 | marketplace.order_fulfilled.email, .inapp |
marketplace.refund.issued.v1 | marketplace.refund.email |
notification.receipt.requested.v1 (internal outbox) | commerce.order.receipt.email (when worker wired) |
marketplace.order.refunded.v1 | marketplace.refund.email (recommended alias to marketplace.refund.issued.v1) |
content.lesson.published.v1 (optional, subscribed tenant) | content.new_lesson.inapp (digest candidate) |
gdpr.subject_request.received.v1 | Self-consumed: scrub user data (see below) |
tenant.created.v1 | tenant.welcome.email (to tenant admin) |
Idempotency of Consumers
Consumer dedupe keyed on eventId. A 48-hour bloom filter plus a 7-day consumed_events table guards against replay.
GDPR Subject-Request Consumer
On gdpr.subject_request.received.v1 with action = erase:
- For all notifications of that user:
- Scrub body + variables fields.
- Replace
recipientAddresswith hash. - Retain metadata (id, channel, category, timestamps, status) 7 years for audit.
- Emit
notification.user_data_scrubbed.v1(internal) for audit trail.
Event Versioning Policy
- Suffix
.v1,.v2- never edit a published schema. - Adding optional fields is not a version bump.
- Removing or renaming fields, changing semantics of an enum, or tightening required fields: new version.
- Old versions deprecated with 90-day window; consumer migration tracked in MIGRATION_PLAN.md.
- Schema registry at
/schemas/notification/*.json; CI gates changes against registry.
Consumer Configuration
consumer "notif-router" {
stream = "DOMAIN_EVENTS"
filter_subjects = [
"identity.>", "enrollment.>", "assignment.>", "progress.>",
"certification.>", "billing.>", "marketplace.>", "content.lesson.published.v1",
"gdpr.subject_request.>", "tenant.created.v1"
]
deliver_policy = "new"
ack_policy = "explicit"
ack_wait = "30s"
max_deliver = 6
max_ack_pending = 256
backoff = ["1s", "4s", "16s", "64s", "256s", "1024s"]
dlq_subject = "notif.dlq.router"
}
Dead-Letter Handling
- After 6 failed deliveries, message lands in
NOTIF_DLQ. - Alert fires via
pagerduty.notifications.dlq. - Admin UI (ops-console) allows inspection and manual replay.
- Retention: 90 days.
Event Replay
Operators can replay any time range via ops-console:
- Re-reads from
DOMAIN_EVENTSstream. - Router emits new notifications with
replayedFrommetadata. - Preference gate still applies (users who have opted-out won't receive again).
- Safety: replay disabled for events older than 30 days without explicit approval (prevents accidental spam).