Notification Service API Reference
Version: 1.5.0 Date: 2026-04-19 Service: notification-serviceRepository: Hello-World-Co-Op/notification-servicePort: 3100 Epic: PLATFORM-002 (notification microservice — stable)
Overview
The notification-service is the standalone transactional email microservice for the Hello World DAO and FounderyOS products. It provides a single REST surface for dispatching branded emails across both products via Resend, with dual-brand support, 14 template types, and a CAN-SPAM–compliant digest flow.
- Dual-brand — every send carries a
domainfield (helloworlddao.comorfounderyos.dev). The service selects the correct From address, logo, and footer copy automatically. Callers never embed brand assets. - 14 template types — 13 transactional + 1 opt-in digest, all validated by Zod schemas server-side.
- Attachment support — base64-encoded attachments (max 10, 25 MiB each).
event-remindergenerates a server-side ICS file automatically; callers do not need to produce one. - Fail-closed auth —
SERVICE_TOKENunset at boot →503on all requests, preventing an open relay. - Graceful degradation — callers absorb delivery failures; a send error never fails the caller's primary business flow.
Current Status
Stable (PLATFORM-002.1 through .5, plus BL-196, BL-228, BL-231):
POST /api/v1/send— single-email dispatch endpointGET /api/v1/health— liveness probe- 14 template types (see Template Catalog)
X-Request-Idmiddleware (BL-213) — UUID correlation across caller logs and service logs- Structured 401 audit log (BL-217) —
auth.unauthorizedevent with SHA-256 token prefix - Constant-time bearer auth (BL-218) —
crypto.timingSafeEqualwith fixed-width padding - Attachment support (BL-231) —
event-reminderICS generation; caller-supplied attachment pass-through - Digest preferences (PLATFORM-002.5) —
notification-digesttemplate with CAN-SPAM signed unsubscribe token
Pending:
- Gateway path
/notify/*routing (PLATFORM-002.6) — service moves behind the platform API gateway; no caller code changes required
Architecture
Network Position
Today, callers reach notification-service in-cluster on OVH Managed Kubernetes (hwdao-staging-mks):
oracle-bridge ──▶ notification-service (in-cluster DNS)
payment-gateway ──▶ notification-service (in-cluster DNS)
founderyos-core ──▶ notification-service (in-cluster DNS)In-cluster DNS name: http://notification-service.notification-service-staging.svc.cluster.local:3100
After PLATFORM-002.6, all callers route through the platform API gateway:
https://staging-apis.helloworlddao.com/notify/api/v1/send (staging)
https://apis.helloworlddao.com/notify/api/v1/send (production)The /notify prefix is stripped at the edge; the service path remains /api/v1/send. The NOTIFICATION_SERVICE_URL env var in each caller is the only value that changes — no code changes are required.
Authentication
Every route under /api/v1/* is guarded by bearer service-token auth except GET /api/v1/health.
Authorization: Bearer <TOKEN_NOTIFICATION_SERVICE>Token Source
- k8s Secret:
platform/service-tokenskeyTOKEN_NOTIFICATION_SERVICE(PLATFORM-006.4). - Pod env: injected as
SERVICE_TOKEN. - Callers: read from their own env as
NOTIFICATION_SERVICE_TOKEN. The token is never exposed to browsers.
The platform API gateway also validates this token at the ForwardAuth layer. A direct in-cluster caller (oracle-bridge) must present the same token even when bypassing the gateway.
Fail-Closed Semantics
If SERVICE_TOKEN is unset at boot, every request returns 503 { error: "service_unavailable" }. The service refuses all traffic rather than accepting an unauthenticated open relay.
Constant-Time Comparison (BL-218)
Bearer token comparison uses crypto.timingSafeEqual over fixed-width zero-padded buffers. This prevents a timing side-channel: a naive length-check short-circuit leaks the valid token length in measurable round-trip time. The implementation always compares at max(len_a, len_b, 1) bytes regardless of input length.
Structured 401 Audit Log (BL-217)
Every rejected request emits a WARN log record:
{
"event": "auth.unauthorized",
"request_id": "<uuid>",
"path": "/api/v1/send",
"source_ip_class": "private" | "loopback" | "public",
"token_prefix": "<first 8 hex chars of SHA-256(presented_token)> | null"
}The raw bearer value never appears in logs. token_prefix is null when no Authorization header was sent.
Unauthenticated Endpoints
| Path | Reason |
|---|---|
GET /api/v1/health | k8s liveness/readiness probes cannot forward a service token |
Request Correlation: X-Request-Id (BL-213)
Every request is stamped with a UUIDv4 correlation identifier. The id appears in:
- The
request_idfield of every response body (success and error alike). - The
X-Request-Idresponse header. - Every pino log line emitted during the request (
requestIdbinding on the child logger).
Client-Supplied IDs
Callers may supply their own X-Request-Id header for distributed trace continuity. The middleware accepts a value only if it is a well-formed RFC 4122 UUID (versions 1–5, case-insensitive). Any other value — including CRLF-injected strings or oversized values — is silently discarded and a fresh UUID is generated. This prevents log injection attacks.
X-Request-Id: aae9048a-31af-4f42-9758-48de4a0fbd8fThe accepted or generated id is echoed back in the X-Request-Id response header.
REST API Reference
Health
GET /api/v1/health
Lightweight liveness/readiness probe.
Auth: Public.
Response (200):
{ "status": "ok" }Send
POST /api/v1/send
Dispatch a single transactional or opt-in email.
Auth: Bearer service token.
Request body:
{
"type": "<TemplateType>", // required — see Template Catalog
"to": "user@example.com", // required — RFC 5322 email address
"domain": "helloworlddao.com", // required — "helloworlddao.com" | "founderyos.dev"
"data": { /* template-specific */ }, // required — validated per template type
"attachments": [ // optional — max 10, 25 MiB each decoded
{
"filename": "event.ics", // required, sanitised against path traversal
"content": "<base64>", // required, valid base64
"content_type": "text/calendar; charset=utf-8" // optional, printable ASCII only
}
]
}Callers MUST supply domain. The service rejects unknown domains with 400 invalid_request. The domain drives the From address and branding — the service never infers a brand from the recipient address.
Template-generated attachments (e.g. the ICS file for event-reminder) are prepended before caller-supplied attachments in the Resend payload.
Response — success (200):
{
"id": "stub_1776653665218_52qna5n5", // Resend message ID, or stub ID in dev/CI
"status": "sent",
"stubbed": true, // true when RESEND_API_KEY is unset
"request_id": "aae9048a-31af-4f42-9758-48de4a0fbd8f"
}stubbed: true means the HTTP call succeeded but NO email was delivered. Callers MUST NOT treat stubbed: true as a delivery confirmation.
Response — errors:
Every error body carries request_id for log correlation.
| Status | error | Meaning |
|---|---|---|
400 | invalid_request | Envelope failed Zod validation — bad type, bad email, unknown domain, bad attachment filename/size/encoding, too many attachments. details[] contains per-field messages. |
400 | invalid_template_data | data object failed the template's own Zod schema (e.g. missing reset_link). details[] contains per-field messages. |
400 | render_failed | Template threw during rendering — server-side bug. Error logged internally; no message returned to caller. |
401 | unauthorized | Bearer token absent, malformed, or wrong. |
403 | forbidden | Token valid but scoped for a different service. |
500 | send_failed | Resend rejected the send. Full error logged internally (may contain API key hints). |
503 | service_unavailable | SERVICE_TOKEN unset at boot — fail-closed. |
Happy-Path Send Flow
sequenceDiagram
autonumber
participant Caller as Caller<br/>(oracle-bridge / payment-gateway)
participant NS as notification-service
participant Resend
Caller->>NS: POST /api/v1/send<br/>Authorization: Bearer TOKEN<br/>X-Request-Id: <uuid> (optional)<br/>{ type, to, domain, data }
NS->>NS: requestIdMiddleware — accept/generate UUID,<br/>set req.id, bind pino child logger,<br/>echo X-Request-Id response header
NS->>NS: bearerAuth — constantTimeStringEqual(provided, SERVICE_TOKEN)<br/>emit auth.unauthorized WARN on mismatch (BL-217)
NS->>NS: Zod validate envelope (type, to, domain, attachments)
NS->>NS: getTemplate(type, domain, data) — Zod validate per-template data,<br/>render HTML + text + subject,<br/>generate template attachments (e.g. ICS for event-reminder)
NS->>NS: merge template attachments + caller attachments
NS->>Resend: emails.send({ from, to, subject, html, text, attachments })
Resend-->>NS: { data: { id: "re_..." }, error: null }
NS->>NS: pino INFO — email.sent (message_id, attachment_count, filenames)
NS-->>Caller: 200 { id, status: "sent", stubbed: false, request_id }Dual-Brand Support
The domain field selects the brand context. Each brand has its own:
- From address — e.g.
notifications@helloworlddao.comvsnotifications@founderyos.dev - Logo and color scheme — rendered into the HTML template at send time
- Footer copy — organization name, support URL, physical mailing address (CAN-SPAM §3(d)(1)(A))
domain value | Brand | Subdomain |
|---|---|---|
helloworlddao.com | Hello World DAO | notifications.helloworlddao.com |
founderyos.dev | FounderyOS | notifications.founderyos.dev |
DKIM is configured at the subdomain level for each brand. Callers select a brand by sending the correct domain — they never embed logo URLs or From addresses in the request.
Template Catalog
All 14 templates are validated by dedicated Zod schemas. Extra fields in data are silently ignored. Required fields marked R; optional fields marked O.
Transactional Templates (no unsubscribe link)
These templates are exempt from CAN-SPAM opt-out requirements because they are triggered by explicit user actions (account creation, payment, security events, etc.).
welcome
Brand(s): DAO + FOS
Sent immediately after a user creates an account.
| Field | R/O | Type | Notes |
|---|---|---|---|
user_name | R | string | Display name |
dashboard_url | O | string | URL to the user's dashboard |
password-reset
Brand(s): DAO + FOS
Password reset link sent to the email address on file.
| Field | R/O | Type | Notes |
|---|---|---|---|
reset_link | R | string | HTTPS reset URL with embedded token |
user_name | R | string | Display name |
expires_minutes | O | number | Token validity window; defaults to 60 if omitted |
password-changed
Brand(s): DAO + FOS
Security notification sent after a successful password change.
| Field | R/O | Type | Notes |
|---|---|---|---|
recipient_name | R | string | Display name |
changed_at | R | string | ISO-8601 timestamp |
security_url | R | string | URL to security settings / account review |
ip_hint | O | string | Approximate IP location hint for user recognition |
device_hint | O | string | Browser/device hint (e.g. "Chrome on macOS") |
verification
Brand(s): DAO + FOS
Email address verification. One of verification_link or verification_code is required.
| Field | R/O | Type | Notes |
|---|---|---|---|
user_name | R | string | Display name |
verification_link | R* | string | One-click verification URL |
verification_code | R* | string | 6–8 character code (alternative to link) |
expires_minutes | O | number | Validity window |
* At least one of verification_link or verification_code must be provided.
invitation
Brand(s): DAO + FOS
Workspace invitation sent to a prospective member.
| Field | R/O | Type | Notes |
|---|---|---|---|
inviter_name | R | string | Name of the person who sent the invite |
workspace_name | R | string | DAO or organization name |
invitation_link | R | string | HTTPS invite acceptance URL |
expires_days | O | number | Days until the invitation expires |
membership-confirm
Brand(s): DAO only
Sent after a member's SBT is activated.
| Field | R/O | Type | Notes |
|---|---|---|---|
member_name | R | string | Display name |
activated_at | R | string | ISO-8601 timestamp of SBT activation |
expires_at | R | string | ISO-8601 expiry timestamp |
membership_tier | O | string | Tier label |
dashboard_url | O | string | URL to membership dashboard |
sbt_token_id | O | string | ICRC-7 token ID of the issued SBT |
renewal-receipt
Brand(s): DAO only
Membership renewal receipt.
| Field | R/O | Type | Notes |
|---|---|---|---|
member_name | R | string | Display name |
amount | R | string | Amount paid (formatted, e.g. "$25.00") |
payment_date | R | string | ISO-8601 |
payment_id | R | string | Payment gateway UUID |
expiration_date | R | string | ISO-8601 new expiry |
receipt_number | R | string | Human-readable receipt reference |
currency | O | string | Default: USD |
payment_last4 | O | string | Last 4 digits of card (Stripe) |
dashboard_url | O | string | URL to membership dashboard |
payment-receipt
Brand(s): DAO + FOS
Generic payment receipt. Fired by payment-gateway on every pending → completed transition.
| Field | R/O | Type | Notes |
|---|---|---|---|
payment_id | R | string | Payment gateway UUID |
amount_display | R | string | Formatted amount (e.g. "$10.00") |
currency | R | string | ISO 4217 or crypto symbol |
type_label | R | string | Human-readable payment type |
user_name | R | string | Display name |
payment_date | R | string | ISO-8601 |
support_url | O | string | URL to support |
completed_at | O | string | ISO-8601; defaults to payment_date if omitted |
refund-confirmation
Brand(s): DAO + FOS
Refund confirmation. Fired by payment-gateway when a refund reaches terminal state.
| Field | R/O | Type | Notes |
|---|---|---|---|
refund_id | R | string | Refund UUID |
original_payment_id | R | string | Payment gateway UUID |
amount_display | R | string | Formatted refund amount |
currency | R | string | ISO 4217 or crypto symbol |
reason_label | R | string | Human-readable reason |
user_name | R | string | Display name |
refund_date | R | string | ISO-8601 |
processing_days | R | number | Expected days to clear (typically 5–10) |
support_url | O | string | URL to support |
treasury-milestone
Brand(s): DAO only
Sent to a treasury payout recipient when a milestone is released.
| Field | R/O | Type | Notes |
|---|---|---|---|
recipient_name | R | string | Display name |
milestone_title | R | string | Milestone description |
amount | R | string | Payout amount (formatted) |
currency | R | string | Token or currency symbol |
tx_hash | R | string | On-chain transaction reference |
dashboard_url | R | string | URL to treasury dashboard |
e2e-test-results
Brand(s): DAO + FOS
CI/CD notification sent to DevOps contacts after an automated test run.
| Field | R/O | Type | Notes |
|---|---|---|---|
run_id | R | string | CI run identifier |
pass_count | R | number | Number of passing tests |
fail_count | R | number | Number of failing tests |
total | R | number | Total tests run |
run_url | R | string | URL to full CI run results |
suite_results | O | array | Per-suite breakdown (see below) |
suite_results[] shape:
{
"suite": "dao-suite", // suite name
"pass": 142, // passing tests
"fail": 0, // failing tests
"url": "https://..." // optional deep link
}parental-consent
Brand(s): DAO + FOS
COPPA parental consent request sent to a minor's parent/guardian.
| Field | R/O | Type | Notes |
|---|---|---|---|
parent_name | R | string | Parent or guardian name |
minor_name | R | string | Minor's display name |
minor_email | R | string | Email the minor used to register |
consent_url | R | string | HTTPS consent completion URL |
consent_expires_at | R | string | ISO-8601 expiry |
platform_contact_email | R | string | Contact email for questions |
event-reminder
Brand(s): DAO + FOS
Event reminder with a server-generated ICS calendar attachment. The caller does NOT supply the ICS — notification-service builds it from the event fields.
| Field | R/O | Type | Notes |
|---|---|---|---|
recipient_name | R | string | Display name |
event_id | R | string | Unique event identifier (used as ICS UID) |
event_title | R | string | Event title |
start_time | R | string | ISO-8601 datetime with timezone |
end_time | R | string | ISO-8601 datetime with timezone |
window_label | R | "24h" | "1h" | Which reminder window triggered this send |
event_description | O | string | Event description |
event_location | O | string | Physical or virtual location |
event_url | O | string | URL to event details |
organizer_name | O | string | Organizer display name |
organizer_email | O | string | Organizer contact email (appears in ICS ORGANIZER field) |
preferences_url | O | string | URL to notification preferences |
The ICS attachment is named event.ics with Content-Type: text/calendar; charset=utf-8. It is prepended before any caller-supplied attachments. Callers that want to suppress the ICS (unusual) should not use this template type — use a different template and supply their own attachment.
Opt-In / Non-Transactional Template (unsubscribe required)
notification-digest
Brand(s): DAO + FOS
Periodic digest of platform activity for users who have opted into daily or weekly summaries. CAN-SPAM requires an unsubscribe mechanism for all commercial/bulk email; notification-digest is the only template that falls into this category. The unsubscribe_url field is required — the schema rejects missing or non-HTTP URLs to prevent accidental compliance violations.
| Field | R/O | Type | Notes |
|---|---|---|---|
recipient_name | R | string | Display name |
digest_frequency | R | "daily" | "weekly" | Drives the email subject and header |
items | R | array | Min 1, max 50 (see below) |
total_count | R | number | Total pending updates (may exceed 50 if capped) |
unsubscribe_url | R | string | Must be https://. CAN-SPAM compliant opt-out link |
inbox_url | O | string | URL to the user's full notification inbox |
items[] shape:
{
"title": "New proposal: Q3 budget allocation", // required, max 200 chars
"body": "Voting opens Friday.", // optional, max 500 chars
"deep_link": "https://...", // optional, https only
"created_at": "2026-04-18T10:00:00.000Z", // required, ISO-8601
"category": "proposals" // optional, max 32 chars
}Rendering invariants:
- Only the first 25 items are rendered in the email body; items 26–50 are counted and displayed as "+N more" with an
inbox_urllink (or omitted ifinbox_urlis not provided). - An empty
itemsarray is a caller bug — the digest cron must skip users with zero pending updates before calling this endpoint. - The unsubscribe link appears in both the email body and the
List-Unsubscribeheader.
Digest Preferences & CAN-SPAM Unsubscribe Flow
sequenceDiagram
autonumber
participant Cron as Digest Cron Job<br/>(oracle-bridge)
participant DB as PostgreSQL<br/>(oracle-bridge)
participant NS as notification-service
participant Resend
participant User
Cron->>DB: SELECT users WHERE digest_frequency != 'none'<br/>AND last_digest_sent_at < NOW() - interval
DB-->>Cron: [{ user_id, email, digest_frequency, ... }]
loop for each eligible user
Cron->>DB: SELECT notification_events WHERE user_id=? AND seen=FALSE<br/>ORDER BY created_at DESC LIMIT 50
DB-->>Cron: items[]
alt items is empty
Cron->>Cron: skip — no digest sent
else items non-empty
Cron->>Cron: build signed unsubscribe token<br/>unsubscribe_url = base_url + /unsubscribe?token=<HMAC-signed-user-id>
Cron->>NS: POST /api/v1/send<br/>{ type: "notification-digest", to, domain,<br/> data: { recipient_name, digest_frequency,<br/> items, total_count, unsubscribe_url } }
NS->>NS: Zod validate — reject if unsubscribe_url missing or non-https
NS->>NS: render digest template (cap render at 25 items)
NS->>Resend: emails.send (List-Unsubscribe header injected)
Resend-->>NS: { data: { id } }
NS-->>Cron: 200 { id, status: "sent", request_id }
Cron->>DB: UPDATE users SET last_digest_sent_at = NOW()
end
end
User->>Cron: GET /unsubscribe?token=<HMAC-signed-user-id>
Cron->>Cron: verify HMAC signature — reject expired or tampered tokens
Cron->>DB: UPDATE users SET digest_frequency = 'none'
Cron-->>User: 200 "You have been unsubscribed."Unsubscribe Token Construction
The unsubscribe_url is constructed by oracle-bridge's digest cron before calling notification-service. The URL embeds an HMAC-signed token that binds the user identity to the unsubscribe action:
unsubscribe_url = https://<domain>/unsubscribe?token=<HMAC-SHA256(user_id + expires_at, UNSUBSCRIBE_SECRET)>- Tokens are time-bound (default 30-day TTL from digest send time).
- The signature covers both
user_idandexpires_atto prevent replay after expiry. - The unsubscribe handler verifies the signature, checks expiry, and sets
digest_frequency = 'none'in one atomic update. - notification-service does NOT verify or generate unsubscribe tokens — it only requires the URL is present and begins with
https://.
Digest Frequency Options
digest_frequency | Meaning |
|---|---|
"daily" | Digest sent once per day when items are pending |
"weekly" | Digest sent once per week when items are pending |
"none" | No digest sent (user opted out) |
Users with digest_frequency = 'none' are excluded from the cron query before notification-service is called.
Caller Patterns
oracle-bridge — TypeScript NotificationClient
oracle-bridge uses a lazy singleton NotificationClient from src/services/notification-client.ts. The client is constructed on first use from env vars, and tests reset it via _resetNotificationClientForTests().
import { getNotificationClient } from './services/notification-client.js';
const client = getNotificationClient();
await client.send({
type: 'password-reset',
to: user.email,
domain: 'helloworlddao.com',
data: {
user_name: user.display_name,
reset_link: `https://helloworlddao.com/reset?token=${token}`,
expires_minutes: 60,
},
});Errors surface as a typed NotificationServiceError with a kind discriminant (config_error | network_error | auth_error | body_error | provider_error). Every call site in oracle-bridge catches NotificationServiceError, logs a WARN per recipient, and continues.
Environment variables required on the VPS:
| Env Var | Description |
|---|---|
NOTIFICATION_SERVICE_URL | Base URL (no trailing slash). When oracle-bridge routes through the gateway: https://staging-apis.helloworlddao.com/notify |
NOTIFICATION_SERVICE_TOKEN | Matches platform/service-tokens.TOKEN_NOTIFICATION_SERVICE |
FounderyOS — no live caller today (successor founderyos-core)
Historical: the retired Python
founderyos-api(deconstructed into the Rust backend 2026-07-13, repo deleted 2026-07-16 perbl-1073) once called this service through an asynchttpx.AsyncClientsingleton (BL-194). That client is gone with the repo.
Its successor, founderyos-core, does not currently call notification-service: the notification fan-out is stubbed behind a NotificationSink trait whose production implementation is the no-op NoopNotificationSink (see src/domain/crm.rs — "notification-service is a later core domain … the timeline event is the fan-out until notification-service exists"). Until that domain is built out in core, the timeline/activity event is the fan-out and no HTTP send leaves founderyos-core.
Default in-cluster base URL (when a caller does route to it): http://notification-service.notification-service-staging.svc.cluster.local:3100
payment-gateway — Detached Promise Pattern
payment-gateway calls notification-service via fireNotification() in src/services/notification.ts. Every call is fire-and-forget: errors are absorbed into a logger.warn and the Stripe webhook handler returns 200 regardless.
// All notification calls are fire-and-forget.
// Failures log WARN and never block the payment.
fireNotification(payment, user);If NOTIFICATION_SERVICE_URL or NOTIFICATION_SERVICE_TOKEN is unset, the factory returns a NoopNotificationClient that logs WARN on every call. Payments still complete normally.
Graceful Degradation Contract
Notification sends MUST NEVER fail the caller's primary business flow. The three reference implementations demonstrate the pattern:
| Caller | Pattern | On failure |
|---|---|---|
| payment-gateway | Detached promise — fireNotification() absorbs all errors | logger.warn; payment returns 200 |
| oracle-bridge | Per-recipient catch of NotificationServiceError | logger.warn per user; cron continues iterating |
founderyos-api (historical — retired → founderyos-core) | try/except around await notification_client.send() (Python httpx) | logger.warning; API response unaffected. The successor founderyos-core does not currently call notification-service (no-op NoopNotificationSink); the timeline event is its fan-out. |
No caller today requires delivery confirmation. If a future caller needs confirmed delivery, it must catch NotificationServiceError, check err.kind, and implement its own retry/escalation policy locally — the service does not expose a webhook for delivery status.
Attachment Support (BL-231)
Caller-Supplied Attachments
Add an attachments array to any request body. Attachments are:
- Base64-encoded in the
contentfield. - Capped at 10 per request and 25 MiB decoded per attachment.
- Filenames sanitised against path traversal and control characters.
content_typeis optional; Resend infers the MIME type from the filename extension if omitted.
{
"type": "payment-receipt",
"to": "member@example.com",
"domain": "helloworlddao.com",
"data": { ... },
"attachments": [
{
"filename": "receipt-2026-04.pdf",
"content": "JVBERi0xLjQ...",
"content_type": "application/pdf"
}
]
}Attachment filenames and counts are logged for audit traceability (attachment_filenames, attachment_count). Attachment content is never logged because it may contain PII (calendar descriptions, PDF receipts).
Template-Generated Attachments
event-reminder generates an ICS calendar file server-side. The caller supplies only the event data fields; the service builds the attachment:
- Filename:
event.ics - Content-Type:
text/calendar; charset=utf-8 - Content: RFC 5545 iCalendar with VEVENT, DTSTART, DTEND, SUMMARY, DESCRIPTION, LOCATION, ORGANIZER, and UID fields populated from the template data.
Template attachments are prepended before caller-supplied attachments in the final Resend payload.
Environment Variables
| Env Var | Default | Notes |
|---|---|---|
PORT | 3100 | Service listen port |
LOG_LEVEL | info | pino log level |
NODE_ENV | development | test skips some env assertions |
SERVICE_TOKEN | — | Required. Bearer token — fails closed if unset. Matches platform/service-tokens.TOKEN_NOTIFICATION_SERVICE. |
RESEND_API_KEY | — | Resend API key. If unset, service boots in stub mode — requests succeed but no email is delivered. |
Caller-Side Variables
Each caller service needs these vars in its own environment:
| Env Var | Notes |
|---|---|
NOTIFICATION_SERVICE_URL | Base URL of notification-service. In-cluster: http://notification-service.notification-service-staging.svc.cluster.local:3100. Via gateway: https://apis.helloworlddao.com/notify |
NOTIFICATION_SERVICE_TOKEN | Must match SERVICE_TOKEN in the notification-service pod. Source: platform/service-tokens k8s Secret. |
Stub Mode
When RESEND_API_KEY is unset, the service boots with a StubProvider. All send requests:
- Succeed with
200. - Return
stubbed: truein the response body. - Log the send event with
event: email.stub_sendat INFO level. - Do NOT deliver any email.
Stub mode is the default for local development and CI. The response shape is identical to a real send so callers need no special handling — they MUST check stubbed: true only if they need delivery confirmation (none do today).
Error Reference
| HTTP | error | Trigger |
|---|---|---|
400 | invalid_request | Envelope Zod failure (bad type, bad email, unknown domain, attachment violation) |
400 | invalid_template_data | Template-specific Zod failure (missing required field, wrong type) |
400 | render_failed | Non-Zod throw during template rendering — server-side bug; details in server logs only |
401 | unauthorized | Missing/invalid bearer token |
403 | forbidden | Token valid, wrong service scope |
500 | send_failed | Resend API error; details in server logs only (may contain API key hints) |
503 | service_unavailable | SERVICE_TOKEN unset at boot — fail-closed |
All error bodies carry request_id. Stack traces and internal details are NEVER included in response bodies.
Versioning
v1is the current stable contract. Nov0existed.- Adding a new
TemplateTypeis additive — callers that do not send the new type are unaffected. - Removing a
TemplateTypeis a breaking change — requires a 30-day deprecation window per the PLATFORM-002 charter. - Breaking changes (request shape, response shape, template removal) require a new
/api/v2/...path AND the 30-day deprecation window on/api/v1/....
Related Documentation
- Platform API gateway:
architecture/api-gateway.md(PLATFORM-006 path routing and service-token secrets) - Payment gateway: payment-gateway.md — fires
payment-receipt,refund-confirmation,membership-confirm - Rotate service tokens:
ops-infra/runbooks/rotate-service-tokens.md - Add service to gateway:
ops-infra/runbooks/api-gateway-add-service.md - notification-service README:
notification-service/README.md— local development and deploy
Changelog
Version 1.5.0 (2026-04-19)
- PLATFORM-002.5:
notification-digesttemplate with CAN-SPAM unsubscribe token support - BL-231: attachment support —
event-reminderICS generation + caller-supplied pass-through - BL-228:
parental-consenttemplate - BL-217: structured
auth.unauthorizedaudit log with SHA-256 token prefix - BL-218: constant-time bearer auth (
crypto.timingSafeEqualwith fixed-width padding) - BL-213:
X-Request-Idmiddleware — request correlation across caller and service logs
Version 1.0.0
Initial PLATFORM-002 delivery. 10 template types, Resend provider, stub mode, dual-brand support.
Maintainer: Platform / DAO Notification Team Last Updated: 2026-04-19