Skip to content

Checking access...

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 domain field (helloworlddao.com or founderyos.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-reminder generates a server-side ICS file automatically; callers do not need to produce one.
  • Fail-closed auth — SERVICE_TOKEN unset at boot → 503 on 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 endpoint
  • GET /api/v1/health — liveness probe
  • 14 template types (see Template Catalog)
  • X-Request-Id middleware (BL-213) — UUID correlation across caller logs and service logs
  • Structured 401 audit log (BL-217) — auth.unauthorized event with SHA-256 token prefix
  • Constant-time bearer auth (BL-218) — crypto.timingSafeEqual with fixed-width padding
  • Attachment support (BL-231) — event-reminder ICS generation; caller-supplied attachment pass-through
  • Digest preferences (PLATFORM-002.5) — notification-digest template 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.

http
Authorization: Bearer <TOKEN_NOTIFICATION_SERVICE>

Token Source ​

  • k8s Secret: platform/service-tokens key TOKEN_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:

json
{
  "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 ​

PathReason
GET /api/v1/healthk8s 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_id field of every response body (success and error alike).
  • The X-Request-Id response header.
  • Every pino log line emitted during the request (requestId binding 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.

http
X-Request-Id: aae9048a-31af-4f42-9758-48de4a0fbd8f

The 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):

json
{ "status": "ok" }

Send ​

POST /api/v1/send ​

Dispatch a single transactional or opt-in email.

Auth: Bearer service token.

Request body:

jsonc
{
  "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):

jsonc
{
  "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.

StatuserrorMeaning
400invalid_requestEnvelope failed Zod validation — bad type, bad email, unknown domain, bad attachment filename/size/encoding, too many attachments. details[] contains per-field messages.
400invalid_template_datadata object failed the template's own Zod schema (e.g. missing reset_link). details[] contains per-field messages.
400render_failedTemplate threw during rendering — server-side bug. Error logged internally; no message returned to caller.
401unauthorizedBearer token absent, malformed, or wrong.
403forbiddenToken valid but scoped for a different service.
500send_failedResend rejected the send. Full error logged internally (may contain API key hints).
503service_unavailableSERVICE_TOKEN unset at boot — fail-closed.

Happy-Path Send Flow ​

mermaid
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.com vs notifications@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 valueBrandSubdomain
helloworlddao.comHello World DAOnotifications.helloworlddao.com
founderyos.devFounderyOSnotifications.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.

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.

FieldR/OTypeNotes
user_nameRstringDisplay name
dashboard_urlOstringURL to the user's dashboard

password-reset ​

Brand(s): DAO + FOS

Password reset link sent to the email address on file.

FieldR/OTypeNotes
reset_linkRstringHTTPS reset URL with embedded token
user_nameRstringDisplay name
expires_minutesOnumberToken validity window; defaults to 60 if omitted

password-changed ​

Brand(s): DAO + FOS

Security notification sent after a successful password change.

FieldR/OTypeNotes
recipient_nameRstringDisplay name
changed_atRstringISO-8601 timestamp
security_urlRstringURL to security settings / account review
ip_hintOstringApproximate IP location hint for user recognition
device_hintOstringBrowser/device hint (e.g. "Chrome on macOS")

verification ​

Brand(s): DAO + FOS

Email address verification. One of verification_link or verification_code is required.

FieldR/OTypeNotes
user_nameRstringDisplay name
verification_linkR*stringOne-click verification URL
verification_codeR*string6–8 character code (alternative to link)
expires_minutesOnumberValidity 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.

FieldR/OTypeNotes
inviter_nameRstringName of the person who sent the invite
workspace_nameRstringDAO or organization name
invitation_linkRstringHTTPS invite acceptance URL
expires_daysOnumberDays until the invitation expires

membership-confirm ​

Brand(s): DAO only

Sent after a member's SBT is activated.

FieldR/OTypeNotes
member_nameRstringDisplay name
activated_atRstringISO-8601 timestamp of SBT activation
expires_atRstringISO-8601 expiry timestamp
membership_tierOstringTier label
dashboard_urlOstringURL to membership dashboard
sbt_token_idOstringICRC-7 token ID of the issued SBT

renewal-receipt ​

Brand(s): DAO only

Membership renewal receipt.

FieldR/OTypeNotes
member_nameRstringDisplay name
amountRstringAmount paid (formatted, e.g. "$25.00")
payment_dateRstringISO-8601
payment_idRstringPayment gateway UUID
expiration_dateRstringISO-8601 new expiry
receipt_numberRstringHuman-readable receipt reference
currencyOstringDefault: USD
payment_last4OstringLast 4 digits of card (Stripe)
dashboard_urlOstringURL to membership dashboard

payment-receipt ​

Brand(s): DAO + FOS

Generic payment receipt. Fired by payment-gateway on every pending → completed transition.

FieldR/OTypeNotes
payment_idRstringPayment gateway UUID
amount_displayRstringFormatted amount (e.g. "$10.00")
currencyRstringISO 4217 or crypto symbol
type_labelRstringHuman-readable payment type
user_nameRstringDisplay name
payment_dateRstringISO-8601
support_urlOstringURL to support
completed_atOstringISO-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.

FieldR/OTypeNotes
refund_idRstringRefund UUID
original_payment_idRstringPayment gateway UUID
amount_displayRstringFormatted refund amount
currencyRstringISO 4217 or crypto symbol
reason_labelRstringHuman-readable reason
user_nameRstringDisplay name
refund_dateRstringISO-8601
processing_daysRnumberExpected days to clear (typically 5–10)
support_urlOstringURL to support

treasury-milestone ​

Brand(s): DAO only

Sent to a treasury payout recipient when a milestone is released.

FieldR/OTypeNotes
recipient_nameRstringDisplay name
milestone_titleRstringMilestone description
amountRstringPayout amount (formatted)
currencyRstringToken or currency symbol
tx_hashRstringOn-chain transaction reference
dashboard_urlRstringURL to treasury dashboard

e2e-test-results ​

Brand(s): DAO + FOS

CI/CD notification sent to DevOps contacts after an automated test run.

FieldR/OTypeNotes
run_idRstringCI run identifier
pass_countRnumberNumber of passing tests
fail_countRnumberNumber of failing tests
totalRnumberTotal tests run
run_urlRstringURL to full CI run results
suite_resultsOarrayPer-suite breakdown (see below)

suite_results[] shape:

jsonc
{
  "suite": "dao-suite",      // suite name
  "pass": 142,               // passing tests
  "fail": 0,                 // failing tests
  "url": "https://..."       // optional deep link
}

Brand(s): DAO + FOS

COPPA parental consent request sent to a minor's parent/guardian.

FieldR/OTypeNotes
parent_nameRstringParent or guardian name
minor_nameRstringMinor's display name
minor_emailRstringEmail the minor used to register
consent_urlRstringHTTPS consent completion URL
consent_expires_atRstringISO-8601 expiry
platform_contact_emailRstringContact 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.

FieldR/OTypeNotes
recipient_nameRstringDisplay name
event_idRstringUnique event identifier (used as ICS UID)
event_titleRstringEvent title
start_timeRstringISO-8601 datetime with timezone
end_timeRstringISO-8601 datetime with timezone
window_labelR"24h" | "1h"Which reminder window triggered this send
event_descriptionOstringEvent description
event_locationOstringPhysical or virtual location
event_urlOstringURL to event details
organizer_nameOstringOrganizer display name
organizer_emailOstringOrganizer contact email (appears in ICS ORGANIZER field)
preferences_urlOstringURL 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.

FieldR/OTypeNotes
recipient_nameRstringDisplay name
digest_frequencyR"daily" | "weekly"Drives the email subject and header
itemsRarrayMin 1, max 50 (see below)
total_countRnumberTotal pending updates (may exceed 50 if capped)
unsubscribe_urlRstringMust be https://. CAN-SPAM compliant opt-out link
inbox_urlOstringURL to the user's full notification inbox

items[] shape:

jsonc
{
  "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_url link (or omitted if inbox_url is not provided).
  • An empty items array 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-Unsubscribe header.

Digest Preferences & CAN-SPAM Unsubscribe Flow ​

mermaid
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_id and expires_at to 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_frequencyMeaning
"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().

typescript
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 VarDescription
NOTIFICATION_SERVICE_URLBase URL (no trailing slash). When oracle-bridge routes through the gateway: https://staging-apis.helloworlddao.com/notify
NOTIFICATION_SERVICE_TOKENMatches 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 per bl-1073) once called this service through an async httpx.AsyncClient singleton (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.

typescript
// 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:

CallerPatternOn failure
payment-gatewayDetached promise — fireNotification() absorbs all errorslogger.warn; payment returns 200
oracle-bridgePer-recipient catch of NotificationServiceErrorlogger.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 content field.
  • Capped at 10 per request and 25 MiB decoded per attachment.
  • Filenames sanitised against path traversal and control characters.
  • content_type is optional; Resend infers the MIME type from the filename extension if omitted.
jsonc
{
  "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 VarDefaultNotes
PORT3100Service listen port
LOG_LEVELinfopino log level
NODE_ENVdevelopmenttest 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 VarNotes
NOTIFICATION_SERVICE_URLBase 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_TOKENMust 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: true in the response body.
  • Log the send event with event: email.stub_send at 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 ​

HTTPerrorTrigger
400invalid_requestEnvelope Zod failure (bad type, bad email, unknown domain, attachment violation)
400invalid_template_dataTemplate-specific Zod failure (missing required field, wrong type)
400render_failedNon-Zod throw during template rendering — server-side bug; details in server logs only
401unauthorizedMissing/invalid bearer token
403forbiddenToken valid, wrong service scope
500send_failedResend API error; details in server logs only (may contain API key hints)
503service_unavailableSERVICE_TOKEN unset at boot — fail-closed

All error bodies carry request_id. Stack traces and internal details are NEVER included in response bodies.


Versioning ​

  • v1 is the current stable contract. No v0 existed.
  • Adding a new TemplateType is additive — callers that do not send the new type are unaffected.
  • Removing a TemplateType is 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/....

  • 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-digest template with CAN-SPAM unsubscribe token support
  • BL-231: attachment support — event-reminder ICS generation + caller-supplied pass-through
  • BL-228: parental-consent template
  • BL-217: structured auth.unauthorized audit log with SHA-256 token prefix
  • BL-218: constant-time bearer auth (crypto.timingSafeEqual with fixed-width padding)
  • BL-213: X-Request-Id middleware — 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

Hello World DAO