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). Leavefrom_emailnull 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 likeform:*works (prefix + trailing star), but*alone does nothing. To catch everything that nothing else caught, useis_catch_all: trueinstead. - 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_parallelsequences still enroll. When this blocks an enrollment, the matcher records it on the contact timeline asconcurrent_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.sendsat 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:00–06: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
failedstate is defined in the schema (enrollments.attemptsplus a>= 3check in the failure handler) but is not currently wired: nothing ever incrementsattempts, so it's permanently0and the>= 3branch never fires. A failing send records the error onlast_errorand leaves the enrollmentactive, so the scheduler retries it on every tick — indefinitely. Today, an enrollment that keeps erroring loops rather than reachingfailed. This is a known gap.
How a step actually sends
Two workers drive every enrollment forward:
The scheduler ticks every 30 seconds. It finds enrollments whose
statusispending/activeand whosenext_send_athas elapsed (capped at 50 per tick), race-safely claims each one by bumpingnext_send_atforward by a 60-second lease and flippingpending→active, then enqueues anurturing.send-stepjob. The claim is re-guarded onstatus IN ('pending','active')andnext_send_at <= now(), so if two ticks race the same row, only the first wins and the loser skips.The send worker handles one
send-stepjob: 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 (recomputingnext_send_at) or marks itcompletedif 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
failedor stillqueued, the worker flips it back toqueued(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.sendssays a soft-deleted step writes askippedrow. The implementation does not do this — a mid-flight soft-deleted step silently advances the enrollment with nosendsrow at all. The only writer ofstate='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(ordo_not_contact: true); it doesn't read the prior value, so re-patchingtruewhen already true just re-runs the cancel (idempotent, harmless). Setting it back tofalsedoes 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, ornurtureleaves the drip running on purpose — onlyunqualified(and stagecustomer/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
briefingand alength(short~40–100 words,medium~120–220,long~280–450; defaultmedium).
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'sbody_htmlis 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-UnsubscribeandList-Unsubscribe-Post: List-Unsubscribe=One-Clickheaders, 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:
- Flips
crm.contacts.email_opted_outtotrue(idempotent — no-op if already opted out). - 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
failedstate is not wired.attemptsis 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 terminalfailedstate. 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_activitiesentry (to keep the timeline quiet). Thecancel_reasonon 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.