notify-sender
notify-sender is the triggers-v2 SDK runtime for notify delivery. It is built from cmd/notify-sender, registered as triggers.NewService("notify"), and shipped together with notify-api inside the shared notify-svc release.
Web-push runtime map
Actions
send_inapp
Per-recipient in-app delivery. The SDK handles ingestion, retry, DLQ, and manifest registration.
Input (SendInAppInput):
| Field | Type | Required | Notes |
|---|---|---|---|
tenant_id | string | yes | |
template_id | int | yes | looked up in Mongo templates_inapp |
player_id | int | yes | one recipient per invocation |
language_id | string | no | falls back to template or tenant default |
placeholders | map[string]string | no | substitution values |
vertical / category / subcategory | string | no | forwarded into the websocket payload and Mongo record |
Result (SendInAppResult): notification_id, delivered.
send_webpush
Per-recipient browser-push delivery. The handler renders a web-push template, resolves active subscriptions from Mongo subscriptions_webpush, upserts activity in Mongo notifications_webpush, and delivers to every active browser subscription for that player.
Input (SendWebPushInput):
| Field | Type | Required | Notes |
|---|---|---|---|
tenant_id | string | yes | |
player_id | int | yes | one recipient per invocation |
template_id | int | yes | looked up in Mongo templates_webpush |
language_enabled_id | string | yes | required for localized content selection |
placeholders | map[string]string | no | substitution values |
Result (SendWebPushResult): notification_id, player_id, status, delivered, invalidated_subscriptions.
Supported notify-api web-push contract
notify-api now has two supported web-push surfaces:
- Editor-token routes for admin workflows:
GET|POST|PUT|DELETE /api/v1/templates/web-push/*POST /api/v1/webpush/sendGET /api/v1/webpush/activity
- Push-platform routes for browser lifecycle traffic coming from a trusted backend:
GET /api/v1/webpush/client-configPOST /api/v1/service/webpush/subscriptionsPOST /api/v1/service/webpush/subscriptions/deactivatePOST /api/v1/service/webpush/events/browserPOST /api/v1/service/webpush/events/semantic
The internal dashboard now uses client-config, subscriptions, subscriptions/deactivate, and browser-event forwarding for browser lifecycle work. Product and UI integrations should follow the same shape through their own same-origin backend routes. They should not use player_id: 0; that sentinel stays reserved for internal dashboard-only smoke usage.
In this workspace today, lumio_admin_dashboard is the only committed browser bridge and service-worker integration. online-services-front is documented separately as the player-facing target integration, but it is still a plan.
Runtime flow by stage
Auth and secrets
Web-push browser lifecycle traffic must use push-platform auth, not the editor token.
NOTIFY_API_PUSH_PLATFORM_CALLERS_JSONandNOTIFY_API_PUSH_PLATFORM_CALLERS_JSON_SECRET_NAMEare the push-platform auth configuration. They let you scope each caller token to explicitallowed_routes,allowed_source_apps, and optionaltenant_ids.NOTIFY_WEBPUSH_VAPID_PUBLIC_KEY/NOTIFY_WEBPUSH_VAPID_PUBLIC_KEY_SECRET_NAMEandNOTIFY_WEBPUSH_VAPID_PRIVATE_KEY/NOTIFY_WEBPUSH_VAPID_PRIVATE_KEY_SECRET_NAMEare the VAPID pair.GET /api/v1/webpush/client-configis only ready when both are configured andWEB_PUSH_DRY_RUN=false.NOTIFY_WEBPUSH_INTERACTION_TOKEN_SECRETandNOTIFY_WEBPUSH_INTERACTION_TOKEN_SECRET_NAMEsign and validate browser interaction tokens used byPOST /api/v1/service/webpush/events/browser.NOTIFY_WEBPUSH_ALLOWED_TARGET_HOSTSoptionally restricts renderedtarget_urlvalues to known product hosts. Leave it empty only if scheme-only validation is acceptable for that environment.INAPP_EDITOR_API_AUTH_TOKENandINAPP_EDITOR_API_AUTH_TOKEN_SECRET_NAMEprotectGET /api/v1/tenants/, template CRUD,POST /api/v1/templates/in-app/send,POST /api/v1/webpush/send, and activity. The dashboard'sNOTIFY_EDITOR_TOKENmust match this value.- Any
*_SECRET_NAMEpath requiresKEY_VAULT_URLon the process that resolves the secret (notify-apiornotify-sender). NOTIFY_WEBPUSH_ALLOW_AUTO_VAPIDis local-dev only. When enabled without configured VAPID keys, notify auto-generates a temporary keypair at startup (lost on restart). Do not enable it in shared or production environments, and do not expect it to makeGET /api/v1/webpush/client-configready.
Example caller policy JSON:
{
"callers": [
{
"caller_id": "lumio-admin-dashboard",
"token": "replace-me",
"allowed_routes": [
"GET /api/v1/webpush/client-config",
"POST /api/v1/service/webpush/subscriptions",
"POST /api/v1/service/webpush/subscriptions/deactivate",
"POST /api/v1/service/webpush/events/browser",
"POST /api/v1/service/webpush/events/semantic"
],
"allowed_source_apps": ["lumio_admin_dashboard"]
}
]
}
Shared local compose currently loads a caller policy JSON that only allows lumio_admin_dashboard. Replace that JSON before another same-origin backend calls the push-platform routes locally.
Runtime storage and retention
subscriptions_webpushstores browser subscriptions keyed by endpoint and grouped by tenant and player.notifications_webpushstores rendered payload metadata and delivery activity.interaction_events_webpushstores browser and semantic interaction events.
The runtime keeps TTL indexes on both notifications_webpush and interaction_events_webpush. NOTIFY_SENDER_WEBPUSH_RETENTION_DAYS defaults to 30, and the current notify-api boot path also enforces a 30 day interaction-event TTL. Treat 30 days as the supported retention target unless those boot paths are changed together.
Service sources
| Source | Transport | Default target |
|---|---|---|
nats | NATS | triggers.v2.notify.inbound |
kafka | Kafka | triggers_v2_notify_inbound |
http | HTTP | bare HTTP derives /v2/notify/inbound; the SDK publishes that canonical path under TRIGGERS_HTTP_PUBLIC_BASE_URL or, when unset, TRIGGERS_HTTP_ADDR |
The notify service declaration stays on the canonical SDK ingress targets, including Kafka triggers_v2_notify_inbound. If a released flow needs a non-canonical Kafka target, that override belongs in flow configuration only and the topic must already exist. A missing Kafka retarget fails closed, so ingestion stops instead of silently falling back to the old topic while the process stays up. Notify does not require a repo-owned KafkaTopic manifest or bootstrap sidecar just to declare or consume its canonical Kafka ingress.
Idempotency and recovery
At-least-once: the SDK acks only after the handler returns. Both actions key their Mongo upsert on the SDK idempotency key (sha256(FlowRunID + NodeID)), which is stable across redeliveries of the same flow run. In-app delivery upserts into notifications_inapp; web push upserts into notifications_webpush. Redelivering the same flow run reuses the existing record instead of creating a duplicate.
SDK Redis transport dedup is part of the declared runtime shape, but Redis is only resolved when the service actually enables that Redis-backed capability. Resolution precedence is: explicit TRIGGERS_REDIS_URL, then TRIGGERS_REDIS_URL_SECRET_NAME, then host/password composition where literal TRIGGERS_REDIS_HOST or TRIGGERS_REDIS_PASSWORD beat their corresponding *_SECRET_NAME variants and TRIGGERS_REDIS_DB supplies the DB suffix when the host input does not already include one. Any *_SECRET_NAME path requires KEY_VAULT_URL, and successful resolution backfills TRIGGERS_REDIS_URL for compatibility. Dedup still only collapses rapid redelivery storms; correctness does not depend on Redis. There is no business dedup across two distinct flow runs in the sender; the producer is responsible for firing once per (notification, player).
Notify-sender config now relies on the SDK-owned Redis inputs in sender.env: TRIGGERS_REDIS_HOST_SECRET_NAME, TRIGGERS_REDIS_PASSWORD_SECRET_NAME, and TRIGGERS_REDIS_DB=3.
Error classification and DLQ
send_inapp
| Outcome | Classification | Effect |
|---|---|---|
| Render OK + websocket delivered | success | {notification_id, delivered:true} routed onward |
| Subscription deactivated/expired | ErrSkipForward | ack, no forward, no DLQ |
| Template missing / render / bind failure | ErrPermanent | DLQ, no retry |
| Mongo write / websocket transient failure | retryable | SDK backoff retry, DLQ on exhaustion |
send_webpush
| Outcome | Classification | Effect |
|---|---|---|
| Render OK + at least one subscription delivered | success | result_event forwarded with delivered=true |
| No active subscriptions for the player | ErrSkipForward | ack, no forward, no DLQ |
Template missing / missing language_enabled_id / render failure | ErrPermanent | DLQ, no retry |
| Subscription lookup / notification write / push transport transient failure | retryable | SDK backoff retry, DLQ on exhaustion |
| Only expired subscriptions remain | success | status=subscription_expired, no retry, expired endpoints deactivated |
Per-action retry: MaxAttempts=5, InitialBackoff=2s, BackoffFactor=3.0, MaxBackoff=1m. Failures land on triggers.v2.dlq.notify.<action>, which triggers-v2 mirrors into Mongo for the notify-api to surface.
Manifest and data sources
The manifest advertises send_inapp, send_webpush, the three service sources, supports_nodes = {condition, lookup, mapping} (action availability is derived from a non-empty actions[], not a literal action entry in supports_nodes), and four read-only data sources answered on triggers.v2.service.notify.data.<name>:
templatestenantspush_templatespush_template_fields