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)
| Header | Required on | Behavior |
|---|---|---|
Idempotency-Key | PATCH /api/v1/notification-preferences, POST /api/v1/notifications/send, POST /api/v1/webhook-subscriptions | Retries 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:
| Header | Purpose |
|---|---|
X-Notification-Internal-Secret | Shared 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
| Method | Path | Auth | Slice | Purpose |
|---|---|---|---|---|
| GET | /api/v1/notifications | user | S0 | List in-app feed |
| PATCH | /api/v1/notifications/{id}/read | user | S0 | Mark as read |
| POST | /api/v1/notifications/read-all | user | S0 | Mark all read |
| GET | /api/v1/notifications/unread-count | user | S0 | Badge counter |
| GET | /api/v1/notification-preferences | user | S0 | Read own prefs |
| PATCH | /api/v1/notification-preferences | user | S0 | Update prefs |
| GET | /api/v1/notification-preferences/{userId} | admin | S0 | Read another user's (compliance) |
| POST | /api/v1/notifications/send | service | S0 | Internal send |
| POST | /api/v1/notifications/send-batch | service | S1 | Batched send |
| GET | /api/v1/notification-templates | admin/author | S0 | List templates |
| GET | /api/v1/notification-templates/{key} | admin/author | S0 | Detail (latest active) |
| GET | /api/v1/notification-templates/{key}/versions | admin/author | S0 | Version history |
| POST | /api/v1/notification-templates | admin/author | S0 | Create draft |
| PATCH | /api/v1/notification-templates/{id} | admin/author | S0 | Update draft |
| POST | /api/v1/notification-templates/{id}/publish | admin | S0 | Activate |
| POST | /api/v1/notification-templates/{id}/deprecate | admin | S0 | Deprecate |
| POST | /api/v1/notification-templates/{id}/preview | admin/author | S0 | Render preview |
| POST | /api/v1/notification-templates/{id}/test-send | admin/author | S0 | Test to self |
| GET | /api/v1/webhook-subscriptions | tenant-admin | S2 | List webhook subs |
| POST | /api/v1/webhook-subscriptions | tenant-admin | S2 | Create sub |
| PATCH | /api/v1/webhook-subscriptions/{id} | tenant-admin | S2 | Update |
| POST | /api/v1/webhook-subscriptions/{id}/rotate-secret | tenant-admin | S2 | Rotate secret |
| DELETE | /api/v1/webhook-subscriptions/{id} | tenant-admin | S2 | Delete |
| GET | /api/v1/webhook-subscriptions/{id}/deliveries | tenant-admin | S2 | Delivery log |
| POST | /api/v1/webhook-subscriptions/{id}/deliveries/{deliveryId}/replay | tenant-admin | S2 | Replay delivery |
| POST | /api/v1/providers/ses/bounce | provider-hmac | S0 | SES SNS bounce ingest |
| POST | /api/v1/providers/twilio/status | provider-hmac | S3 | Twilio delivery receipt |
| POST | /api/v1/providers/fcm/feedback | provider-hmac | S1 | FCM feedback |
| WS | /api/v1/notifications/stream | user (JWT query or subprotocol) | S0 | In-app push channel |
| GET | /api/v1/notifications/{id}/audit | admin | S0 | Full 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: trueheader andSunset: <date>per RFC 8594.
Error Codes (domain-level)
| Code | HTTP | Meaning |
|---|---|---|
NOTIF.TEMPLATE_NOT_FOUND | 404 | Template key doesn't exist or no active version |
NOTIF.TEMPLATE_INVALID_VARIABLES | 422 | Missing required vars or type mismatch |
NOTIF.PREFERENCE_LOCKED_CATEGORY | 422 | Cannot disable regulated category |
NOTIF.CHANNEL_ADDRESS_MISSING | 422 | User lacks email/phone for requested channel |
NOTIF.RATE_LIMIT_EXCEEDED | 429 | Per-tenant or per-user |
NOTIF.BUDGET_EXHAUSTED | 429 | Tenant's billing budget gate |
NOTIF.SUPPRESSED | 200 (accepted-but-not-sent) | Returned as notification status, not HTTP error |
NOTIF.WEBHOOK.INVALID_URL | 422 | Non-HTTPS webhook URL (tenant outbound subscription) |
NOTIF.WEBHOOK.UNREACHABLE | 424 | During 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 userPATCH /notifications/{id}/read: 300 req/min per userPATCH /notification-preferences: 10 req/min per userPOST /notifications/send(internal): 2000 req/s per service identity- WebSocket: 1 connection per user per device