Skip to content

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.

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:

Terminal window
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:

Terminal window
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.

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