Architecture
This page is the detailed version of How it works. It covers the three moving parts, the request-to-delivery loop, the data model, and what happens when something fails.
The three parts
Section titled “The three parts”flowchart LR
App["Your app"]
Core["palumb-core<br/>(managed control plane)<br/>auth · audience · channels<br/>trigger ingress + delivery"]
Engine["palumb-engine<br/>(durable runtime on Restate)<br/>executor · digest/cron"]
Bridge["Your Bridge<br/>(@palumb/sdk webhook)<br/>workflow() + step.*"]
Provider["Provider<br/>SMTP / Slack / …"]
PG[("PostgreSQL<br/>tenants · audience<br/>channels · ledgers")]
App -->|"POST /v1/events"| Core
Core -->|"submit run"| Engine
Engine -->|"HMAC-signed: run next step"| Bridge
Bridge -->|"step result"| Engine
Engine -->|"send step → /v1/deliver"| Core
Core -->|"resolve channel, decrypt, send"| Provider
Core --- PG
-
palumb-core— the managed control plane. A NestJS service with a PostgreSQL database. It is the only component that holds tenant state and secrets. It exposes a REST API, authenticates every call by API key, owns delivery, and is a client of the engine — it submits runs but never hosts any workflow code itself. -
palumb-engine— the durable runtime. A Restate host running a generic executor that drives your Bridge step by step, plus the durable building blocks (digest, cron). It persists workflow state and timers and drives retries. palumb operates it as part of the managed platform. -
Your Bridge — built with
@palumb/sdk. One small signed HTTP endpoint you deploy. Your workflows are plain TypeScript (workflow()+step.*); the engine calls the webhook to run each step. No Restate runs on your side — the durability lives in palumb.
The request-to-delivery loop
Section titled “The request-to-delivery loop”-
Trigger. Your app calls
POST /v1/eventswith{ workflowId, to, payload? }.workflowIdis the name of one of your workflows;tois either{ subscriberId }or{ topics: [...] }. The API key identifies the tenant. -
Resolve recipients. palumb expands
tointo a deduplicated set of subscriber external ids: a single id passes through; topics fan out via the subscription table. Exactly one ofsubscriberId/topicsmust be present (otherwise422). -
Persist + start a run, per subscriber. For each resolved subscriber, palumb writes a
NotificationLogrow (received), then submits a one-way run of the generic executor onpalumb-engine(POST {ingress}/PalumbExecutor/{runId}/run/send), carrying your Bridge binding (webhookUrl+ signing secret) and the payload as data. It recordsdispatched(with theinvocationId) orfailed. One subscriber’s failure does not abort the fan-out — each run sees exactly one subscriber. -
The engine drives your Bridge. It calls your webhook (HMAC-signed) to run the next un-completed step, records the result durably, and loops until the workflow finishes. Your workflow may branch, call your own services, wait days for an event, or route into a digest. State and timers survive restarts.
-
Send. A
step.send(id, channel, render)declares a delivery; the engine turns it into an internalPOST /v1/deliver(authenticated with an internal token + the tenant id), deriving a stable idempotency key per step. -
Deliver. palumb resolves your enabled channel of that kind (lowest
prioritywins), decrypts its credentials, resolves the recipient address fromsubscriber.contacts[kind], records aDeliveryAttempt, calls the provider, and recordssentorfailed. The attempt is idempotent on its key.
The data model (control plane)
Section titled “The data model (control plane)”All tables are tenant-scoped. The important entities:
| Entity | Purpose | Notes |
|---|---|---|
Tenant | The account | Holds the Bridge binding: webhookUrl + an encrypted signing secret |
ApiKey | Machine auth | Stores only a SHA-256 hash + a non-secret prefix |
Subscriber | A recipient | Addressed by your external_id; contacts per kind |
Topic | A pub/sub group | Keyed; born implicitly on first subscribe |
Subscription | Subscriber ↔ topic | Used for the trigger fan-out |
TenantChannel | An enabled channel | Holds the encrypted provider config |
NotificationLog | Ingress ledger | One row per dispatched subscriber |
DeliveryAttempt | Egress ledger | One row per send; unique on idempotency key |
Two separate ledgers are deliberate: a digest collapses N items into one delivery, so ingress and egress cannot share a row. See Channels, Audience, Triggers, and Delivery for each in depth.
Failure behavior
Section titled “Failure behavior”Durability is the reason palumb exists, so the failure semantics are explicit:
- Transient failure on send (5xx / network). Surfaced as a normal error so
the engine retries the durable step. The idempotency key keeps retries from
double-sending: a key already marked
sentshort-circuits. - Configuration error on send (4xx). No enabled channel for the kind, or no
contact address for the subscriber → surfaced as a
TerminalError. It is not retried, because retrying cannot fix it; the workflow sees a clear terminal failure. A bad signature or unknown workflow on the Bridge is terminal the same way. - Crash mid-workflow. The engine restores the run’s durable state and timers and resumes — a digest waiting out its window keeps waiting; an already-completed step is not re-run.
- Dispatch failure for one subscriber. Logged as
failedon that subscriber’sNotificationLog; the rest of the fan-out proceeds.
Security boundaries
Section titled “Security boundaries”- Credentials never leave palumb. Channel configs are encrypted at rest
(AES-256-GCM) and decrypted only inside
palumb-coreat send time. No API response ever returns a secret. - API keys are stored hashed. Only the SHA-256 hash is persisted; the raw key is shown to you exactly once, at creation.
- The Bridge hop is signed. palumb signs every call to your webhook with an HMAC secret it generates at onboarding, returns once, and stores encrypted at rest. Your webhook verifies the signature, so only palumb can drive it.
- Your code never sees secrets and palumb never sees your logic. The HTTP edges are also the trust boundaries.
- Durability & Restate — why the runtime choice matters.
- Quickstart — run this whole loop end to end.