Skip to main content

API Contracts

:::info Source Sourced from services/notification-service/API_CONTRACTS.md in the documentation repo. :::

Base URL

https://api.ghasi.dev/v1/ (versioned at path level, following platform API standard).

Authentication

  • User endpoints: JWT from identity-service, RS256, scoped by tenantId, userId, roles[].
  • Internal endpoints (service-to-service): mTLS + service JWT with svc:* scope.
  • Webhook-inbound (provider receipts): HMAC validation against provider-specific secret.

All responses use the platform envelope:

{
"success": true,
"data": { ... },
"error": null,
"meta": { "traceId": "...", "timestamp": "..." }
}

EP-10 — Idempotency, internal send, OpenAPI, WebSocket

HTTP idempotency (Idempotency-Key)

HeaderRequired onBehavior
Idempotency-KeyPATCH /api/v1/notification-preferences, POST /api/v1/notifications/send, POST /api/v1/webhook-subscriptionsRetries return the same stored response when the body hash matches. 409 if the same key is reused with a different body (scope is per-tenant + operation).

Stored server-side in idempotency_keys (see DATA_MODEL).

Internal multi-channel send

POST /api/v1/notifications/send additionally requires:

HeaderPurpose
X-Notification-Internal-SecretShared secret (NOTIFICATION_INTERNAL_API_SECRET); rejects if missing or wrong.

OpenAPI

Canonical OpenAPI 3.1 JSON for this service lives in the Ghasi-edTech monorepo at services/notification-service/openapi.json (generated via scripts/emit-openapi.ts, committed for CI/contract tests).

WebSocket GET /api/v1/notifications/stream

Current implementation: the client must pass query parameters tenantId (UUID) and userId (user id string). The server subscribes the socket to in-app events for that pair and sends JSON messages { "type": "notification", "payload": { ... } } and an initial { "type": "ping" }.

Hardening note: production should authenticate the handshake (JWT / Sec-WebSocket-Protocol) instead of trusting query parameters alone.

Endpoint Index

MethodPathAuthSlicePurpose
GET/api/v1/notificationsuserS0List in-app feed
PATCH/api/v1/notifications/{id}/readuserS0Mark as read
POST/api/v1/notifications/read-alluserS0Mark all read
GET/api/v1/notifications/unread-countuserS0Badge counter
GET/api/v1/notification-preferencesuserS0Read own prefs
PATCH/api/v1/notification-preferencesuserS0Update prefs
GET/api/v1/notification-preferences/{userId}adminS0Read another user's (compliance)
POST/api/v1/notifications/sendserviceS0Internal send
POST/api/v1/notifications/send-batchserviceS1Batched send
GET/api/v1/notification-templatesadmin/authorS0List templates
GET/api/v1/notification-templates/{key}admin/authorS0Detail (latest active)
GET/api/v1/notification-templates/{key}/versionsadmin/authorS0Version history
POST/api/v1/notification-templatesadmin/authorS0Create draft
PATCH/api/v1/notification-templates/{id}admin/authorS0Update draft
POST/api/v1/notification-templates/{id}/publishadminS0Activate
POST/api/v1/notification-templates/{id}/deprecateadminS0Deprecate
POST/api/v1/notification-templates/{id}/previewadmin/authorS0Render preview
POST/api/v1/notification-templates/{id}/test-sendadmin/authorS0Test to self
GET/api/v1/webhook-subscriptionstenant-adminS2List webhook subs
POST/api/v1/webhook-subscriptionstenant-adminS2Create sub
PATCH/api/v1/webhook-subscriptions/{id}tenant-adminS2Update
POST/api/v1/webhook-subscriptions/{id}/rotate-secrettenant-adminS2Rotate secret
DELETE/api/v1/webhook-subscriptions/{id}tenant-adminS2Delete
GET/api/v1/webhook-subscriptions/{id}/deliveriestenant-adminS2Delivery log
POST/api/v1/webhook-subscriptions/{id}/deliveries/{deliveryId}/replaytenant-adminS2Replay delivery
POST/api/v1/providers/ses/bounceprovider-hmacS0SES SNS bounce ingest
POST/api/v1/providers/twilio/statusprovider-hmacS3Twilio delivery receipt
POST/api/v1/providers/fcm/feedbackprovider-hmacS1FCM feedback
WS/api/v1/notifications/streamuser (JWT query or subprotocol)S0In-app push channel
GET/api/v1/notifications/{id}/auditadminS0Full audit trail

Detailed Contracts

GET /api/v1/notifications

Query params:

  • cursor (string, optional)
  • limit (int, default 20, max 100)
  • unread (bool, default false)
  • category (csv of NotificationCategory)
  • since (ISO date)

Response 200:

{
"success": true,
"data": {
"items": [
{
"id": "01HXYZ...",
"templateKey": "enrollment.created.inapp",
"category": "academic",
"title": "You're enrolled in Intro to Algebra",
"body": "<markdown body>",
"iconUrl": "https://cdn.ghasi.dev/icons/enrollment.svg",
"actions": [
{"label": "Open course", "href": "/learn/courses/01HX..."}
],
"priority": "normal",
"createdAt": "2026-04-15T09:12:03Z",
"readAt": null
}
],
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA0LTE1In0="
}
}

PATCH /api/v1/notifications/{id}/read

Body: (none) Response 200: { "success": true, "data": { "id": "...", "readAt": "2026-04-15T10:00:00Z" } } Errors:

  • 404 if not found or not belonging to caller
  • 409 if already read (idempotent; returns 200 but with same readAt)

