@shware/workflow
v9.2.0
Published
Readme
@shware/workflow
Code-first customer journeys. Workflows are written as TypeScript, compiled to a versioned IR, and executed by a single generic runner: the IR is data, so one deployed Worker runs every journey. Events and profile fields are typed by a shared Zod contract, messages are typed templates (React email, push, Slack, Discord), and the host is a Cloudflare Worker on Cloudflare Workflows, with its data in your own Postgres.
const welcome = workflow({ id: 'welcome', trigger: trigger.event(e.sign_up) })
.email(email.getting_started, { name: u.name })
.delay('3 days')
.waitUntil(performed(e.activated), {
timeout: '7 days',
onTimeout: (w) => w.email(email.nudge, { name: u.name }),
});Install
pnpm add @shware/workflow zodThe peer dependencies are optional; install the ones the subpaths you use need:
| Peer | Needed by |
| -------------------- | -------------------------------------------------------------------------------- |
| react, react-dom | @shware/workflow/react-email, and @shware/workflow/cloudflare, which uses it |
| postgres | @shware/workflow/postgres-js |
Email components are usually written with @react-email/components, and the
Worker is deployed with wrangler.
Quick start
A project has one contract, a set of templates, the workflows, and one
workflow.config.ts that registers all of it. The compiler, the Worker and the
Workflow Studio (@shware/workflow-ui) all read that one object.
1. The contract
The events journeys react to and the profile fields they read, as Zod schemas. Put it in a module both the producer (your api) and the Worker can import.
import { defineContract } from '@shware/workflow';
import { email, looseObject, number, optional, strictObject, string } from 'zod/mini';
export const contract = defineContract({
events: {
login: looseObject({ method: string() }),
purchase: looseObject({ transaction_id: string(), currency: string(), value: number() }),
},
profile: strictObject({ email: optional(email()), name: string() }),
subscriptions: { topics: ['promotions', 'newsletter', 'tips'] },
});
/** The DSL's reference tables: `e.login`, `u.name`. */
export const e = contract.events;
export const u = contract.user;Event payloads are object schemas (looseObject keeps extra analytics fields).
The profile is a flat object of string, number, boolean, enum and literal
fields. Defaults, transforms, coercion and custom refinements are refused when
the contract is defined, because input and output must mean the same JSON.
subscriptions.topics lists the subscription topics: content categories
("offers", "tips"), not channels — ids only (a plain token; the app's i18n gives
them names). A plain array literal is enough: defineContract keeps the
literals, and the union they form types every email and push template's
topic, the config and JourneyClient.setSubscriptions. A user opts out of a
topic per channel (the preference page is topic × channel), or of a whole
channel; the engine keeps that record and is the one source of truth for it
(see Email, Push and JourneyClient).
Leave the field out when every email and push is transactional.
2. Templates
One object per template, identified by its own id (unique per channel): the
IR, the manifest, every send and the studio name it by that id, so keep it
stable and rename title instead. Its strict Zod props schema is the only
source of the props type: the DSL's arguments, the preview and the component
are all derived from it, and every send parses the props against it first.
// src/contract.ts (continued): the template factories bound to the contract
export const { emailTemplate, pushTemplate } = templatesFor(contract);// src/email/welcome.tsx
import { Html, Text } from '@react-email/components';
import { strictObject, string } from 'zod/mini';
import { emailTemplate } from '../contract';
export default emailTemplate({
id: 'welcome',
transactional: true,
from: { email: '[email protected]', name: 'Acme' },
title: 'Welcome',
props: strictObject({ name: string() }),
subject: 'Welcome aboard',
preview: { name: 'John Doe' },
component: ({ name }) => (
<Html>
<Text>Welcome, {name}! Create your first project; it takes a minute.</Text>
</Html>
),
});Every email and push template says what it is: topic: 'tips' (a topic the
contract declares, which the user can opt out of on that channel) or
transactional: true (a receipt, a code, an invite: sent whatever they opted
out of; for an email, with no unsubscribe link). Leaving both out, or giving
both, is a type error and a compile error, and the error says so in words.
templatesFor(contract) gives the factories with the topic typed by the
contract, so a topic it does not declare is an error at the template's own
topic line, in the editor and when the module loads; the bare emailTemplate
/ pushTemplate from @shware/workflow are for a project without a contract
(transactional templates only), and the compile checks every template again.
Gather them per channel; the workflows send the definitions and the config registers the whole:
// src/email/index.ts
import tip from './tip';
import welcome from './welcome';
export const email = { welcome, tip };// src/templates.ts
import { email } from './email';
export const templates = { email };tip.tsx is written the same way, with topic: 'tips', and so is a
pushTemplate. The property names are JavaScript names only. .email(email.welcome, props) checks props
against that template's schema, and a push template is not accepted where an
email is. The compile refuses an id registered twice in a channel, a workflow
sending a definition other than the one registered under its id, and a topic
the contract does not declare (with a contract in the config, the editor
refuses it first).
3. A workflow
// src/workflows/welcome.ts
import { not, performed, trigger, workflow } from '@shware/workflow';
import { e, u } from '../contract';
import { email } from '../email';
export const welcomeSeries = workflow({
id: 'welcome_series',
name: 'Welcome series',
trigger: trigger.event(e.login),
goal: { condition: performed(e.purchase), within: '7 days' },
})
.email(email.welcome, { name: u.name })
.delay('2 days')
.branch([not(performed(e.purchase)), (w) => w.email(email.tip, { name: u.name })]);id is wire identity (instances, the entry ledger and stats are keyed on it),
so rename name instead.
4. The config
// workflow.config.ts
import { defineConfig } from '@shware/workflow';
import { contract } from './src/contract';
import { templates } from './src/templates';
import { welcomeSeries } from './src/workflows/welcome';
export default defineConfig({
title: 'Workflow Studio · Acme',
contract,
workflows: [welcomeSeries],
templates,
email: { unsubscribeUrl: 'https://acme.test/unsubscribe' },
runtime: {
development: { timeScale: 600, logMessages: true },
production: { timeScale: 1, logMessages: false },
},
});5. The Worker
// src/index.ts
import { type JourneyEnv, journeyWorker } from '@shware/workflow/cloudflare';
import { postgresJsStore } from '@shware/workflow/postgres-js';
import { cloudflareEmailTransport } from '@shware/workflow/transports/cloudflare-email';
import config from '../workflow.config';
interface Env extends JourneyEnv {
HYPERDRIVE: { connectionString: string };
EMAIL: NonNullable<JourneyEnv['EMAIL']>;
}
const journeys = journeyWorker<Env>({
config,
store: (env, database) => postgresJsStore(env.HYPERDRIVE.connectionString, database),
email: (env) => cloudflareEmailTransport({ binding: env.EMAIL }),
});
/** The wrangler `workflows` binding's class_name. */
export const AcmeJourneyRunner = journeys.Runner;
export default { fetch: journeys.fetch, scheduled: journeys.scheduled };store names the engine's Postgres (here through Hyperdrive); create its tables
first (see Postgres).
// wrangler.jsonc
{
"name": "acme-workflow",
"main": "src/index.ts",
"compatibility_date": "2026-09-12",
"compatibility_flags": ["nodejs_compat"],
"workflows": [
{ "name": "acme-journey", "binding": "JOURNEY", "class_name": "AcmeJourneyRunner" },
],
"hyperdrive": [{ "binding": "HYPERDRIVE", "id": "<hyperdrive id>" }],
"send_email": [{ "name": "EMAIL" }],
"vars": { "ENVIRONMENT": "production", "WORKFLOW_ORIGIN": "https://workflow.acme.test" },
"triggers": { "crons": ["*/15 * * * *"] },
}6. Deploy
wrangler secret put API_TOKEN
wrangler deploy
curl -X POST https://workflow.acme.test/bundle/deploy -H "authorization: Bearer $API_TOKEN"POST /bundle/deploy compiles the config the Worker was built with and swaps
the routing table. Run it after every deploy that changes a journey.
7. Send events
From the service that knows what users do:
import { contract } from '@acme/workflow-contract';
import { JourneyClient } from '@shware/workflow/client';
export const journeys = new JourneyClient({
contract,
origin: 'https://workflow.acme.test',
token: process.env.WORKFLOW_API_TOKEN,
});
await journeys.ingest({
userId: user.id,
profile: { email: user.email, name: user.name },
events: [{ event: 'login', payload: { method: 'google' }, id: `login-${session.id}` }],
});Identify the properties a journey reads before the event that starts it.
The three planes
| Plane | What it is | Where it lives |
| --------- | ------------------------------------------ | ----------------------------- |
| Authoring | The DSL, compiled to IR by compileBundle | @shware/workflow |
| Execution | The interpreter, over abstract ports | @shware/workflow/engine |
| Adapter | Cloudflare bindings, ingest, runner | @shware/workflow/cloudflare |
The interpreter never touches a database or a network. Everything it needs
arrives through the ports in engine/ports.ts, which is what makes a journey
testable without deploying one.
Authoring
Chained calls and condition expressions build a serializable graph, never
runtime code. Sub-flow callbacks run exactly once, while the workflow is
defined, and runtime values are always references (u.xxx), not closures.
Reference tables
contract.events and contract.user are the typed reference tables: e.purchase
is an event reference, u.name a profile reference (u.userId is always
there). Without a contract, build them from TypeScript types:
import { event, user } from '@shware/workflow';
const e = event<{ demo_signup: Record<never, never>; demo_purchase: { value: number } }>();
const u = user<{ userId: string; email: string; plan: string }>();workflow(definition)
| Option | Meaning |
| ------------- | --------------------------------------------------------------------------------- |
| id | Wire identity; never rename it for readability |
| trigger | A trigger.xxx() asset |
| goal | A condition, or { condition, within?, exitOnMatch? }; exits on match by default |
| exitWhen | Leave without counting as a conversion (unsubscribed, no longer eligible) |
| name | Studio label; metadata, outside the hash |
| description | One line on what the flow does; metadata, outside the hash |
The goal is checked before each node runs. A performed() inside a goal counts
from workflow entry, so earlier history cannot satisfy it.
Triggers
trigger.event(e.login);
trigger.event(e.purchase_failed, (p) => notInArray(p.code, ['CHECKOUT_CANCELED']));
trigger.event(e.sign_up, { filter: exists(u.email) });
trigger.segment(dormant7d);
trigger.date('2026-12-25 09:00:00');
trigger.webhook();eventtakes an optional payloadwhere(typed by the event) and a profilefilter; the user enters only when the event fired and both hold.segmentstarts the journey when a user goes from not matching a named segment to matching it. Membership is re-evaluated on the user's events and identifies, and by the segment sweep (the cron trigger), which catches segments entered by the clock alone.date(one-shot, UTC) andwebhookcompile and deploy, but are not routed by the runtime yet: deploy lists them inunrouted.
Steps
| Step | What it does |
| --------------------------------------------- | ------------------------------------------------------------------------- |
| .email(email.k, props) | Send an email |
| .push(push.k, props, { devices? }) | Push to every device of the user, or only the 'latest'; skipped if none |
| .slack(slack.k, props) | Post to a Slack incoming webhook |
| .discord(discord.k, props) | Post to a Discord webhook |
| .sms(), .inApp(), .survey() | Message nodes with no built-in sender (see Channels) |
| .prefer([first, second], props, { label? }) | One push and one email: the first the user is reachable on |
| .delay('1 day') | Sleep; { min, max } sleeps a random time in between |
| .timeWindow({ days?, between, tz? }) | Hold until inside a weekly window |
| .waitUntil(condition, opts) | Wait for a condition, or time out |
| .branch(label?, ...arms) | First-match branch; rejoins the main line afterwards |
| .filter(condition, { reason? }) | Exit unless the condition holds |
| .cohort(arms, { key? }) | Random A/B split; weights sum to 100 |
| .exit(reason?) | End the journey; inside an arm, do not rejoin |
| .sendEvent(e.x, payload?) | Emit an event, which may trigger another workflow |
| .run(action, args) | Run a custom action inside a durable step |
| .use(fragment) | Splice a flow(...) fragment in place |
Props may reference the profile ({ name: u.name }); references resolve at send
time and are parsed by the template then. Durations are typed literals such as
'30 minutes', '1 day' or '2 weeks', so a typo does not compile.
prefer is sugar for the branch it compiles to: the choice is made once when the step is reached, and a first choice that fails to send retries as itself. Both templates must declare the same props type.
workflow({ id: 'welcome_series', trigger: trigger.event(e.login) })
.email(email.welcome, { name: u.name })
.delay('1 day')
.prefer([push.tip_push, email.tip], { name: u.name }, { label: 'tip by channel' });timeWindow takes between: ['09:00', '17:00'] (24-hour, leading zeros, not
overnight), days (all week by default) and tz: 'user' (the default) reads
the profile's timezone property, an IANA id, falling back to UTC when it is
invalid; any other string is a fixed IANA zone.
waitUntil continues on the main line once the condition holds. On timeout it
does onTimeout: 'continue' (the default), 'exit', or a sub-flow. A
performed() inside it counts from the moment the wait began.
workflow({ id: 'notification_optin', trigger: trigger.event(e.login) }).waitUntil(
or(
reachable('push'),
performed(e.notification_permission, (p) => eq(p.status, 'granted'))
),
{
timeout: '1 day',
onTimeout: (w) => w.email(email.enable_notifications, { name: u.name }),
}
);branch arms are [condition, sub-flow] tuples; a bare sub-flow in last
position is the default arm. With no match and no default, execution
continues.
workflow({ id: 'nps_followup', trigger: trigger.event(e.nps_sent) }).branch(
'by score',
[
performed(e.nps_sent, (p) => gte(p.score, 9)),
(w) => w.delay('1 day').email(email.review_ask, { name: u.name }),
],
[
performed(e.nps_sent, (p) => lte(p.score, 6)),
(w) => w.slack(slack.nps_detractor, { name: u.name }).exit('handed to support'),
]
);cohort arm names are letters, digits, _ and -, not starting with a
digit, and weights have at most two decimals. Set key for any experiment you
intend to read: bucketing then hashes userId:key, so assignments survive
edits to the workflow.
workflow({ id: 'checkout_recovery', trigger: trigger.event(e.begin_checkout) })
.delay('1 day')
.cohort(
{
discount: { weight: 50, flow: (w) => w.email(email.offer_discount, { name: u.name }) },
social_proof: { weight: 50, flow: (w) => w.email(email.offer_reviews, { name: u.name }) },
},
{ key: 'checkout_offer_v1' }
);sendEvent carries scalar fields only (literals or u.xxx), and the event is
checked against the contract like any ingested one. Because the entry policy is
once per user, a self-triggering sendEvent does not loop yet.
run executes a custom action. The IR records only its name, args and a hash
of its code; the implementation ships in the Worker and is registered through
the config's actions. It is retried on throw, so pass ctx.idempotencyKey to
any external side effect.
import { action } from '@shware/workflow';
export const issueCoupon = action<{ code: string }>('issue_coupon', async ({ code }, ctx) => {
await billing.issueCoupon({ code, userId: ctx.userId, idempotencyKey: ctx.idempotencyKey });
});
workflow({ id: 'winback', trigger: trigger.segment(dormant7d) }).run(issueCoupon, {
code: '15OFF',
});flow names a reusable fragment, for .use() or as a branch arm. Each use
gets its own copy:
const officeHours = flow((w) =>
w.timeWindow({ days: ['mon', 'tue', 'wed', 'thu', 'fri'], between: ['09:00', '17:00'] })
);Conditions
| Function | Meaning |
| ------------------------------------------------- | ----------------------------------------------------------------- |
| eq, ne | Equal / not equal |
| gt, gte, lt, lte | Ordered comparison (string or number) |
| between, notBetween | Inclusive range |
| inArray, notInArray | Membership in a list |
| exists, notExists | Property present / absent |
| contains, notContains | Substring (string) |
| performed(e.x, where?, { within?, count? }) | Did the event, optionally matching, in a window, at least N times |
| reachable(channel) | Push: has a registered device; other channels: has the address |
| and, or, not | Combinators; nest freely |
| segment({ id, condition, name?, description? }) | A named condition: reusable, swept, usable as a trigger |
Predicates take a profile reference (eq(u.platform, 'ios')), or, inside a
where, a payload reference ((p) => gt(p.value, 100)). Values are typed by
the reference, so eq(u.platform, 'ios ') against an enum does not compile.
"Did not do" is not(performed(...)).
export const dormant7d = segment({
id: 'dormant_7d',
name: 'Dormant (7d)',
condition: and(performed(e.login), not(performed(e.login, { within: '7 days' }))),
});Segments a workflow references must be passed in the config's segments, or
the compile fails.
Channels
emailTemplate({ id, topic | transactional, from, replyTo?, unsubscribeUrl?, title, description?, props, subject, preview, component })
declares one. The Worker renders it with @shware/workflow/react-email (React's
streaming renderer, which waits for react-email's <Tailwind>) and sends it
through the host's email transport, from the template's own from:
// src/email/tip.tsx
export default emailTemplate({
id: 'tip',
topic: 'tips',
from: { email: '[email protected]', name: 'Acme' },
replyTo: { email: '[email protected]', name: 'Acme Support' }, // left out: no Reply-To
title: 'Weekly tip',
props: strictObject({ name: string() }),
subject: 'One thing to try this week',
preview: { name: 'John Doe' },
component: ({ name }) => <Layout name={name}>…</Layout>,
});Every template is one of two kinds, by type: a topic email names one of the
contract's subscriptions.topics, and a transactional email says
transactional: true (and takes no unsubscribeUrl). The kind decides what
the engine does before and after the send:
| Email | Before sending | Unsubscribe link, List-Unsubscribe |
| ------------- | -------------------------------------------------------------- | ------------------------------------- |
| topic | skipped when the user opted out of that topic, or of all email | yes |
| transactional | always sent (a receipt, a code, an invite) | none, whatever email.unsubscribeUrl |
There is no global sender: share addresses through a module of your own. A
topic email's unsubscribe link is the template's unsubscribeUrl, else the
config's email.unsubscribeUrl, else none; its preferences link is the config's
email.preferencesUrl with the copy's message id, when the copy has one; a
transactional email never has either (resolveEmail is the rule). A layout
reads them from the render, never from a constant, so a template's own URL
reaches its HTML and each copy's link names that copy; the resolved topic is
there too, for a line like "you get these because you subscribed to tips":
import { useEmailContext } from '@shware/workflow/email-context';
const { unsubscribeUrl, preferencesUrl } = useEmailContext();
{
preferencesUrl !== undefined && <Link href={preferencesUrl}>Email preferences</Link>;
}
{
unsubscribeUrl !== undefined && <Link href={unsubscribeUrl}>Unsubscribe</Link>;
}// workflow.config.ts
email: {
unsubscribeUrl: 'https://acme.test/unsubscribe', // templates without their own
preferencesUrl: 'https://acme.test/settings/email', // the app's preference page
tracking: { quickClickMs: 1_000 }, // the default
},The transport is the host's email option, chosen independently of where the
workflow runs. Rendering, tracking and the tracked-link commit happen before it,
the same for every provider (EmailSender):
| Transport | Import |
| -------------------------------------------------------------------------- | ---------------------------------------------- |
| cloudflareEmailTransport({ binding }) — a Worker's send_email | @shware/workflow/transports/cloudflare-email |
| cloudflareEmailTransport({ accountId, apiToken }) — REST, anywhere | @shware/workflow/transports/cloudflare-email |
| sesEmailTransport({ region, credentials? }) — SES v2, SigV4 over fetch | @shware/workflow/transports/ses |
sesEmailTransport runs in a Worker and in Node alike. Without credentials it
reads AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN at send
time (a Lambda's execution role); a Worker passes them, or a provider, from its
env. Neither SES nor Cloudflare Email Service accepts an idempotency key, so the
engine itself keeps a copy from going out twice — see Delivery guarantee.
A host whose workflows send email but that has no email option (nor a
messages override) fails when it is created, naming those workflows.
An email with an unsubscribe link also carries List-Unsubscribe (RFC 2369), so
a mail client's own unsubscribe button works. With reporting on (a Postgres store and
WORKFLOW_ORIGIN), it names the copy's one-click endpoint /t/u/<message>, together with
List-Unsubscribe-Post: List-Unsubscribe=One-Click (RFC 8058). Gmail and Yahoo require
one-click unsubscribe for bulk senders. The provider's POST there unsubscribes at once from
the email's topic; the same URL opened in a browser (and the email's own link) asks first, with
two buttons: the email's topic, and all email. Without reporting, the header names the
template's own unsubscribe link, with no one-click claim. Only https URLs are named, so a local
http:// origin sends no header.
So a topic email carries two ways out, for two readers. The header is for the mail client:
List-Unsubscribe points at the Worker's own /t/u/<message>, which a one-click POST or the
Worker's plain confirmation page settles without the app. The footer is for the person:
preferencesUrl is the app's own page, branded, signed in or not, reached as
https://acme.test/settings/email?m=<message id> (the page's own query is kept, m set beside
it). The message id in m is how the page knows the recipient without a login: it is the
capability, an unguessable UUID that only ever reached that mailbox, the same trust /t/u runs
on. The page's API calls GET /subscriptions/by-message/<m> with the bearer
(JourneyClient.getSubscriptionsByMessage), gets the recipient's record plus
message: { channel, topic } to highlight the topic that email belonged to, and writes with
PUT /subscriptions/by-message/<m> (setSubscriptionsByMessage), the same patch as the user
endpoint. An id that names no copy (never sent, or erased with its user by /users/forget) or a
transactional one answers 404, the rule /t/u applies: show the link as expired. The Worker
does not rate-limit these routes; the app's proxy does, as it sees fit. The link is tracked as
an ordinary click (/t/l/...), never as an unsubscribe: preferencesUrl must not share its
origin and path with unsubscribeUrl (or a template's own), and the config refuses one that
does. Without reporting (no Postgres reporting or no WORKFLOW_ORIGIN) there is no message id,
so preferencesUrl is undefined in the render and the footer shows nothing; a preview
(GET /preview and the studio) renders it with a placeholder id, PREVIEW_MESSAGE_ID.
What a user opted out of is the engine's record, per topic and channel: a topic email is
skipped while its topic (on email) or all email is opted out, a transactional email never is,
and reachable('email') reads the all-email opt-out only — a user unsubscribed from one topic
is still reachable, so an exitWhen: not(reachable('email')) fires on "all email", not on a
topic. An email opt-out says nothing about push: the same topic is opted out per channel. The
app reads and sets the record through GET / PUT /subscriptions/:userId
(JourneyClient.getSubscriptions / setSubscriptions), which is how a signed-in preference
page is built: the engine holds the truth, the page shows it. The all-email opt-out and the
topic opt-outs are separate records: all: false masks every topic, whatever its own flag says,
and all: true clears only the all-email opt-out — a topic opted out on its own stays off until
a patch names it.
An email step refuses to send when the profile has no email.
GET /preview/<id>?props=<JSON> renders a template, with the subject in the
x-subject header.
Push
pushTemplate({ id, topic | transactional, title, description?, props, preview, content }),
where content(props) returns { title, body, image?, url? }: final text, never
placeholder-expanded. url is the page a browser push opens.
export default pushTemplate({
id: 'onboarding_tip_push',
topic: 'tips',
title: 'Onboarding tip (push)',
props: strictObject({ name: string() }),
preview: { name: 'John Doe' },
content: ({ name }) => ({ title: 'A tip for today', body: `Face a window, ${name}.` }),
});A push is a topic push (one of the contract's subscriptions.topics) or a
transactional one (transactional: true: a code, a receipt, a reply), like
an email. A topic push is skipped while the user opted out of that topic on
push, or of all push; a transactional push always goes. The app records the
opt-outs through PUT /subscriptions/:userId (JourneyClient.setSubscriptions
with { push: { all?, topics? } }), which is the in-app switch App Store Review
Guideline 4.5.4 requires for marketing pushes: push.all: false is "no
promotional notifications", a topic false is one category off. The opt-outs are
per channel: a topic switched off for push is still sent by email.
reachable('push') is a device plus no all-push opt-out; a topic opt-out leaves
it true. Changing push.all wakes the waits on it, as a device change does.
Pushes go to the devices the engine keeps. The app registers each token with
JourneyClient.registerDevice(userId, { token, platform }), and a token moves
to the latest user that registers it. Each platform is turned on by the config,
with its secret in the environment:
| Platform | Config | Secret | Transport |
| --------- | ---------------------------------------------- | --------------------------------------- | -------------------------- |
| ios | apns: { topic, keyId, teamId, environment? } | APNS_PRIVATE_KEY_BASE64 (the .p8) | @shware/workflow/apns |
| android | fcm: { androidChannelId? } | GOOGLE_APPLICATION_CREDENTIALS_BASE64 | @shware/workflow/fcm |
| web | webPush: { subject, openedEvent? } | VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY | @shware/workflow/webpush |
A secret is only read when something is sent on that platform. Only a token
the provider reports as gone is removed (and waits on reachable('push') wake
on every device change); a configuration error keeps the device and fails the
step without retrying, and a transient answer is retried:
| Provider | Token removed, copy settled | Settled, device kept | Failed, no retry (PermanentError) | Retried |
| -------- | ----------------------------------- | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| APNs | 410; Unregistered, ExpiredToken | BadDeviceToken (a token or the environment) | DeviceTokenNotForTopic, TopicDisallowed, BadTopic, MissingTopic, bad certificate or provider token, other 4xx | 403 ExpiredProviderToken (re-signed), 429, 5xx |
| FCM | UNREGISTERED; 404 NOT_FOUND | INVALID_ARGUMENT | SENDER_ID_MISMATCH, THIRD_PARTY_AUTH_ERROR, other 4xx | 401 (access token re-minted), QUOTA_EXCEEDED, UNAVAILABLE, 429, 5xx |
| Web Push | 404, 410 | — | payload over 3993 bytes (refused before sending), 400, 401, 403, 413, other 4xx | 429, 5xx |
Every APNs request carries apns-id and apns-collapse-id, and every web
push a Topic, derived from the copy's idempotency key, so the rare copy sent
again (a template with redelivery: 'resend') collapses on the device; FCM has no
such header and does not de-duplicate — see Delivery guarantee.
Provider credentials are kept
per host (ApnsProviderToken, FcmAccessToken, VapidSigner): the APNs
provider token, the FCM access token and the VAPID key are reused across runs,
while the senders themselves are built per run.
apns.environment maps a mode to sandbox or production; unlisted modes use
production in production and sandbox otherwise. Generate a VAPID pair once
per environment with generateVapidKeys() from @shware/workflow/webpush.
webPush.openedEvent names the contract event a browser's notification tap is
also recorded as ({ message_id }), so conditions see web opens too.
APNs speaks HTTP/2 only, which wrangler dev cannot. Locally, run the relay
next to it and point the Worker at it with APNS_RELAY_ORIGIN:
import { startApnsRelay } from '@shware/workflow/apns-relay';
const relay = await startApnsRelay({ port: 8280 });
// wrangler dev --var APNS_RELAY_ORIGIN:http://localhost:8280The relay sees the provider JWT, so production (ENVIRONMENT unset or
production) ignores APNS_RELAY_ORIGIN: it calls APNs directly and logs one
console.error saying the variable was ignored.
Slack and Discord
Team-channel messages go through incoming webhooks. The URL is a secret, so the
template reads it from the Worker's environment when it sends. Pass the
environment's Zod schema to journeyWorker({ env }): it is checked on the
first request, every problem listed, and the parsed values are what templates
see.
import { slackTemplate } from '@shware/workflow';
import { optional, strictObject, string } from 'zod/mini';
import type { Env } from '../env';
export default slackTemplate({
id: 'nps_detractor',
title: 'NPS detractor (support)',
props: strictObject({ name: string(), email: optional(string()) }),
preview: { name: 'John Doe', email: '[email protected]' },
webhook: (env: Env) => env.WEBHOOK_SLACK_SUPPORT,
content: ({ name }) => ({ text: `${name} gave us a low NPS score` }),
});discordTemplate takes the same fields; its content returns a Discord webhook
body (content, embeds, allowed_mentions, …). Register both under
templates: { email, push, slack, discord }. A request the channel refuses for
good settles the copy as undelivered rather than retrying, so a team-channel
post never holds up the messages after it.
Delivery guarantee
A message is delivered at most once. Each copy goes through one durable step:
the copy's row is recorded, the copy is claimed (message.claimed_at, committed
before the send), the transport takes it, and the result is settled
(delivered_at or undelivered_reason). On a replay of the step:
- a settled copy is skipped;
- a copy the transport refused for sure — a thrown send, a 5xx, a 429 — had its claim released, so the retry sends it, as before;
- a copy that is claimed but not settled is treated as possibly delivered and is not sent again. The run that claimed it died between the transport taking the copy and the report (or the step's checkpoint) committing, so no one knows whether it went out. The engine logs a warning with the idempotency key and moves on.
Exactly-once is not available: Cloudflare Email Service accepts no idempotency
key or client message id — the send_email binding's send() takes only the
addresses, subject, body, headers and attachments and returns a server-generated
messageId (Workers API),
and the REST endpoint is the same — and SES has none either. APNs (apns-collapse-id)
and Web Push (Topic) collapse a repeat on the device; FCM does not.
Between the two options left, at most once is the one for journeys: a duplicate marketing email costs spam complaints, unsubscribes and sender reputation, while a lost nudge is invisible to the user and the journey has later steps. Customer.io and Braze behave the same way — the delivery record is the source of truth, nothing is resent on uncertainty, and frequency caps sit on top. A transactional template that must arrive opts into at-least-once per template:
emailTemplate({
id: 'dispute_opened',
transactional: true,
redelivery: 'resend', // a duplicate is a nuisance here; a missing copy is a failure
// ...
});redelivery ('skip' by default, 'resend' to opt in) exists on every template
kind — email, push, Slack, Discord. This is a deliberate product decision made on
2026-11-10 after reviewing the alternatives (provider-side de-duplication, a
shorter claim window, resending with a dedupe header): do not change the default
back to at-least-once; set redelivery: 'resend' on the templates that need it.
The decision is kept by the host's reporting store: claimed_at is a column of
message (hosts upgrading regenerate the drizzle file and migrate, see
Postgres). Without a reporting store there is no record between two
runs of a step, and a replay sends again — the behaviour of every version before.
Other channels
.sms(), .inApp() and .survey() compile, and take a hand-typed reference
by key: template.sms<P>(key) (also inApp, survey). The config's channels deliver only email, push, Slack and
Discord; supply anything else with journeyWorker({ messages }).
Configuration
defineConfig takes:
| Field | Holds |
| ----------- | ----------------------------------------------------------------------------------------- |
| title | The Studio title |
| contract | The defineContract(...) every /ingest is validated against |
| workflows | The workflows to deploy |
| segments | Named segments |
| actions | Custom actions .run() uses |
| templates | The registries: { email?, push?, discord?, slack? }; topics typed by the contract |
| email | { unsubscribeUrl?, preferencesUrl?, tracking? }: what every topic email template shares |
| runtime | Per mode (development, production, …): { timeScale?, logMessages? } |
| apns | { topic, keyId, teamId, environment? } |
| fcm | { androidChannelId? } |
| webPush | { subject, openedEvent? } |
| database | { schema? }: the Postgres schema, workflow by default |
The ENVIRONMENT var picks the runtime entry (production when unset).
timeScale fast-forwards every duration: 600 turns "1 day" into 2.4 minutes. The production
entry's type holds it at 1 (ProductionRuntimeOptions): a development factor pasted under the
production key is a compile error, not every user's journey compressed. A deploy meant to run fast
is not production: give it a mode of its own (ENVIRONMENT=rehearsal), as free as development.
Secrets never go in the config; the Worker reads them from its environment:
| Variable | Purpose |
| --------------------------------------- | ---------------------------------------------------------- |
| API_TOKEN | Bearer for every endpoint but /health and /t/... |
| WORKFLOW_ORIGIN | Public origin for email tracking and push delivery reports |
| ENVIRONMENT | Which runtime entry and APNs environment apply |
| APNS_PRIVATE_KEY_BASE64 | The APNs .p8 key, base64 |
| GOOGLE_APPLICATION_CREDENTIALS_BASE64 | The Firebase service account JSON, base64 |
| VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY | The Web Push key pair |
| APNS_RELAY_ORIGIN | Development only: the local APNs relay |
journeyWorker (and journeyLambda) also take observer, messages and runtime overrides,
contractMode: 'report' to log contract refusals instead of answering 400, and authorize to
replace the API_TOKEN bearer with a check of your own (see
AWS deployment notes).
JourneyClient
@shware/workflow/client is the producer side: plain fetch, no dependencies.
With a contract, event names and payloads are typed and checked before
anything is sent (a refusal throws JourneyValidationError); a failed request
throws JourneyClientError with its status and body.
const journeys = new JourneyClient({ contract, origin, token });| Method | Endpoint | Does |
| --------------------------------------------------------------------- | --------------------------------- | ---------------------------------------------------------------------------------- |
| ingest({ userId?, visitorId?, profile?, events? }) | POST /ingest | The one way in: merge a visitor, then the profile, then record the events in order |
| registerDevice({ deviceId?, userId?, visitorId?, token, platform }) | POST /devices | Register a push token for whoever holds the device now |
| unbindDevice(token) | POST /devices/unbind | Signed out: the token stays, bound to nobody |
| removeDevice(token) | POST /devices/remove | Forget a token for good |
| messageOpened(userId, messageId) | POST /messages/opened | A journey push tapped open (the message_id it carried) |
| getSubscriptions(userId) | GET /subscriptions/… | { email, push }, each { all, topics }, true = on |
| setSubscriptions(userId, patch) | PUT /subscriptions/… | Change what the patch names; answers the new state |
| getSubscriptionsByMessage(messageId) | GET /subscriptions/by-message/… | The recipient's record by a copy's id, plus message: { channel, topic } |
| setSubscriptionsByMessage(messageId, patch) | PUT /subscriptions/by-message/… | The same patch, for the copy's recipient; answers as the GET does |
| forgetUser(userId) | POST /users/forget | Stop the user's journeys and erase their data |
| forgetVisitor(visitorId) | POST /users/forget | The same for a visitor who never signed in |
| deploy(bundle) | POST /deploy | Deploy a compiled bundle (development; a production host with its own answers 409) |
Pass each event's id whenever a caller may retry; see
Give each event an id.
ingest is the only way to report: the profile and the events it belongs to travel
as one batch, so a journey an event starts always finds its recipient. With separate
calls an email step could run before the address arrives and settle as no_recipient.
Visitors and devices
Three ids, with different jobs:
| Id | What it says | At sign-out |
| ----------- | ------------------------------- | ---------------- |
| userId | Which account | No longer sent |
| visitorId | Who this is before signing in | Must be replaced |
| deviceId | Which device holds a push token | Stays |
visitorId is any stable id for one client, as long as it changes when a user signs
out; visitorId() from @shware/analytics already does. Journeys and events belong
to a user or a visitor, never to a device: one device can pass between people.
await journeys.ingest({ visitorId, events }); // not signed in
await journeys.ingest({ userId, visitorId, profile, events }); // signed in
await journeys.registerDevice({ deviceId, visitorId, token, platform });Naming both ids says this visitor is this user. Their events, profile, devices and opt-outs move to the user, once, and a later report of that visitor reaches the user. A visitor already linked stays with its first user. Their running journeys carry on as the user's, except one the user is already in, which stops.
A visitor enters a workflow only if its trigger asks for them. They have no address, so such a journey reaches them by push, and an entry made as a visitor counts as the user's once they sign in:
workflow({ id: 'offer', trigger: trigger.event(e.browse, { visitors: true }) });Segments are about users: visitors are not swept and do not enter segment triggers.
A visitor who never signs in is deleted 90 days after they were last heard from, unless a journey of theirs is still running. A link to a user is kept, so late reports still reach the user and, once the user is erased, are refused.
A device table row holds only the current owner, so after a sign-out the previous user
can no longer be reached there. unbindDevice clears it at sign-out; the client then
registers again with its new visitorId.
JourneyQueue
JourneyQueue sends the same batch through a queue, for a producer that must
neither lose a report nor wait for the engine:
const queue = new JourneyQueue({ contract, queue: env.JOURNEY_QUEUE });
await queue.ingest({
userId,
visitorId,
profile: { email, name },
events: [{ event: 'login', payload: { method }, id: rowId, ts }],
});Once ingest resolves the batch is durable. A rejection means it was not queued,
and the caller decides what follows. Export journeys.queue from the Worker and
declare the queue with a dead letter queue:
"queues": {
"consumers": [
{ "queue": "journey", "max_retries": 8, "dead_letter_queue": "journey-dead" }
]
}The consumer retries a failed batch with a backoff from 30 seconds up to 15
minutes. It acknowledges a batch the contract refuses, logging its field paths,
and one for an erased user: neither can ever succeed. Delivery is at least once
and in no guaranteed order. Batches can arrive out of order, but event ids absorb
repeats and each profile property keeps its latest at, so send what must stay
in order as one batch.
getSubscriptions and setSubscriptions are typed by the contract's
subscriptions.topics, and a topic it does not declare is refused before the
request. The record is topic × channel: email and push each list every
topic. A preference page is a thin proxy over them:
const { email, push } = await journeys.getSubscriptions(userId);
// email: { all: false, topics: { promotions: false, newsletter: true, tips: true } }
// push: { all: true, topics: { promotions: true, newsletter: true, tips: true } }
// `all: false` masks every topic: newsletter reads true, but no email is sent until `all` is
// true again. Each flag is its own record, so a preference page shows `all` as the master
// switch and the topics as they are. Push is untouched by the email record.
await journeys.setSubscriptions(userId, { email: { all: true } });
// email: { all: true, topics: { promotions: false, newsletter: true, tips: true } }
// `all: true` cleared only the all-email opt-out; promotions stays off until named:
await journeys.setSubscriptions(userId, { email: { topics: { promotions: true } } });
await journeys.setSubscriptions(userId, { email: { all: false } }); // unsubscribe from everything
await journeys.setSubscriptions(userId, { push: { all: false } }); // the in-app "no promotional pushes"
await journeys.setSubscriptions(userId, { push: { topics: { promotions: false } } }); // one category off, on push onlyA "subscribe to all" button sends all: true plus every topic true; an
"unsubscribe from all" sends all: false and leaves the topics as they are, so
turning all back on restores the user's own choices.
The same page opened from an email's footer, with no session, reads the m query parameter the
preferencesUrl link carries and goes by the copy instead of the user (see
Email); a 404 (JourneyClientError with status 404) is an expired link:
// GET /api/email-preferences?m=<message id>, proxied by the app's API, rate-limited there
const { email, push, message } = await journeys.getSubscriptionsByMessage(m);
// message: { channel: 'email', topic: 'promotions' } — the email this page was opened from
await journeys.setSubscriptionsByMessage(m, { email: { topics: { [message.topic]: false } } });Compiling and planning
compileConfig(config) returns the BundleIR the Worker deploys. It is
compileBundle plus a template manifest generated from the registries, and it
checks every message node: an unregistered or cross-channel template, a missing
or undeclared prop, a literal the schema refuses, or a u.xxx the contract's
profile does not declare fails with the workflow and node named
(ConfigCompileError).
compileBundle({ workflows, segments?, templates?, actions? }) is the lower
level: it compiles each workflow, rejects duplicate ids, segment references
missing from the bundle, payload predicates outside a where, and actions that
are not passed in.
import { compileConfig, plan } from '@shware/workflow';
import config from './workflow.config';
const bundle = compileConfig(config);
const changes = plan(bundle, deployedBundle); // deployed: `GET /bundle` of the running Worker
if (changes.hasChanges) console.log(changes.workflows.filter((w) => w.status !== 'unchanged'));plan(local, deployed?) diffs workflows, segments, templates and actions.
Each change is added, removed, changed, metadata_only or unchanged,
and a workflow change lists the fields (trigger, goal, exitWhen) and
nodes that moved. Omitting deployed plans a first deploy.
Versioning
Every workflow carries a contentHash over its own IR, with metadata excluded,
so a reworded description is not a new version but a changed delay is. The hash
is the address the IR is stored under, and a journey is pinned to the hash it
entered on. An in-flight journey therefore finishes on the version it started,
however many times you deploy underneath it.
plan() diffs a compiled bundle against what is deployed and reports which
workflows actually changed. Deploy recomputes every declared hash and refuses a
bundle whose hashes do not describe its contents.
Node ids are structural paths, so inserting a node shifts the ids of the nodes after it. Template ids are stable identities too: an id in flight takes only backward-compatible changes, so a new required prop means a new id.
Cloudflare setup
The Worker needs two bindings, named as in JourneyEnv and the quick start's wrangler.jsonc:
| Binding | Type | Holds |
| ------------ | ---------- | ----------------------------------------------------------- |
| JOURNEY | Workflow | The JourneyRunner entrypoint |
| HYPERDRIVE | Hyperdrive | The connection to the engine's Postgres (postgresJsStore) |
The Worker needs the nodejs_compat compatibility flag (the bearer check uses
node:crypto's timingSafeEqual). Add the EMAIL send_email binding
when it sends through Cloudflare Email (cloudflareEmailTransport({ binding: env.EMAIL })), and a triggers.crons entry with scheduled
exported to run the segment sweep.
Set API_TOKEN in production: a host whose ENVIRONMENT resolves to production refuses every
request but GET /health and /t/... with a 503 until it is set. Any other mode (development,
say) runs open, for local work only — /deploy rewrites the whole routing table. In production a
host that carries its bundle (journeyWorker, the AWS host) refuses POST /deploy with a 409:
it deploys by publishing a version (the new version applies its own bundle on its first request;
POST /bundle/deploy re-applies it after a rollback), so a bundle posted by hand there could only
be a leaked bearer at work. The bare handleRequest API carries none and still takes one.
Erasing a user (POST /users/forget) commits an erasure tombstone first,
which every write about the user is conditional on from then on. A runner
admits itself against that tombstone before its first effect — before the
observer hears of it, before any action or send — on every invocation and on
every replay after an eviction, never from a checkpoint: an instance whose
create lands after the tombstone, or that wakes after it, exits without
running. The erasure stops the admitted instances (terminate), and only once
each is known stopped deletes the data; erased: true means every admitted
runner was confirmed stopped, the entries never admitted can never run, and
the data is gone. An admitted instance that will not stop answers
erased: false and the scheduled sweep retries it. Every write about the user
runs in the user's locked transaction, the lock the tombstone itself takes: a
write either committed before the tombstone and is deleted by the erase, or
waited for the lock and is refused by the tombstone it then sees. There is no
window to come back for and no second pass; the tombstone stays, so a late
write cannot bring the user back.
One database per host. The routing tables are global (triggers, segments, the live
deployment row), so two Workers on one Postgres schema would swap each other's routes on
every deploy: give staging and production their own schema (database: { schema }) or
database.
Cloudflare deployment notes
- Email transport. Pass
emailexplicitly:cloudflareEmailTransport({ binding: env.EMAIL })for Cloudflare Email, orsesEmailTransport({ region, credentials })with credentials from the Worker's env, since a Worker has no execution role. A Worker whose workflows email without one fails at startup. - List-Unsubscribe. Cloudflare Email rejects a
List-Unsubscribeheader with a non-TLS URI, so the one-click header is only sent with an httpsWORKFLOW_ORIGIN.wrangler devonhttp://localhostsends none. - Workflows limits (paid plan):
- An instance's wall time is unlimited.
- A single
step.sleeplasts at most 365 days. - 10,000 steps per instance by default, configurable up to 25,000. Sleeps do not count, but every wait attempt costs a few steps.
- A step's result is at most 1 MiB.
- A completed instance's state is kept 30 days.
- Authorization. Set
API_TOKENwithwrangler secret put, or passauthorize(below). The tracking routes (/t/...) stay public by design. - Deploying. With the
CF_VERSION_METADATAbinding, eachwrangler deploymakes its own bundle live (see Postgres);POST /bundle/deployforces it. - APNs in development.
wrangler dev(workerd) cannot make HTTP/2 subrequests: run@shware/workflow/apns-relayand pointAPNS_RELAY_ORIGINat it. A deployed Worker needs no relay, and production ignores the variable.
AWS Lambda
The same config runs as one Lambda durable function behind a Function URL, on
Postgres. @aws/durable-execution-sdk-js and @aws-sdk/client-lambda are optional
peer dependencies, needed only by @shware/workflow/aws. The durable SDK's own type
declarations reference aws-lambda, so a TypeScript project that type-checks
dependencies (no skipLibCheck) also needs @types/aws-lambda.
// src/aws.ts
import { journeyLambda } from '@shware/workflow/aws';
import { postgresJsStore } from '@shware/workflow/postgres-js';
import { sesEmailTransport } from '@shware/workflow/transports/ses';
import config from '../workflow.config';
import { env } from './env.aws'; // process.env, parsed with zod
const journeys = journeyLambda({
config,
env,
store: (env, database) => postgresJsStore(env.DATABASE_URL, database),
email: (env) => sesEmailTransport({ region: env.AWS_REGION }),
});
export const handler = journeys.handler;One handler serves three inputs, told apart by the event's top-level shape, so no HTTP body can pass for the others:
- A Function URL request: the HTTP API, as on Cloudflare. A journey it
starts is its own durable execution, submitted with an asynchronous
Invokeof this function's numbered version under a stable execution name, and the request returns. - A run (
$workflow.run): one instance. Steps, delays and waits map onto the durable SDK'sstep,waitand callbacks; a wait parks on a fresh callback, recorded in Postgres, which the next relevant event completes. - A sweep (
{ "type": "$workflow.sweep" }, from EventBridge Scheduler, orPOST /segments/sweepfrom an existing cron): the segment sweep, plus retrying starts whose submit was never confirmed and reconciling executions the platform ended (timeout, stop) into the ledger. Without one, clock-only segments and this upkeep do not happen.
A start is recorded with its entry in one transaction (the execution table),
so a lost Invoke response is retried with the same name and payload and never
re-entered as a second execution. Retries stop after 12 hours (startRetryMs,
keep it under the execution retention) and the start is left unresolved. A
failed entry that is entered again gets a new execution, never the closed one.
Erasing a user (POST /users/forget) commits the tombstone, stops their
admitted executions, then waits stopDrainMs (15 minutes by default; at least
the function's timeout) before erasing: a stop does not interrupt an invocation
already running, which goes on to its next checkpoint. Until then the erasure
answers erased: false and the sweep finishes it; the drain is one judgment
for every path — the sweep's reconcile keeps a stopped execution's entry
running until the drain has passed, and an execution the platform reports
stopped before the stop was recorded is dated at its first sighting, never
later. An execution whose runner never admitted itself needs no stop: its run
entry meets the tombstone and exits, and so does a wake of a stopped one. The
tombstone takes the user's lock, which every write about the user also takes,
so nothing of theirs lands after it and no second pass is needed.
Deploy one artifact per version: publish a numbered version and point the
live alias, which the Function URL is bound to, at it. Each version carries its
compiled bundle and makes it live by itself, only over an older one. Each route
records the version that applied it, and an instance it starts runs on that
version, so its IR and its code always match, even while an older version is
still serving; executions stay on the version that started them. POST /deploy of an arbitrary bundle is
refused.
Workflow:
Type: AWS::Serverless::Function
Properties:
Runtime: nodejs24.x
Handler: aws.handler
Timeout: 60
AutoPublishAlias: live
# ExecutionTimeout: the longest journey (waits, delays and the goal window), up to 366 days;
# pass the same value to journeyLambda({ executionTimeoutMs }) to check it at startup
DurableConfig: { ExecutionTimeout: 2592000, RetentionPeriodInDays: 30 }
FunctionUrlConfig: { AuthType: NONE, InvokeMode: BUFFERED }AWS deployment notes
Checked against a real durable Lambda (Node 24, us-east-1).
Function URL: AuthType: NONE, with the API protected in the app. Not every caller can
sign a request:
| Caller | Routes |
| ------------------------------------------ | ------------------------------------------- |
| Your backend | /ingest, /devices, /users/forget, ... |
| Recipients' mail clients and browsers | /t/o, /t/l, /t/u |
| Gmail / Yahoo one-click unsubscribe | POST /t/u/<message> (RFC 8058) |
| iOS notification extension, service worker | POST /t/d, POST /t/p |
An alias has one URL and one auth type, and the tracking and one-click routes must stay public:
AWS_IAMwould break those routes. SigV4 also takes theAuthorizationheader, so the bearer could not travel with it.- CloudFront's origin access control (OAC) does not help either: with OAC, every POST must carry
the body's SHA-256 in
x-amz-content-sha256, and a mail provider's one-click POST does not.
Keep the routes public and protect the API in the app: the API_TOKEN bearer by default, or
your own authorize. Production refuses to serve without one of the two.
Your own authorization. authorize(request, env) replaces the bearer check on every API
route, in any format you choose: an HMAC over the body, a JWT, a header your proxy adds. It
receives a copy of the request, so it can read the body, and the env the host validated. True
lets the request through, false answers 401, and a throw answers 500 rather than letting the
request in. The tracking routes and /health never ask it. bearerMatches and
timingSafeEqual are exported for building one:
import { journeyLambda, timingSafeEqual } from '@shware/workflow/aws';
journeyLambda({
// ...
authorize: async (request, env) =>
timingSafeEqual(
request.headers.get('x-signature') ?? '',
await hmac(env.SECRET, await request.text())
),
});Resource policy. Grant lambda:InvokeFunctionUrl to everyone (FunctionUrlAuthType: NONE)
and lambda:InvokeFunction only with lambda:InvokedViaFunctionUrl = true, so the URL cannot
be used to invoke the function directly.
Put CloudFront and WAF in front, without OAC. A custom domain needs CloudFront (or another HTTPS proxy) anyway; Route 53 alone cannot terminate TLS for a Function URL. Add a rate-based WAF rule there: every request that reaches the function starts a durable execution and is billed, even one the app answers 401. Reserved concurrency caps what a flood can cost.
What a request costs. Every Function URL request is a durable execution of its own:
| Execution | Durable ops | Per million |
| ------------------------------------------- | ----------- | ----------- |
| GET (pixel, redirect, /health), no step | 1 | about $9 |
| POST (an event, an identify), one step | 2 | about $17 |
| A plain, non-durable Lambda for comparison | - | about $1 |
| A journey: email, delay, one wake-up, email | 24 | about $190 |
Durable operations cost $8 per million, on top of Lambda's request and duration charges. That is
negligible at thousands of requests a month and worth a second, non-durable function for HTTP at
tens of millions. Server-side latency stays small: /health takes about 6 ms, a write 40–300 ms.
Quotas.
- Starting durable executions: 3,000 per second (6,000 in us-east-1, us-west-2 and eu-west-1). HTTP requests and journey starts share it.
- 30,000 operations per execution, which cannot be raised. A wait attempt costs about five: the callback plus four steps.
- 1 MB per operation's payload. Reads (
GET,HEAD) are answered outside a step so their bodies are not checkpointed.
Timeouts and retention.
- Set
DurableConfig.ExecutionTimeoutto the longest journey (waits, delays and the goal window), up to 366 days, and pass the same value as `journeyLambda({ executionTimeoutMs
