Steeren/Nurturing sequenceslive from the platform← site
Engage your audience

Nurturing sequences

A nurturing sequence is an automated, multi-step email drip that fires for one lead at a time. When a new (non-spam) lead lands in the CRM, the matcher checks it against your active sequences, enrolls it where the trigger fits, and a background runner sends each step on a schedule until the sequence completes — or the lead converts, opts out, or you cancel it.

This is the per-lead sibling of newsletters. A newsletter is a broadcast: one issue, many recipients, sent on demand. A sequence is per-lead: each lead has its own position in the drip and its own clock. The two share a parent nav ("Nurturing") in the dashboard but are entirely separate schemas (nurturing.* vs newsletters.*).

This article covers how sequences match leads, how steps are ordered and edited safely, the enrollment state machine, send windows, the guarantees against double-sends, what happens when a lead drops out, AI step generation, and one-click unsubscribe.

[!NOTE] Nurture emails go out through Resend, like newsletters. If a sequence sets a custom from_email, its domain must be verified for the workspace (platform-owned domains are exempt). Leave from_email null to use the system default sender, which needs no verification. See sender domains.

The pieces

A sequence has four moving parts, each backed by its own table:

Concept Table What it holds
Sequence nurturing.sequences The definition: name, trigger binding, send config, status
Step nurturing.sequence_steps One email in the drip — delay, optional window, subject, body
Enrollment nurturing.enrollments One lead's run through one sequence — the state machine
Send nurturing.sends Append-only log of every send attempt; the idempotency key

You build sequences and steps directly (dashboard or MCP tools). Enrollments and sends are written by the background workers — you read them, you don't create them.

Sequences: draft to active

A new sequence starts as status = 'draft'. Drafts don't match leads — the matcher only looks at active sequences. The lifecycle is:

Status Behaviour
draft Editable, never matches leads. The default on create.
active Matches new leads and sends.
paused Stops matching new leads; in-flight enrollments keep their active status, but the send worker detects the paused sequence at dispatch and pushes the next send out by an hour instead of sending. Resume by setting status back to active.
archived Stops matching; cancels every in-flight enrollment with reason sequence_archived. Terminal.

The typical flow: create the sequence as a draft, add steps, generate or write the copy, then flip it to active.

nurturing_create_sequence   name="New-lead welcome", status="draft"
nurturing_add_step          delay_seconds=0,     subject="Thanks for downloading"
nurturing_add_step          delay_seconds=259200, subject="One more thing"   # +3 days
nurturing_update_sequence   status="active"

delay_seconds on the first step (position 0) runs from the moment of enrollment. On every later step it runs from the previous step's send. 259200 seconds is three days.