GET /api/v1/notification-preferences

Response 200:

{
"success": true,
"data": {
"userId": "01HU...",
"language": "en-KE",
"channels": {
"academic": {"email": "digest", "sms": "off", "push": "instant", "inapp": "instant"},
"billing": {"email": "instant", "sms": "off", "push": "off", "inapp": "instant"},
"marketing": {"email": "off", "sms": "off", "push": "off", "inapp": "off"},
"security": {"email": "instant", "sms": "instant", "push": "instant", "inapp": "instant"},
"social": {"email": "digest", "sms": "off", "push": "instant", "inapp": "instant"},
"system": {"email": "instant", "sms": "off", "push": "instant", "inapp": "instant"},
"compliance": {"email": "instant", "sms": "off", "push": "off", "inapp": "instant"}
},
"quietHours": {
"enabled": true,
"startLocal": "22:00",
"endLocal": "07:00",
"timezone": "Africa/Nairobi",
"overrideForCritical": true
},
"digestSchedule": {
"dailyDigestAtLocal": "08:00"
},
"version": 14
}
}

PATCH /api/v1/notification-preferences

Body: RFC 7396 merge-patch of the document (partial).

Headers: Idempotency-Key required (EP-10).

Response 200: Full updated preferences. Errors:

  • 409 on version conflict if If-Match: <version> sent
  • 422 if trying to disable security.email

POST /api/v1/notifications/send (internal)

Request:

{
"tenantId": "01HT...",
"userId": "01HU...",
"templateKey": "assignment.window.opened.email",
"variables": {
"assignmentTitle": "Week 3 Problem Set",
"dueDate": "2026-04-22T23:59:00Z",
"courseTitle": "Intro to Algebra",
"submitUrl": "https://learn.ghasi.dev/a/01HV..."
},
"locale": "en-KE",
"priority": "normal",
"scheduledFor": null,
"correlationId": "assign-window-01HV-01HU",
"sourceEvent": {
"type": "assignment.window.opened.v1",
"id": "01HW..."
},
"channels": ["email", "push", "inapp"]
}

Response 202:

{
"success": true,
"data": {
"accepted": [
{"notificationId": "01HX1...", "channel": "email"},
{"notificationId": "01HX2...", "channel": "push"},
{"notificationId": "01HX3...", "channel": "inapp"}
]
}
}

Errors:

  • 422 with structured errors on unknown template, bad variables, invalid locale
  • 429 on rate limit
  • 404 if user/tenant not found (eventually consistent — retry advised)

POST /api/v1/notification-templates/{id}/preview

Body:

{
"locale": "en-KE",
"variables": { ... }
}

Response 200:

{
"success": true,
"data": {
"subject": "You're enrolled in Intro to Algebra",
"bodyHtml": "<!doctype html>...",
"bodyText": "plain text fallback",
"renderWarnings": []
}
}

GET /api/v1/notifications/stream (WebSocket)

Subprotocol: ghasi-notif.v1 Auth: JWT via Authorization: Bearer during handshake OR via Sec-WebSocket-Protocol: ghasi-notif.v1, Bearer.<jwt>.

Messages server→client:

{ "type": "notification", "payload": { ...NotificationSummary } }
{ "type": "read-sync", "payload": { "ids": ["01HX..."], "readAt": "..." } }
{ "type": "ping" }

Messages client→server:

{ "type": "ack", "ids": ["01HX..."] }
{ "type": "read", "ids": ["01HX..."] }
{ "type": "pong" }

Heartbeat: ping every 30s; server closes after 90s of no pong.

OpenAPI 3.1 Spec

Canonical committed artifact: monorepo services/notification-service/openapi.json. CI contract tests validate route coverage. Path-based major versioning remains /api/v1.

Versioning Policy

  • Path-based major versioning (/api/v1, /api/v2).
  • Additive changes within same major: new optional fields, new endpoints. Never break existing contracts.
  • Deprecated endpoints: respond with Deprecation: true header and Sunset: <date> per RFC 8594.

Error Codes (domain-level)

CodeHTTPMeaning
NOTIF.TEMPLATE_NOT_FOUND404Template key doesn't exist or no active version
NOTIF.TEMPLATE_INVALID_VARIABLES422Missing required vars or type mismatch
NOTIF.PREFERENCE_LOCKED_CATEGORY422Cannot disable regulated category
NOTIF.CHANNEL_ADDRESS_MISSING422User lacks email/phone for requested channel
NOTIF.RATE_LIMIT_EXCEEDED429Per-tenant or per-user
NOTIF.BUDGET_EXHAUSTED429Tenant's billing budget gate
NOTIF.SUPPRESSED200 (accepted-but-not-sent)Returned as notification status, not HTTP error
NOTIF.WEBHOOK.INVALID_URL422Non-HTTPS webhook URL (tenant outbound subscription)
NOTIF.WEBHOOK.UNREACHABLE424During validation ping

CORS

  • Allowed origins: tenant-specific domains registered in tenant-service.
  • Credentials allowed.
  • Preflight cached 10 min.

Rate Limits (public endpoints)

  • GET /notifications: 60 req/min per user
  • PATCH /notifications/{id}/read: 300 req/min per user
  • PATCH /notification-preferences: 10 req/min per user
  • POST /notifications/send (internal): 2000 req/s per service identity
  • WebSocket: 1 connection per user per device