Welcome email
The simplest workflow: a single trigger produces a single, immediate email — no batching, no waiting. A good first thing to build after the Quickstart. Then we extend it into a durable welcome → wait → re-engage flow.
One trigger, one email
Section titled “One trigger, one email”import { workflow } from '@palumb/sdk/bridge';
const userRegistered = workflow('userRegistered', async ({ payload, subscriber, step }) => { const name = String(payload.name ?? 'there'); await step.send('welcome', 'email', () => ({ subject: 'Welcome to Acme', body: `<p>Hi ${name}, thanks for signing up!</p>`, }));});step.send declares a delivery: your callback renders the content, palumb
resolves the channel and recipient (the run’s subscriber) and performs the
physical send. See Sending messages for rendering
with subscriber attributes/locale.
Register it on your Bridge (new BridgeClient().register([userRegistered]) +
serveNest, see the Quickstart), then trigger it:
curl -sS "$PALUMB_BASE_URL/v1/events" \ -H "Authorization: Bearer $PALUMB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "workflowId": "userRegistered", "to": { "subscriberId": "cust_1" }, "payload": { "name": "Ada" } }'The response carries the run id — keep it; you’ll use it below to signal the run.
Going further: welcome → wait → re-engage
Section titled “Going further: welcome → wait → re-engage”A welcome email is more useful when it can react to what the user does next. palumb runs are durable: a workflow can send the welcome, then pause for days waiting for an inbound event, and branch on whether it arrived — all without you hosting anything (palumb holds the run for as long as the wait takes).
const onboarding = workflow('onboarding', async ({ step }) => { await step.send('welcome', 'email', () => ({ subject: 'Welcome aboard', body: '<p>Welcome! Open this to get started.</p>', }));
// Suspend the run until the user engages — or 3 days elapse. No tenant code // runs while waiting; palumb holds the run durably. const engaged = await step.waitForEvent('awaitEngagement', { event: 'userEngaged', timeout: '3 days', });
if (!engaged) { await step.send('nudge', 'email', () => ({ subject: 'Still there?', body: '<p>Did you catch our welcome email?</p>', })); } else { await step.run('markEngaged', async () => ({ engaged: true })); }});The branch is driven by an inbound event, not by palumb pulling your backend (“did this user activate?”). Your system already knows when the user engaged — so it tells palumb, by signalling the waiting run:
curl -sS "$PALUMB_BASE_URL/v1/events/$RUN_ID/signal" \ -H "Authorization: Bearer $PALUMB_API_KEY" \ -H 'Content-Type: application/json' \ -d '{ "event": "userEngaged", "data": { "source": "first-login" } }'If that signal arrives within 3 days, waitForEvent resolves with its data and
the workflow takes the engaged branch; otherwise it times out (returns null)
and sends the nudge. This events-in / sends-out shape keeps palumb dumb about
your business logic — it never calls back into your backend to decide.
The flow
Section titled “The flow”flowchart TD
T["POST /v1/events<br/>workflowId: onboarding"]
W["step.send('welcome')<br/>→ palumb delivers"]
A["step.waitForEvent('userEngaged', 3 days)<br/>run suspended, durable"]
S{"signal arrived?"}
E["step.run('markEngaged')"]
N["step.send('nudge')<br/>→ palumb delivers"]
T --> W --> A --> S
S -- "POST /v1/events/:runId/signal" --> E
S -- "timeout" --> N
Related
Section titled “Related”- Writing a workflow
- Sending messages
- Escalation ladder — the same wait-and-branch shape, generalized into an acknowledge-or-escalate chain.