Temporal
Self-hosted orchestrator for notification delivery: retries, quiet hours, escalation, cancel-on-read. Admin Workflows are product definitions (escalation ladder). Temporal workflow types are the two functions below.
Do not start workflows from the browser or with a guessed id. Producers call
POST /v1/trigger; the bridge does signalWithStart. gRPC localhost:7233 is server-side only.1. How it works
One durable run per subscriber + product workflow + idempotency key.
Ingest
Producer
API key
ingestion-api
POST /v1/trigger
Redis
trigger-buffer
bridge
signalWithStart
Temporal · namespace outreach
NotificationWorkflow
queue: orchestration · id: notif-<workflowId>-<subscriberId>-<idempotencyKey>
Resolve ladder
DB definition or code registry
Wait → gate
quiet hours / delay · evaluateChannelGate
Channel activity (isolated worker, 5 retries)
email
inapp
telegram
push
webhook
Success
writeAudit → notification_log
Fail after retries
child DeadLetterWorkflow
Signals in
triggerstart / payloadpref-changedre-read prefsreadcancel ladderTerminal
Delivery log
sent / skipped / read
DeadLetterWorkflow
queue: dead-letter · status dead_lettered
Text outline
Producer
→ POST /v1/trigger (Bearer producer key)
→ Redis BullMQ trigger-buffer
→ ingestion-bridge
→ signalWithStart NotificationWorkflow
taskQueue: orchestration
signal: trigger { payload }
Temporal id: notif-<workflowId>-<subscriberId>-<idempotencyKey>
NotificationWorkflow (fresh run)
→ resolve escalation definition (DB or code registry)
→ sequential ladder: wait (quiet hours / delay) → gate → channel Activity
→ writeAuditActivity (notification_log)
→ on channel failure after 5 retries: child DeadLetterWorkflow
taskQueue: dead-letter
id: dlq-…:{channel}2. Workflow types that exist
Only these two are registered on worker-core. Channel workers have Activities, not workflow code.
| Type | Queue | What it does |
|---|---|---|
| NotificationWorkflow | orchestration | Delivery spine: preference gate, timers, five channels (email, inapp, telegram, push, webhook), signals, audit. |
| DeadLetterWorkflow | dead-letter | Abandoned child after Activity retries exhaust. Only writer of dead_lettered on the delivery log. |
Visibility list query: WorkflowType = "NotificationWorkflow" AND ExecutionStatus = "Running"
3. Workers & task queues
Isolated processes. Retry on every Activity: 5 attempts, exponential backoff.
| Queue | Process | Role |
|---|---|---|
| orchestration | temporal-worker-core | Runs NotificationWorkflow; prefs / audit / gate / wait |
| dead-letter | temporal-worker-core (same process) | Runs DeadLetterWorkflow; writes status=dead_lettered |
| temporal-worker-email | emailSendActivity (stub or DashaMail) | |
| inapp | temporal-worker-inapp | inappSendActivity → Centrifugo publish |
| telegram | temporal-worker-telegram | telegramSendActivity |
| push | temporal-worker-push | pushSendActivity (FCM) |
| webhook | temporal-worker-webhook | webhookSendActivity |
4. Signals
Named Temporal signals on NotificationWorkflow.
| Signal | When | Effect |
|---|---|---|
| trigger | signalWithStart from ingestion-bridge after POST /v1/trigger | Carries payload; starts or attaches to NotificationWorkflow |
| pref-changed | PUT preferences (best-effort list of Running executions) | Re-read prefs before the next ladder send |
| read | In-app mark-read / producer read | Cancel pending escalation; audit status=read |
5. How to connect
UI is HTTP. SDK is gRPC to the frontend service. Namespace must be
outreach.UI http://localhost:8088/namespaces/outreach/workflows
compose maps host 8088 → temporal-ui 8080
namespace: outreach
gRPC localhost:7233
workers + bridge + createTemporalClient()import { createTemporalClient, TASK_QUEUES } from "@finamx/outreach-temporal-shared";
const client = await createTemporalClient();
// TEMPORAL_ADDRESS + TEMPORAL_NAMESPACE (default: localhost:7233 / outreach)
const handle = client.workflow.getHandle("<temporal workflow id>");
const desc = await handle.describe();In-app channel after a successful ladder step: Centrifugo. Delivery outcomes: Delivery log.
6. Env
Same values for workers, ingestion-bridge, and local SDK scripts.
TEMPORAL_ADDRESS=localhost:7233 # compose DNS: temporal:7233
TEMPORAL_NAMESPACE=outreach
TEMPORAL_UI_PORT=8088
# DEFAULT_NAMESPACE=outreach is set on the temporal auto-setup container