Texting contractors: the consent and revocation records an agent needs

Redeployment outreach ends up in a text message. A consultant finishing on the 26th answers a text about the next assignment in an hour and an email in four days, so any agent that drafts redeployment or timesheet-chase outreach eventually gets pointed at SMS. That is where a back-office agent stops being an internal tool and starts touching a body of law with per-message statutory damages attached.

The Telephone Consumer Protection Act is the one piece of messaging regulation a staffing firm cannot delegate to a vendor. The FCC's revocation rules, adopted in Strengthening the Ability of Consumers To Stop Robocalls and codified at 47 CFR 64.1200, are short and specific: a recipient may revoke consent in any reasonable manner, revocation must be honored within a fixed number of business days, and a confirmation message may go back once. Read the current rule text with your counsel before you wire anything; what follows is the data model that lets you answer questions about it, not legal advice, and no schema makes a tool "compliant".

This tutorial builds the two tables and one gate that sit between a drafting agent and a messaging provider.

Rule one: consent is an event log, not a checkbox

Most ATSs give you a boolean somewhere on the contact record. A boolean cannot tell you who granted consent, when, through what wording, or whether it was later withdrawn and re-granted. Store events instead, append-only.

create table consent_events (
  event_id      bigserial primary key,
  person_id     bigint not null,          -- your identity table, not the ATS id
  phone_e164    text   not null,
  event_type    text   not null check (event_type in ('granted','revoked')),
  channel       text   not null,          -- 'application_form','sms_keyword','call','email','portal'
  occurred_at   timestamptz not null,
  captured_by   text   not null,          -- named human, or 'agent:sms-inbound'
  evidence_ref  text,                     -- form submission id, recording id, message sid
  disclosure_text_version text,           -- the exact wording shown at grant time
  raw_payload   jsonb not null,
  created_at    timestamptz not null default now()
);
create index on consent_events (person_id, phone_e164, occurred_at desc);

Two details carry the weight. disclosure_text_version means you can reproduce what the contractor actually agreed to eighteen months ago, which is the question you get asked in a demand letter. phone_e164 means consent attaches to the number, not the person: contractors change numbers, and numbers get reassigned to strangers.

Current state is a view, not a column:

create view consent_current as
select distinct on (person_id, phone_e164)
       person_id, phone_e164, event_type as state, occurred_at, evidence_ref
from consent_events
order by person_id, phone_e164, occurred_at desc, event_id desc;

Rule two: revocation is free-text, so the agent must read it

Keyword lists (STOP, UNSUBSCRIBE, QUIT) are the floor, not the requirement. Real replies look like "pls stop texting me", "wrong number", "I'm off the market, take me off your list". A classifier is genuinely useful here, and this is one of the few places where erring toward over-detection costs you nothing but a lost outreach.

def handle_inbound(msg):
    if matches_standard_keyword(msg.body):          # deterministic, runs first
        record_revocation(msg, captured_by="agent:sms-inbound", basis="keyword")
        return send_one_confirmation(msg.from_e164)

    verdict = classify_opt_out(msg.body)            # {"revokes": bool, "confidence": float}
    if verdict["revokes"]:
        record_revocation(msg, captured_by="agent:sms-inbound",
                          basis=f"model:{MODEL_VERSION}")
        queue_for_human_review(msg, verdict)        # confirm, do not gate the suppression
        return send_one_confirmation(msg.from_e164)

    return route_to_recruiter(msg)

Suppress first, review second. The human review queue exists to catch false negatives and to give a recruiter the context ("this contractor opted out of texts but wants calls"), not to hold up the suppression. And send exactly one confirmation: a second "are you sure?" is a message to a number that just told you to stop.

Log the model version on every model-basis revocation. When you change classifiers, you want to know which decisions came from which one, the same way a rubric screener logs its rationale.

Rule three: one gate, no side doors

Every outbound message goes through one function. Not one per workflow, one for the firm.

def may_send(person_id, phone_e164, now):
    c = consent_current(person_id, phone_e164)
    if c is None or c.state != "granted":
        return deny("no_consent")
    if suppressed(phone_e164):                      # carrier complaint, litigator list, bad number
        return deny("suppressed")
    local = to_local_time(now, area_code_timezone(phone_e164), person_timezone(person_id))
    if not (QUIET_START <= local.time() <= QUIET_END):
        return deny("quiet_hours")
    if messages_sent_today(phone_e164) >= DAILY_CAP:
        return deny("frequency_cap")
    return allow()

Every call, allowed or denied, writes a row:

create table send_attempts (
  attempt_id    bigserial primary key,
  person_id     bigint not null,
  phone_e164    text not null,
  workflow      text not null,            -- 'redeployment','timesheet_chase','credential_expiry'
  decision      text not null,            -- 'allow' or the deny reason
  consent_event_id bigint references consent_events(event_id),
  approved_by   text,                      -- the recruiter who released the draft
  message_body  text,
  attempted_at  timestamptz not null default now()
);

consent_event_id is the point of the whole exercise. For any message you sent, you can name the specific consent event it relied on, the human who released it, and the wording shown when consent was given. That is a five-minute answer to a complaint instead of a two-week reconstruction.

Quiet hours deserve a note: derive the timezone from the contractor's record where you have it and fall back to area code, because a contractor with a New York number working a Phoenix assignment will get texted at the wrong hour otherwise.

What to check weekly

Three queries, on the same weekly report as your redeployment numbers:

  • Sends to revoked numbers. Should be zero. Any row means something bypassed the gate.
  • Time from revocation event to last send. Your margin against the honor-within-N-business-days rule. Watch the tail, not the average.
  • Deny reasons by workflow. A chase workflow denied 40% of the time on no_consent is telling you your intake form never asked.

Where we say not yet

Two places. First, autonomous sending: the agent drafts, a named recruiter releases, and the release is the row in approved_by. An approval queue costs seconds per message and is the difference between a mistake and a pattern.

Second, backfilling consent. Firms ask whether an agent can infer consent from a history of two-way texting on the contact record. Sometimes that is a reasonable read and sometimes it is a manufactured record; either way it is a question for the firm's counsel and not something to bury in a migration script. Start the log from today, sending only to numbers with an event you can point to, and let coverage grow from intake.

If you are scoping SMS outreach on Loxo, Crelate, JobAdder, Vincere or Recruiterflow and want the consent tables mapped against what your ATS and messaging provider already store, tell us your stack. We reply within one business day.