HCC Docs

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 becauseThe lead
do_not_callLeaves as do_not_call
invalid_numberLeaves as removed
opted_out, only Texts revokedGoes to the next step, as calls may still be yours
billing_*, usage_limit_reached, plan_has_no_seats, test_cap_reachedWaits, and the campaign pauses
number_not_registered, messaging_unavailable, caller_id_not_owned, no_caller_idParks as step_failed, with the reason as lastOutcome
rate_limitedComes 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 inWhat happens
STOP, or a carrier blockLeaves every campaign as do_not_call, paused ones too. None of your code runs, and none can change it.
START, HELPYour goals and exits hear it, as lead.text_received. Nothing else does.
Anything elseGoals 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.