Litestore · Developer docs
Background work

Workflows

Litestore sends follow-ups days after an event with workflows, and some delayed jobs belong in a workflow rather than a cron.

A workflow lets Litestore follow up on something days after it happened, for example by emailing a shopper a day after they abandon a cart. Read this page when you need to add a delayed or repeated follow-up to an event.

JunJulAugSepOctNov
Summer drop
Live
Linen launch
Draft
Back to school
Scheduled
Black Friday
Scheduled
Campaigns switch on and off on their dates.
A shopper abandons a cart, and Litestore sends a follow-up and later a coupon after the delays set in code.

A workflow pairs a domain event with an async function. The function receives the event payload and a durable step object.

All of the workflow code is in modules/email/automations.ts. It contains a type, a builder and a WORKFLOWS array with three entries. The domain-event handler in jobs/domain-events.ts runs every entry that matches the event.

Litestore has no separate workflow engine. You write the order of steps and the delays as plain code that calls Inngest's own step.sleep and step.run. A follow-up that spans several days therefore needs no state machine, no scheduler table and no job to reconcile them.

Step object

The type has only two methods on purpose. A workflow can pause, and it can run a side effect whose result is remembered. It can do nothing else:

modules/email/automations.ts
type WorkflowStep = {
  /** Suspend for a duration ("3d", "2h") — the reaction resumes later, durably. */
  sleep: (id: string, duration: string) => Promise<void>
  /** Run + memoise a side effect so it fires exactly once across resumes. */
  run: <T>(id: string, fn: () => Promise<T>) => Promise<T>
}

type Workflow = {
  event: DomainEventType
  name: string
  run: (payload: unknown, step: WorkflowStep) => Promise<void>
}

Where you define a workflow, the workflow() builder gives the payload the type of its event. It then stores the result as a plain Workflow. Workflows are type-safe while you write them, and the array can still hold workflows for different events. workflowsFor(event) is the only code that reads the array.

Runtime

Every domain-event function runs its workflows at the very end, after it has cleared caches, sent webhooks and run the handler body:

jobs/domain-events.ts
for (const flow of workflowsFor(eventType)) {
  await flow.run(stripPayloadMeta(payload), {
    sleep: (id, duration) => step.sleep(`${flow.name}:${id}`, duration),
    run: <T>(id: string, fn: () => Promise<T>) =>
      step.run(`${flow.name}:${id}`, fn) as Promise<T>,
  })
}

Litestore prefixes each step id with the workflow's name. Two workflows on the same event therefore can never use the same step id. If no workflow matches an event, nothing runs.

Built-in workflows

The cart.abandoned event starts this workflow.

It waits ABANDONED_CART_FOLLOWUP_HOURS and sends a "Still thinking it over?" follow-up. If ABANDONED_CART_COUPON_CODE is set, it then waits ABANDONED_CART_COUPON_HOURS minus the first delay and sends an email with that coupon.

Before each email it calls cartRecovered(cartId). If the cart now has an order or has been deleted, the workflow stops.

Before it sends the coupon, it reads the coupon again and runs getCouponState. Since the cart was abandoned, someone may have disabled or deleted the code, it may have expired, or it may have reached its usage limit. Litestore must never email a coupon code that no longer works.

Shared behavior

Each delay is a typed constant in platform/config/email-automations.ts, not an admin setting. To change one, edit the constant and redeploy. A value of zero or less turns that step off. If SIGNUP_NUDGE_DAYS is 0, the nudge never goes out. If REVIEW_REMINDER_DAYS is 0, the customer gets the review request but no reminder.

ConstantWorkflowWhat it controls
ABANDONED_CART_FOLLOWUP_HOURSrecovery-dripThe wait before the first follow-up.
ABANDONED_CART_COUPON_HOURSrecovery-dripThe time until the coupon email.
ABANDONED_CART_COUPON_CODErecovery-dripThe code in the coupon email. Empty means no coupon email.
REVIEW_REQUEST_DAYSreview-requestThe wait before the review request.
REVIEW_REMINDER_DAYSreview-requestThe wait before the reminder.
SIGNUP_NUDGE_DAYSonboarding-nudgeThe wait before the nudge.

All three send classification: "marketing" email through queueEmail. The unsubscribe check and the List-Unsubscribe headers in jobs/email.send.ts therefore apply to them.

All three look up the customer's email address at the moment of sending, through customerContact. It returns null when the customer has no email on file.

The workflows check things again because hours or days have passed while they slept. The cart, order or coupon may have changed since the workflow started waiting.

jobs/referral.reward.ts works differently. The payment webhook works out the wake-up date from REFERRAL_REWARD_HOLD_DAYS when the event is sent and puts it in the event payload, so a run that is already waiting keeps the date it started with.

Durable waits and polling crons

Both durable waits and polling crons run work later. The abandoned-cart flow uses both, which makes the difference easy to see.

QuestionDurable waitPolling cron
What starts it?A domain event arrives.A time on the clock.
How do you write it?await step.sleep("wait", "24h") inside a workflow.A { cron: "*/30 * * * *" } trigger in jobs/cron.*.ts.
Does it have the context?Yes, because it keeps the payload while it sleeps.No, so it has to search for candidates on every run.
How does it avoid doing work twice?Inngest stores the result of each step id within a run.You write it yourself. cron.abandoned-carts.ts sets abandonedEmailSentAt before it does anything else.
What does it cost when there is nothing to do?Nothing.It still runs and finds nothing.

Crons

Crons exist to detect conditions. No event is sent when a cart goes quiet, when a Stripe webhook never arrives, or when a shipment misses its estimated date. Nobody is there to send it.

So cron.abandoned-carts.ts checks for idle carts every 30 minutes and emits cart.abandoned for each. From then on, the recovery-drip workflow takes over with real durable waits. The cron detects the cart, and the workflow follows up.

cron detects an idle cart
    ↓
emits cart.abandoned
    ↓
recovery-drip sleeps, re-checks, sends

The inline fallback in platform/events/emitter.ts runs handler bodies inside the app without durable steps, so it skips workflows entirely. To test a follow-up locally, run bunx inngest-cli dev.

Event-triggered functions

jobs/ has four more functions that are neither crons nor domain-event handlers. Each one listens for its own event name, and Litestore registers each on its own in app/api/inngest/route.ts.

Mechanisms

Use a workflow when you already have the ID of the cart, order or customer you are dealing with and the next step only has to wait, such as a cart that was just abandoned or an order that was just delivered.

A workflow follows one specific item forward in time. A cron goes looking for items that nobody announced.

On this page