API_CONTRACTS — notification-service
Sibling: APPLICATION_LOGIC · DOMAIN_MODEL · SECURITY_MODEL · EVENT_SCHEMAS
Strategic anchors: 05 API Design · standards/NAMING · standards/ERROR_CODES
All endpoints are versioned at /api/v1. Request/response bodies are JSON; errors are RFC 7807 Problem+JSON. Timestamps are ISO-8601 UTC unless explicitly noted as a local date (YYYY-MM-DD). All mutating endpoints require Idempotency-Key and propagate traceparent.
This service is fronted by:
bff-backoffice-service— staff: read feed, resend, manage templates, manage channels, view delivery audit, manage suppressions, manage preferences (on behalf of guest with consent).bff-tenant-booking-service— guest funnel: read their in-app feed, mark read, update preferences, opt-out via token (no auth).- internal
iam-service,billing-service,tenant-servicecall the internal notifications API directly via service-to-service mTLS for transactional notifications (password reset, invoice generated, invitation). - vendor webhooks are public but HMAC-validated.
1. Common request headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <jwt> | yes (except webhooks and opt-out/:token) | Issued by iam-service for staff; service-to-service JWT for internal callers |
X-Tenant-Id: tnt_… | yes (except webhooks and opt-out/:token) | Cross-checked against JWT; mismatch → 403 MELMASTOON.TENANT.MISMATCH |
Idempotency-Key: <ULID> | yes for POST/PATCH | 24h dedupe window |
If-Match: <version> | yes for OCC PATCH (templates, preferences, channels) | OCC version; mismatch → 412 |
Accept-Language: <bcp47> | recommended | Drives Problem+JSON title localization |
traceparent: 00-…-…-01 | yes (set by gateway) | W3C trace propagation |
2. Common error envelope (RFC 7807 + extensions)
{
"type": "https://errors.melmastoon.com/NOTIFICATION/TEMPLATE_NOT_FOUND",
"title": "The requested template version could not be resolved.",
"status": 404,
"code": "MELMASTOON.NOTIFICATION.TEMPLATE_NOT_FOUND",
"detail": "No active version found for template key 'reservation.confirmed.email' on tenant tnt_01H… for locale 'ps-AF'.",
"instance": "/api/v1/notifications",
"traceId": "01H3Z4WK7…",
"tenantId": "tnt_01H…",
"violations": [
{ "field": "templateKey", "rule": "no_active_version" }
]
}
Common status mapping:
| Status | Used for |
|---|---|
400 | validation, malformed request |
401 | missing/invalid JWT or invalid webhook HMAC |
403 | tenant mismatch, RBAC/ABAC denial, HITL required |
404 | not found (or RLS-hidden) |
409 | OCC, illegal state transition, duplicate suppression release |
412 | OCC If-Match mismatch |
422 | domain invariant violation (sender id missing, opted-out target on transactional bypass attempt, WhatsApp template not approved with no fallback) |
429 | rate limit (per-tenant or per-recipient) |
500 | unhandled |
503 | downstream (Pub/Sub, Postgres, vendor) unavailable |
3. Endpoints
3.1 POST /api/v1/notifications
Enqueue a single notification. Used by internal services and by staff (for ad-hoc sends).
Request
{
"templateKey": "reservation.confirmed.email",
"category": "transactional",
"priority": "normal",
"channel": "email",
"locale": "ps-AF",
"recipient": {
"by": "guestId",
"guestId": "gst_01H3Z…AHMED"
},
"variables": {
"guestName": "احمد خان",
"reservationCode": "MELM-2026-04-001234",
"stayWindow": { "start": "2026-04-25", "end": "2026-04-28" },
"roomLabel": "Suite 304",
"balanceDue": { "amountMicro": "120000000", "currency": "USD" },
"propertyName": "هتل د کابل",
"checkInTime": "14:00",
"policyExcerptKey": "cancellation.standard"
},
"scheduledFor": null,
"sourceEvent": { "id": "01J3Z…", "type": "melmastoon.reservation.confirmed.v1" },
"channelOverrides": null,
"tags": ["reservation:rsv_01H3Z…AHMED", "saga:booking"]
}
Response 202 (request accepted; async enqueue)
{
"notificationId": "ntf_01J4A…",
"status": "queued",
"scheduledFor": null,
"channel": "email",
"templateVersion": { "id": "tpv_01H…", "semver": "1.4.2", "key": "reservation.confirmed.email" },
"links": {
"self": "/api/v1/notifications/ntf_01J4A…",
"deliveryAttempts": "/api/v1/notifications/ntf_01J4A…/delivery-attempts"
}
}
Response 202 (suppressed)
{
"notificationId": "ntf_01J4A…",
"status": "suppressed",
"suppression": { "reason": "opted_out", "channel": "email", "since": "2026-03-12T10:00:00Z" }
}
Errors
| Code | Status |
|---|---|
MELMASTOON.NOTIFICATION.TEMPLATE_NOT_FOUND | 404 |
MELMASTOON.NOTIFICATION.RECIPIENT_INVALID | 422 |
MELMASTOON.NOTIFICATION.SENDER_ID_MISSING | 422 |
MELMASTOON.NOTIFICATION.WHATSAPP_TEMPLATE_NOT_APPROVED | 422 |
MELMASTOON.NOTIFICATION.RATE_LIMITED | 429 |
MELMASTOON.NOTIFICATION.CHANNEL_DISABLED | 422 |
MELMASTOON.NOTIFICATION.LOCALE_FALLBACK_EXHAUSTED | 422 |
MELMASTOON.GENERAL.VALIDATION_FAILED | 400 |
MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED | 409 |
3.2 POST /api/v1/notifications/batch
Enqueue many notifications under one batch (marketing blasts, post-stay surveys). Synchronous create-then-async-dispatch.
Request
{
"name": "Spring 2026 returning-guest promo (PK markets)",
"templateKey": "marketing.promo_2026_spring.whatsapp",
"category": "marketing",
"channel": "whatsapp",
"locale": "ur-PK",
"recipientQuery": {
"type": "segment",
"segmentId": "seg_01J…RETURNING_PK",
"estimateOnly": false
},
"variables": { "promoCode": "SPRING25", "expiresOn": "2026-05-31" },
"scheduledFor": "2026-04-23T08:00:00Z",
"rateLimit": { "perSecond": 50 }
}
Response 202
{
"batchId": "dbt_01J4B…",
"status": "scheduled",
"estimatedRecipients": 1843,
"scheduledFor": "2026-04-23T08:00:00Z",
"links": { "self": "/api/v1/notification-batches/dbt_01J4B…" }
}
Errors: as above plus MELMASTOON.NOTIFICATION.BATCH_LIMIT_EXCEEDED (429), MELMASTOON.TENANT.PLAN_LIMIT_EXCEEDED (402).
3.3 GET /api/v1/notifications/{notificationId}
Read a single notification (status, history, render snapshot).
Response 200
{
"id": "ntf_01J4A…",
"tenantId": "tnt_01H…",
"templateKey": "reservation.confirmed.email",
"templateVersion": { "id": "tpv_01H…", "semver": "1.4.2" },
"channel": "email",
"category": "transactional",
"priority": "normal",
"locale": "ps-AF",
"status": "delivered",
"recipientRef": { "id": "rcp_01H…", "addressKindHash": "sha256:…" },
"renderSnapshot": {
"subject": "د څښتن لپاره ستاسو د بکنګ تایید — MELM-2026-04-001234",
"bodyFormat": "html",
"bodyRef": "gs://melmastoon-notifications-prod/tnt_01H…/rendered/2026/04/22/ntf_01J4A….html",
"attachments": [],
"checksum": "sha256:…"
},
"scheduledFor": null,
"queuedAt": "2026-04-22T15:32:18.211Z",
"dispatchedAt": "2026-04-22T15:32:19.044Z",
"deliveredAt": "2026-04-22T15:32:24.901Z",
"deliveryAttempts": [
{
"id": "dat_01J…",
"attemptNumber": 1,
"vendor": "sendgrid",
"vendorMessageId": "smg_qz…",
"outcome": "accepted",
"vendorStatus": "delivered",
"latencyMs": 833
}
],
"version": 4
}
3.4 GET /api/v1/notifications
List/search.
Query params: recipientId, status (csv), channel (csv), category, templateKey, from, to, tag, cursor, limit (max 100).
Response 200
{
"items": [ /* notifications */ ],
"nextCursor": "eyJ0Ijo…"
}
3.5 GET /api/v1/notifications/{id}/delivery-attempts
Per-attempt audit, including raw vendor responses (stored as references, fetched if expand=raw=true).
3.6 POST /api/v1/notifications/{id}/resend
Resend a previously sent notification (creates a sibling). The new notification carries causationId referencing the original.
Request
{ "channelOverride": "whatsapp", "localeOverride": "ur-PK" }
Response 202 — same shape as 3.1.
3.7 PATCH /api/v1/notifications/{id}/read (in-app)
Mark in-app notification(s) as read. Body accepts an optional bulk ids for the multi-mark variant PATCH /api/v1/notifications/read.
Response 204
3.8 GET /api/v1/notifications/feed
Read a recipient's in-app feed for the calling user (via JWT subject) or for an explicit recipientId (staff scope).
Query: cursor, limit, unreadOnly=true|false, since.
Response 200
{
"items": [
{
"id": "ntf_01J…",
"category": "operational",
"deliveredAt": "2026-04-22T15:32:24.901Z",
"readAt": null,
"title": "Booking confirmed",
"preview": "Your stay 25–28 Apr at Suite 304 is confirmed.",
"deepLink": "melmastoon://reservations/rsv_01H…AHMED"
}
],
"unreadCount": 7,
"nextCursor": null
}
3.9 WS /api/v1/notifications/feed/stream
WebSocket stream for live in-app notifications. Auth via Sec-WebSocket-Protocol: bearer.<jwt>. Server pushes JSON envelopes:
{ "type": "feed.upsert", "notification": { /* feed item shape */ } }
{ "type": "feed.read", "ids": ["ntf_…"], "at": "…" }
{ "type": "ping" }
4. Templates
4.1 GET /api/v1/notification-templates
List templates visible to tenant (platform globals + tenant overrides).
Query: key, channel, category, status, cursor, limit.
4.2 GET /api/v1/notification-templates/{templateId}
Read template + all versions metadata.
4.3 POST /api/v1/notification-templates
Create a new template (or new draft version of an existing key). Tenant-scoped overrides are created with key referencing a platform-global key.
Request (new tenant override of reservation.confirmed.email)
{
"key": "reservation.confirmed.email",
"channel": "email",
"category": "transactional",
"ownerScope": "tenant",
"newVersion": {
"semver": "1.0.0-tnt.1",
"locales": {
"ps-AF": {
"subject": "ستاسو د څښتن لپاره د بکنګ تایید — {{reservationCode}}",
"preheader": "د {{stayWindow.start}} څخه تر {{stayWindow.end}} پورې",
"body": "<mjml>… {{> hotelHeader}} … </mjml>",
"bodyFormat": "mjml",
"direction": "rtl"
},
"ur-PK": { /* … */ },
"en-US": { /* … */ }
},
"variablesSchema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["guestName","reservationCode","stayWindow","propertyName"],
"properties": {
"guestName": { "type": "string", "maxLength": 200 },
"reservationCode":{ "type": "string", "pattern": "^MELM-[0-9]{4}-[0-9]{2}-[0-9]{6}$" },
"stayWindow": { "type": "object", "properties": { "start": {"type":"string","format":"date"}, "end": {"type":"string","format":"date"} } },
"roomLabel": { "type": "string" },
"balanceDue": { "type": "object" },
"propertyName": { "type": "string" },
"checkInTime": { "type": "string" },
"policyExcerptKey":{ "type": "string" }
}
},
"rendererProfile": "mjml-handlebars-1",
"fallbackChain": ["en-US"],
"previewSamples": [
{ "locale": "ps-AF", "variables": { "guestName": "احمد", "reservationCode": "MELM-2026-04-001234", "stayWindow": {"start":"2026-04-25","end":"2026-04-28"}, "roomLabel":"Suite 304", "balanceDue":{"amountMicro":"120000000","currency":"USD"}, "propertyName":"هتل د کابل", "checkInTime":"14:00", "policyExcerptKey":"cancellation.standard" } }
]
}
}
Response 201
{
"templateId": "tpl_01J…",
"versionId": "tpv_01J…",
"status": "draft"
}
4.4 POST /api/v1/notification-templates/{id}/versions/{versionId}/publish
Publish a draft. Activates this version; archives the previously-active version.
Request
{ "approverUserId": "usr_01H…", "publishNotes": "Adds policy excerpt." }
If version.source = 'ai_drafted', approverUserId is mandatory; otherwise → 403 MELMASTOON.AI.HITL_REQUIRED.
4.5 POST /api/v1/notification-templates/{id}/versions/{versionId}/archive
Archive a version. The active version cannot be archived directly — first publish another or archive the whole template.
4.6 POST /api/v1/notification-templates/{id}/versions/{versionId}/preview
Render the version against a previewSample or supplied variables and return the rendered output (no send). Useful for staff preview.
Request
{ "locale": "ps-AF", "variables": { /* … */ }, "themeOverride": null }
Response 200
{
"subject": "...",
"preheader": "...",
"bodyFormat": "html",
"body": "<html>...</html>",
"direction": "rtl",
"warnings": []
}
4.7 POST /api/v1/notification-templates/{id}/versions/{versionId}/test-send
Send a test notification to an internal recipient (the calling user, or an explicit allowlisted address — must belong to the tenant). Categorised internally as system and bypasses suppression for the chosen address.
5. Recipients & preferences
5.1 GET /api/v1/recipients/{recipientId}
Read recipient projection + preferences + suppression view.
5.2 PATCH /api/v1/notification-preferences/{recipientId}
Update preferences (channel allow-list, locale, timezone, quiet hours, marketing consent).
Request
{
"locale": "ps-AF",
"timezone": "Asia/Kabul",
"channels": {
"email": { "enabled": true, "categories": ["transactional","operational","security"] },
"sms": { "enabled": true, "categories": ["transactional","operational","security"] },
"whatsapp": { "enabled": true, "categories": ["transactional","operational","reminder"] },
"push": { "enabled": true, "categories": ["operational","reminder"] },
"inapp": { "enabled": true, "categories": ["transactional","operational","security","reminder"] }
},
"quietHours": { "start": "22:00", "end": "07:00", "timezone": "Asia/Kabul" },
"marketingConsent": { "email": true, "sms": false, "whatsapp": true, "push": false }
}
Response 200: updated RecipientPreferences shape (see DOMAIN_MODEL §3.3).
5.3 POST /api/v1/notification-preferences/opt-out/{token} (PUBLIC)
Single-tap opt-out from email footer / SMS link. Token is single-use; channel is encoded in the token claims.
Response 200
{ "status": "opted_out", "channel": "email", "tenantId": "tnt_01H…" }
5.4 POST /api/v1/recipients/{recipientId}/preferences/opt-out-tokens
Mint a new opt-out token (used internally when generating email footers).
6. Suppressions
6.1 GET /api/v1/suppressions
List suppression rows. Query: channel, reason, addressHash, cursor, limit.
6.2 POST /api/v1/suppressions
Manually add a suppression row.
{ "channel": "email", "address": "guest@example.com", "reason": "manual", "notes": "Owner request 2026-04-22." }
6.3 POST /api/v1/suppressions/{id}/release
Release a suppression. Requires reason + role = OWNER or BILLING_ADMIN.
7. Channel configuration
7.1 GET /api/v1/notification-channels
List per-tenant channel configs.
7.2 PATCH /api/v1/notification-channels/{channelId}
Update vendor selection, sender, branding overrides, rate limits.
Request
{
"vendor": "twilio",
"fallbackVendor": "infobip",
"sender": { "kind": "sms", "senderId": "MELMHTL", "country": "PK", "registrationRef": "reg_PTA_2026_…" },
"rateLimit": { "perSecond": 200, "perMinute": 6000, "perDayPerRecipient": 5 },
"status": "active"
}
7.3 POST /api/v1/notification-channels/{channelId}/credentials
Create a new credential (returns the Secret Manager reference). The plaintext secret is provided once and never returned.
7.4 POST /api/v1/notification-channels/{channelId}/credentials/{credId}/rotate
Rotate a credential. New credential becomes active; previous becomes superseded after a 24h overlap window.
7.5 POST /api/v1/notification-channels/{channelId}/probe
Trigger an on-demand health probe (rate-limited).
7.6 POST /api/v1/notification-channels/{channelId}/whatsapp-templates/sync
For WhatsApp channels — pull approved business-template list from Meta and update whatsappTemplateRefs on relevant TemplateVersion rows.
8. Webhooks (vendor → us)
8.1 POST /api/v1/webhooks/vendors/{vendor} (PUBLIC, HMAC-validated)
vendor∈{ sendgrid, mailgun, ses, twilio, infobip, meta_whatsapp, fcm, apns_native_relay }.- Headers vary by vendor; HMAC verifier picks the right algorithm.
- Body is forwarded to
WebhookInboundraw store; persistence is synchronous, processing is async.
Response 204 on accepted; 401 on invalid HMAC; 400 on parse failure.
Idempotent — replaying the same payload yields the same end-state.
9. Internal admin
| Endpoint | Purpose |
|---|---|
GET /api/v1/internal/health | Liveness/readiness (k8s/Cloud Run probe). |
GET /api/v1/internal/trigger-map | Read-only view of the active trigger map; admin-only. |
POST /api/v1/internal/trigger-map/reload | Force a hot-reload of the trigger map cache. |
GET /api/v1/internal/queue-stats | Backlog counts per channel + worker. |
GET /api/v1/internal/dlq | List recent DLQ entries; download payload. |
POST /api/v1/internal/dlq/{id}/retry | Re-inject a DLQ message. |
These require iam:platform_engineer and are exposed only on the internal mesh.
10. Rate limits (per route)
| Endpoint | Limit |
|---|---|
POST /api/v1/notifications | 100 req/s/tenant; 5 req/s/IP for staff origin |
POST /api/v1/notifications/batch | 5 req/min/tenant; max 1 active batch per category |
GET /api/v1/notifications/feed | 60 req/min/recipient |
WS /api/v1/notifications/feed/stream | 1 active conn per recipient + 5 across recipients of same tenant |
POST /api/v1/notification-preferences/opt-out/{token} | 30 req/min/IP |
POST /api/v1/webhooks/vendors/{vendor} | 1000 req/s/vendor |
429 responses set Retry-After and X-RateLimit-* headers per 05 §11.
11. OpenAPI
The authoritative spec lives at services/notification-service/openapi/openapi.v1.yaml. CI fails any drift between this spec and the implementation (contract test in TESTING_STRATEGY §5).
12. Authentication & authorisation matrix
| Route prefix | Caller class | Auth | Scope (claims) |
|---|---|---|---|
POST /api/v1/notifications | internal services, bff-backoffice-service | mTLS + service JWT, or staff JWT | notifications:write |
POST /api/v1/notifications/batch | bff-backoffice-service (staff: marketing manager) | staff JWT | notifications:batch |
GET /api/v1/notifications | bff-backoffice-service | staff JWT | notifications:read |
GET /api/v1/notifications/feed | bff-tenant-booking-service, bff-backoffice-service | guest session token or staff JWT | notifications:feed.read.self (guest) or notifications:feed.read.any (staff) |
PATCH /api/v1/notifications/{id}/read | feed BFFs | as above | as above |
PATCH /api/v1/notification-preferences/{id} | feed BFFs (self) or bff-backoffice-service (with consent record) | as above | notifications:preferences.write.self or …any |
POST /api/v1/notification-preferences/opt-out/{token} | end user | none (token-bearer) | n/a |
*/notification-templates/* | bff-backoffice-service | staff JWT | `notifications:templates.read |
*/notification-channels/* | bff-backoffice-service | staff JWT (role: OWNER/PLATFORM_ENG) | `notifications:channels.read |
POST /api/v1/webhooks/vendors/{vendor} | vendor | HMAC | n/a |
*/internal/* | platform | mTLS + admin JWT | notifications:internal |
Detailed RBAC mapping in SECURITY_MODEL §3.