Skip to main content

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-service call 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

HeaderRequiredNotes
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/PATCH24h dedupe window
If-Match: <version>yes for OCC PATCH (templates, preferences, channels)OCC version; mismatch → 412
Accept-Language: <bcp47>recommendedDrives Problem+JSON title localization
traceparent: 00-…-…-01yes (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:

StatusUsed for
400validation, malformed request
401missing/invalid JWT or invalid webhook HMAC
403tenant mismatch, RBAC/ABAC denial, HITL required
404not found (or RLS-hidden)
409OCC, illegal state transition, duplicate suppression release
412OCC If-Match mismatch
422domain invariant violation (sender id missing, opted-out target on transactional bypass attempt, WhatsApp template not approved with no fallback)
429rate limit (per-tenant or per-recipient)
500unhandled
503downstream (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

CodeStatus
MELMASTOON.NOTIFICATION.TEMPLATE_NOT_FOUND404
MELMASTOON.NOTIFICATION.RECIPIENT_INVALID422
MELMASTOON.NOTIFICATION.SENDER_ID_MISSING422
MELMASTOON.NOTIFICATION.WHATSAPP_TEMPLATE_NOT_APPROVED422
MELMASTOON.NOTIFICATION.RATE_LIMITED429
MELMASTOON.NOTIFICATION.CHANNEL_DISABLED422
MELMASTOON.NOTIFICATION.LOCALE_FALLBACK_EXHAUSTED422
MELMASTOON.GENERAL.VALIDATION_FAILED400
MELMASTOON.SYNC.IDEMPOTENCY_KEY_REUSED409

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 WebhookInbound raw 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

EndpointPurpose
GET /api/v1/internal/healthLiveness/readiness (k8s/Cloud Run probe).
GET /api/v1/internal/trigger-mapRead-only view of the active trigger map; admin-only.
POST /api/v1/internal/trigger-map/reloadForce a hot-reload of the trigger map cache.
GET /api/v1/internal/queue-statsBacklog counts per channel + worker.
GET /api/v1/internal/dlqList recent DLQ entries; download payload.
POST /api/v1/internal/dlq/{id}/retryRe-inject a DLQ message.

These require iam:platform_engineer and are exposed only on the internal mesh.


10. Rate limits (per route)

EndpointLimit
POST /api/v1/notifications100 req/s/tenant; 5 req/s/IP for staff origin
POST /api/v1/notifications/batch5 req/min/tenant; max 1 active batch per category
GET /api/v1/notifications/feed60 req/min/recipient
WS /api/v1/notifications/feed/stream1 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 prefixCaller classAuthScope (claims)
POST /api/v1/notificationsinternal services, bff-backoffice-servicemTLS + service JWT, or staff JWTnotifications:write
POST /api/v1/notifications/batchbff-backoffice-service (staff: marketing manager)staff JWTnotifications:batch
GET /api/v1/notificationsbff-backoffice-servicestaff JWTnotifications:read
GET /api/v1/notifications/feedbff-tenant-booking-service, bff-backoffice-serviceguest session token or staff JWTnotifications:feed.read.self (guest) or notifications:feed.read.any (staff)
PATCH /api/v1/notifications/{id}/readfeed BFFsas aboveas above
PATCH /api/v1/notification-preferences/{id}feed BFFs (self) or bff-backoffice-service (with consent record)as abovenotifications:preferences.write.self or …any
POST /api/v1/notification-preferences/opt-out/{token}end usernone (token-bearer)n/a
*/notification-templates/*bff-backoffice-servicestaff JWT`notifications:templates.read
*/notification-channels/*bff-backoffice-servicestaff JWT (role: OWNER/PLATFORM_ENG)`notifications:channels.read
POST /api/v1/webhooks/vendors/{vendor}vendorHMACn/a
*/internal/*platformmTLS + admin JWTnotifications:internal

Detailed RBAC mapping in SECURITY_MODEL §3.