[!WARNING] Activating a sequence with no live steps is a no-op for any lead it matches — the matcher enrolls nobody into an empty sequence (it loads the first live step and bails if there isn't one). Add at least one step before going active.

Trigger binding

A sequence's trigger decides which leads it catches. There are three ways to bind, and when several sequences match the same lead, the most specific one wins. Specificity is ranked, most-specific first:

Rank Bind via Matches when match_reason
1 trigger_source_form_id The lead's source_form_id equals this exact form id source_form_id
2 trigger_source_pattern (exact) The lead's source equals the pattern verbatim, e.g. blog_form pattern_exact
3 trigger_source_pattern (wildcard) The pattern ends in * and is a prefix of the lead's source, e.g. form:* matches form:guide-dl pattern_wildcard
4 is_catch_all: true Nothing above matched catch_all

The match against source / source_form_id happens in the matcher worker (in JavaScript), not in SQL — that's what lets it rank by specificity rather than just taking the first row that fits.

A few rules worth pinning down:

  • The bare * pattern is not supported as a wildcard. A pattern like form:* works (prefix + trailing star), but * alone does nothing. To catch everything that nothing else caught, use is_catch_all: true instead.
  • At most one catch-all per workspace. This is enforced by a partial unique index in the database, plus a friendlier 400 if you try to add a second one. Archive the existing catch-all before adding a new one.
  • A catch-all fires only as a fallback. If any non-catch-all sequence matched the lead, every catch-all is dropped from consideration — even one marked allow_parallel.

When more than one matches

Within the winning specificity tier, priority (an integer, default 0) breaks ties — higher wins. By default only one sequence enrolls a given lead, to avoid a generic "new lead" drip and a specific "guide download" follow-up both firing one-shot emails at the same person.

Two settings change that:

  • allow_parallel: true — this sequence enrolls alongside any other matching sequence, regardless of what else the lead is already in. Use it for a drip that should run concurrently (e.g. a slow educational series that coexists with a fast product nudge).
  • Concurrent-drip protection — if the lead already has any in-flight (pending/active) enrollment in any sequence, a new non-parallel match is skipped. allow_parallel sequences still enroll. When this blocks an enrollment, the matcher records it on the contact timeline as concurrent_drip_blocked, listing what would have matched.

Every matcher run — match or no match — emits a nurture_match_result activity on the contact's CRM timeline, so you can always see in the UI that the matcher ran and what it decided (no_match, opted_out_at_match_time, stage_excluded, no_active_sequences, etc.). The matcher won't enroll a lead who has no email, has opted out / is marked do-not-contact, is at stage customer or disqualified, or has status unqualified.

Steps: stable id, mutable position

Each step has a permanent id (a UUID) and a position (an integer ordering it within the sequence). The split matters because everything downstream pins to the id, never the position. An enrollment tracks the step it's about to send by current_step_id; the send log dedupes on step_id. If reordering renumbered an existing step's id, in-flight leads would silently re-route onto the wrong step and the idempotency key would stop protecting them.

So the rule is: position is mutable, id is identity. A "reorder" in the dashboard is never an in-place renumber of an existing step — it's translated into soft-delete the old step + insert a new one (with a fresh id) at the target position. The new step carries no send history, so the (enrollment_id, step_id) idempotency key on nurturing.sends keeps working.

Inserting and deleting

Inserting a step at a given position bumps every step at or after that index forward by one. Deleting a step is a soft delete — it sets deleted_at rather than removing the row, so historical foreign keys from sends (which point at step_id) and from already-advanced enrollments stay intact. Live-step lookups all filter on deleted_at IS NULL.

When you delete a step, any enrollment currently waiting at it advances to the next live step on save (its next_send_at is recomputed against the new step's delay and window). If there's no next live step, those enrollments are marked completed.

A step can also be soft-deleted after an enrollment is already scheduled to send it but before the runner dispatches. In that case the send worker notices the current_step_id points at a soft-deleted step, advances the enrollment to the next live step (or marks it completed), and writes no sends row for that tick — there's nothing to send, so nothing is logged.

Editing a step in-flight

The risky edits — changing delay_seconds, changing the step's time_window, inserting, or deleting — can move when in-flight leads get their next email. Every one of these accepts a preflight flag that returns how many enrollments would be affected without saving, so you can confirm before committing.

nurturing_update_step  sequence_id=… step_id=… delay_seconds=86400 preflight=true
# → { affected_count: 7, double_send_risk_count: 0 }

double_send_risk_count is always 0 for these operations — that's the whole point of the stable-id design. When you commit (preflight off), the affected enrollments' next_send_at is re-anchored in the same save path, and the scheduler picks up the new times on its next tick.

[!TIP] Subject and body edits are always safe and need no preflight. Past sends are snapshotted into nurturing.sends at send time, so editing copy never rewrites history — it only changes what future sends look like.

Send windows

A step can carry a time_window so emails only go out at sensible local hours. A sequence can set a default_send_window that every step inherits unless it sets its own. The shape:

{ "start": "09:00", "end": "17:00", "tz": "Europe/Vienna", "weekdays": [1,2,3,4,5] }
Field Meaning
start / end HH:MM 24-hour, local to tz. end is exclusive.
tz IANA timezone. Defaults to UTC if absent.
weekdays ISO 8601 days — 1=Mon … 7=Sun. Empty or missing means every day.

When a step is due, the runner takes the raw target time (anchor + delay) and clamps it forward to the earliest moment that satisfies the window. If the raw time already lands inside the window on an allowed weekday, it's used as-is. Set end < start for an overnight window (e.g. 22:0006:00). A null window (or one where start === end) means no restriction — send 24/7. The window resolution is DST-aware and never sends earlier than the delay implies.

[!NOTE] A misconfigured window with no allowed weekday in the next 14 days won't trap leads forever — after scanning 14 days ahead the runner gives up clamping and returns the raw time, so the lead still gets the email. Windows delay sends; they never cancel them.

The enrollment state machine

An enrollment is one lead's run through one sequence. It carries the lead's position (current_step_id — the next step to send, not the last sent), the schedule (next_send_at), and a snapshot of why it matched (match_reason, source_snapshot).

States:

Status Meaning
pending Enrolled, waiting for the first step's next_send_at.
active The runner has claimed it at least once; mid-drip.
completed Last live step sent (or advanced past with nothing left). Terminal.
cancelled Stopped early — lead opted out, converted, sequence archived, or operator cancel. Terminal, carries a cancel_reason.
failed A terminal failure state in the schema. Currently not reached — see the note below.

A partial unique index allows only one pending/active enrollment per (tenant, sequence, contact) — no double-enrollment while a lead is in flight. Once an enrollment reaches a terminal state, the same lead can re-enroll on a fresh trigger (subject to the matcher's concurrent-drip rules).

[!WARNING] The failed state is defined in the schema (enrollments.attempts plus a >= 3 check in the failure handler) but is not currently wired: nothing ever increments attempts, so it's permanently 0 and the >= 3 branch never fires. A failing send records the error on last_error and leaves the enrollment active, so the scheduler retries it on every tick — indefinitely. Today, an enrollment that keeps erroring loops rather than reaching failed. This is a known gap.

How a step actually sends

Two workers drive every enrollment forward:

  1. The scheduler ticks every 30 seconds. It finds enrollments whose status is pending/active and whose next_send_at has elapsed (capped at 50 per tick), race-safely claims each one by bumping next_send_at forward by a 60-second lease and flipping pendingactive, then enqueues a nurturing.send-step job. The claim is re-guarded on status IN ('pending','active') and next_send_at <= now(), so if two ticks race the same row, only the first wins and the loser skips.

  2. The send worker handles one send-step job: loads the enrollment, contact, sequence, and current step; re-checks the bail conditions; reserves the send; calls Resend; then advances the enrollment to the next step (recomputing next_send_at) or marks it completed if nothing follows.

status and next_send_at are both load-bearing inputs to the scheduler: a cancelled/completed/failed row is invisible to it regardless of its timestamp. The reason editing a step is cheap is that it only rewrites next_send_at on the affected rows — the next tick re-scans and picks up the new time. There's no scheduled job to cancel or re-enqueue.

[!NOTE] The scheduler is an in-process 30-second poll, not a precise cron. A step due "in 3 days" actually fires on the first tick after the three days elapse (and after any send window clamps it forward). Treat delays as "no sooner than", not "exactly at".

If a send to Resend fails, the worker records the error on the sends row (state='failed') and on the enrollment's last_error, then leaves the enrollment active. The scheduler picks it up again on a later tick and retries. As noted above, there is currently no retry budget that escalates this to a terminal failed state.

Sending now (dev/test)

There's an operator shortcut to fire an enrollment's current step immediately instead of waiting for its delay: it sets next_send_at to now and enqueues the send job straight away. It only acts on pending/active enrollments (a 400 otherwise). It's a development/testing convenience — the idempotency key still protects against a double-send if a regular tick and a send-now race each other.

No double sends, ever

The guarantee that one lead never gets the same step twice rests on a single database constraint:

UNIQUE (enrollment_id, step_id)  -- on nurturing.sends

The send worker inserts the sends row with state = 'queued' before it calls Resend. If two worker runs somehow converge on the same (enrollment_id, step_id) — a crash mid-send, a retry overlapping a fresh tick, a send-now racing the scheduler — the second insert collides on the unique key and short-circuits into the idempotent-recovery path instead of calling Resend again:

  • If the existing row is already sent, the worker treats this as "already delivered", advances the enrollment, and exits.
  • If it's failed or still queued, the worker flips it back to queued (clearing the error) and proceeds with the send.

The sends row's state walks through queued → sent on success, or queued → failed on a Resend rejection. A fourth state, skipped, records a send that was deliberately not made: the only path that writes it today is the local-dev recipient allowlist (see below). On success the worker snapshots the rendered subject and HTML onto the row, and Resend webhooks later fill in opened_at / clicked_at.

[!NOTE] The schema comment on nurturing.sends says a soft-deleted step writes a skipped row. The implementation does not do this — a mid-flight soft-deleted step silently advances the enrollment with no sends row at all. The only writer of state='skipped' is the dev allowlist.

The sends table is append-only and immutable from your side — it's the audit log. nurturing_list_sends reads it per sequence; the CRM contact-detail "Nurturing" panel reads it per contact.

The local-dev recipient allowlist

NURTURE_RECIPIENT_ALLOWLIST is a comma-separated env var that gates who can actually receive a nurture email. It's a safety net for dev/staging so iterating on the feature can't accidentally email real test data. When it's set and the recipient isn't on the list, the worker writes a skipped sends row and cancels the enrollment with cancel_reason = 'recipient_not_in_allowlist'. Production leaves the env unset, so the gate is off. (This is why the cancel-reason list below includes recipient_not_in_allowlist even though it never appears in production.)

There's a second dev-only env flag: NURTURE_DEV_SKIP_SEND=true makes the worker synthesize a fake success (messageId: 'dev-skip-…') and advance the enrollment as if Resend had delivered, without any real Resend traffic. Use it to exercise the full matcher → scheduler → send → advance pipeline on a supabase start workspace with no Resend key. Never set it in production — the synthetic success is dishonest about delivery, and you'll see dev-skip-* ids in the sends log.

Cancel propagation

When a lead stops being a valid nurture target mid-flight, every in-flight (pending/active) enrollment for that contact is cancelled in a single update. Past sends are never touched — the recipient already received those emails; only future steps stop.

This cancel is triggered from several places, each stamping a cancel_reason:

Trigger Where cancel_reason
Contact patch sets email_opted_out: true CRM updateContact opted_out
Contact patch sets do_not_contact: true CRM updateContact dnc
Stage moves to customer CRM setStage stage_customer
Stage moves to disqualified CRM setStage stage_disqualified
Status moves to unqualified CRM setStatus status_unqualified
Sequence archived nurturing.sequences archive sequence_archived
Operator cancels one enrollment nurturing_cancel_enrollment manual (or your text)
Hard bounce / spam complaint (Resend webhook) newsletters webhook bounced / complained
One-click unsubscribe public unsub handler opted_out
Recipient not on dev allowlist (NURTURE_RECIPIENT_ALLOWLIST) send worker, dev only recipient_not_in_allowlist

A few things to know:

  • The cancel is best-effort and non-blocking on the CRM side — it's a side-effect of the stage/status change, not part of its contract. A nurture cancel failing never blocks the contact update itself.
  • The opt-out cancel fires whenever the contact patch payload contains email_opted_out: true (or do_not_contact: true); it doesn't read the prior value, so re-patching true when already true just re-runs the cancel (idempotent, harmless). Setting it back to false does not re-enroll — re-enrollment requires a fresh lead arrival through the matcher.
  • The send worker also re-checks the bail conditions at dispatch time, right before sending. So even if a cancel raced the scheduler, the worker catches a converted/opted-out lead at the last moment and cancels the enrollment instead of sending.
  • Not every status change cancels. Moving a lead into working, contacted, qualified, or nurture leaves the drip running on purpose — only unqualified (and stage customer/disqualified) stops it.

To stop everything for a contact from the API, patch the contact with email_opted_out: true rather than cancelling enrollments one by one — the CRM cascades the cancel across every sequence.

AI step generation

You can have the writer draft a single step's copy. nurturing_generate_step enqueues an async job (the HTTP route returns 202 with a job_id immediately) and the worker fills in the step's subject and body_markdown.

The writer reads, as context:

  • the sequence's purpose (name + description),
  • the attached campaign brief and voice notes, if the sequence has a campaign_id,
  • the workspace brand voice,
  • the prior live step's subject and an excerpt of its body (first 240 chars), so step N continues the thread rather than restarting,
  • an optional per-step briefing and a length (short ~40–100 words, medium ~120–220, long ~280–450; default medium).
nurturing_generate_step  sequence_id=… step_id=… briefing="Nudge them toward booking a demo" length="short"
# → { job: { data: { job_id: "…" } } }

Generation is per step, not per sequence — there's no "write the whole drip" call. Add the step rows first (with their delays), then generate each one. The job correlates by step_id, so the dashboard's progress indicator tracks the right step.

[!NOTE] Generation has no single lead in mind — it drafts copy for the sequence in general, using the trigger as a proxy for the audience. Per-recipient {{tokens}} (first name, etc.) are substituted later, at send time, not at generation time. After generation the step's body_html is cleared (set to null) so the send worker re-derives it from the new markdown on the next save.

One-click unsubscribe

Every nurture email carries an unsubscribe link in two forms:

  • A visible footer link the recipient can click.
  • The RFC 8058 List-Unsubscribe and List-Unsubscribe-Post: List-Unsubscribe=One-Click headers, which let mail clients (Gmail, Apple Mail, etc.) offer a native unsubscribe button that POSTs without the recipient leaving their inbox. Gmail/Yahoo bulk-sender rules expect this; the code comment cites CASL/CAN-SPAM for the footer link.

Both point at the same handler — GET for the human click, POST for the mail-client one-click — and the link is keyed by a signed token (a JWT carrying the contact id, valid 180 days). Clicking it:

  1. Flips crm.contacts.email_opted_out to true (idempotent — no-op if already opted out).
  2. Cancels every in-flight enrollment for that contact across all sequences in the workspace, with cancel_reason = 'opted_out'. Opting out of one drip opts the lead out of all of them.

Past nurturing.sends rows are immutable — the lead keeps the emails already sent. The handler is idempotent, so repeated clicks are harmless.

One subtlety worth knowing: the link is built against the sending tenant's own public host (its verified blog domain or <slug>.<platform>), because the unsubscribe page is served by the multi-tenant public app, resolved by host. It only falls back to the global platform URL when the tenant has neither a verified domain nor a slug.

Reading what happened

To see… Use
All sequences, filtered by status/campaign nurturing_list_sequences
One sequence + its live steps + active enrollment count nurturing_get_sequence
Who's enrolled in a sequence and at what stage nurturing_list_enrollments
Every send a sequence produced (with open/click) nurturing_list_sends
Everything nurturing knows about one contact nurturing_get_contact_nurturing

The enrollments and sends tables are published over Supabase Realtime, so the CRM contact-detail "Nurturing" panel updates live as steps send and the enrollment advances — no reload.

What's basic for now

A few honest edges:

  • The failed state is not wired. attempts is never incremented, so a send-gate block (e.g. an unverified custom sender domain) or a repeatedly-failing Resend call leaves the enrollment retrying on every tick rather than reaching a terminal failed state. A gate-blocked enrollment in production neither sends nor terminates — it loops.
  • Open/click tracking depends on Resend webhooks landing; the timestamps populate asynchronously and aren't instant.
  • There's no per-cancel activity row on the timeline — a cancel flips the enrollment status but, unlike a match or a send, doesn't write its own crm.contact_activities entry (to keep the timeline quiet). The cancel_reason on the enrollment is the record.
  • Step body rendering is basic markdown — paragraphs, bold, line breaks, and {{token}} substitution. It's not the full blog post renderer.
  • Generation is one step at a time; there's no whole-sequence draft yet.

FAQ

Can the same lead go through a sequence more than once? Yes, but not concurrently. The unique index blocks a second pending/active enrollment in the same sequence while one is in flight. After it reaches completed/cancelled, a fresh lead arrival can re-trigger it — subject to the matcher's concurrent-drip rules.

If I edit a step's delay while leads are mid-drip, does anyone get an email twice? No. Editing delay or window only re-anchors next_send_at. The stable step_id plus the (enrollment_id, step_id) idempotency key guarantee double_send_risk_count: 0. Run the edit with preflight: true first to see how many leads shift.

What happens to a lead waiting on a step I delete? It advances to the next live step on save, with next_send_at recomputed from that step's delay and window. If there's no next step, the enrollment is marked completed. The deleted step's row stays (soft delete), so history is preserved.

Does pausing a sequence cancel in-flight leads? No. In-flight enrollments keep their active status; the scheduler still picks them up each tick, but the send worker sees the paused sequence and pushes next_send_at out by an hour instead of sending. Set the sequence back to active to let the next due send go through. Archiving is what cancels in-flight enrollments (reason sequence_archived).

A lead unsubscribed from one sequence — are they still in the others? No. Unsubscribe is contact-level: it sets email_opted_out and cancels every in-flight enrollment across all sequences in the workspace at once.

Why didn't my sequence catch a lead it should have? Check the contact's nurture_match_result activity. Common reasons: the sequence is still draft/paused (only active matches), a more-specific sequence won, the lead was already in flight elsewhere (concurrent_drip_blocked), or the lead was opted out / past the funnel at match time.

Served live from the platform · /docs/nurturing-sequences