Texts in a campaign
.text is a step. Your handler writes the words, as there's no default copy, and we send them once per lead per run. Everything in Texts holds here: what you may send, to whom and when is yours to decide. There's no quiet-hours window, consent check or frequency cap on your behalf. Read lead.consent and the clock in your handler, and return c.skip() when you shouldn't send.
export const renewals = defineCampaign('renewals')
.text((lead, c) => c.send(`Hi ${lead.name}, your plan renews Friday.`), { name: 'heads-up' })
.wait('2d')
.call(sales)
.onReply((text, lead, c) => {
if (text.possibleOptOut) return c.optOut();
return c.assign(lead.fields.ownerRep);
});{ from } on the step or on c.send picks the number, which must be yours. With neither, your default caller ID sends it.
How a Text step ends
The lead moves on once the carrier has answered, even if that answer is a failure. lastOutcome says which: text_sent, text_failed, text_unknown or skipped. A delivery receipt arriving later doesn't move anyone.
A send that may have reached the carrier is never sent again, including after a crash: that lead moves on as text_unknown. A lead that started over gets a new Text.
| Refused because | The lead |
|---|---|
do_not_call | Leaves as do_not_call |
invalid_number | Leaves as removed |
opted_out, only Texts revoked | Goes to the next step, as calls may still be yours |
billing_*, usage_limit_reached, plan_has_no_seats, test_cap_reached | Waits, and the campaign pauses |
number_not_registered, messaging_unavailable, caller_id_not_owned, no_caller_id | Parks as step_failed, with the reason as lastOutcome |
rate_limited | Comes back in 5 minutes |
A handler that keeps failing parks the lead as step_failed, with hook_failed as its lastOutcome, and no Text goes.
What a reply does
A campaign hears a Text only if it has a .text step or an .onReply. A Text reaches every campaign the number is active in, whichever of your numbers it came to, and whether or not this lead has been texted yet.
| What came in | What happens |
|---|---|
STOP, or a carrier block | Leaves every campaign as do_not_call, paused ones too. None of your code runs, and none can change it. |
START, HELP | Your goals and exits hear it, as lead.text_received. Nothing else does. |
| Anything else | Goals and exits first. If neither ends it, .onReply decides. With no .onReply, the lead leaves as exit if the campaign texts. |
.onReply returns one of c.continue(), c.exit(), c.assign(repId) or c.optOut(). With assign, the Rep owns the Thread and the lead leaves as exit. A Rep we don't know or who is disabled doesn't get it, and the lead still leaves. optOut revokes every scope and adds the number to Do Not Call, as a keyword does, and the lead leaves as do_not_call. "Please stop texting me" isn't a keyword: it reaches you with text.possibleOptOut, and what happens next is your code's to say.
A lead can answer during a call. It leaves the sequence, the call goes on, and the Rep's disposition still counts: a .goal can still match, and a callback time brings it back.
If .onReply keeps failing, the default stands. While a campaign is paused, none of your code runs for a reply, and the default stands.