Skip to main content

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

StreamSubjectsRetentionMax AgeReplication
NOTIF_EVENTSnotification.>limits30 daysR3
NOTIF_OUTBOUNDnotif.outbound.>interest24hR3
NOTIF_PROVIDER_RECEIPTSproviders.receipts.>limits7 daysR3
NOTIF_DLQnotif.dlq.>limits90 daysR3

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.v1identity.welcome.email, identity.welcome.inapp
identity.password.reset_requested.v1identity.password_reset.email (security, critical)
identity.mfa.enrolled.v1identity.mfa_confirmed.email
identity.session.suspicious_login.v1identity.suspicious_login.email, .sms
enrollment.created.v1enrollment.created.email, .inapp
enrollment.cancelled.v1enrollment.cancelled.email, .inapp
enrollment.seat_waitlisted.v1enrollment.waitlisted.inapp
assignment.window.opened.v1assignment.available.inapp, .push
assignment.window.closed.v1(nothing - graded event drives that)
assignment.submission.submitted.v1assignment.submitted_receipt.email
assignment.submission.graded.v1assignment.graded.inapp, .email
assignment.overdue.v1assignment.overdue.email, .push
progress.milestone.achieved.v1progress.milestone.inapp
certification.certificate.issued.v1certification.issued.email, .inapp
certification.certificate.revoked.v1certification.revoked.email (compliance, critical)
billing.payment.succeeded.v1billing.receipt.email
billing.payment.failed.v1billing.payment_failed.email (billing, high)
billing.subscription.canceled.v1billing.subscription_canceled.email
marketplace.order.placed.v1marketplace.order_placed.email
marketplace.order.fulfilled.v1marketplace.order_fulfilled.email, .inapp
marketplace.refund.issued.v1marketplace.refund.email
notification.receipt.requested.v1 (internal outbox)commerce.order.receipt.email (when worker wired)
marketplace.order.refunded.v1marketplace.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.v1Self-consumed: scrub user data (see below)
tenant.created.v1tenant.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:

  1. For all notifications of that user:
    • Scrub body + variables fields.
    • Replace recipientAddress with hash.
    • Retain metadata (id, channel, category, timestamps, status) 7 years for audit.
  2. 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_EVENTS stream.
  • Router emits new notifications with replayedFrom metadata.
  • 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).