AnyBio

Messaging and consent

SMS opt-in and decline, the consent ledger, STOP and START handling, the per-patient message budget, the identity disclosure, and what a program must have before it can text anyone.

Nothing patient-facing is sent unless four things are true: a sender is configured for the environment, an active communication policy allowed the content, the address has consent open, and the patient's message allowance for the window has room. Each of those fails closed on its own, and each is recorded. This page covers consent and the budget; the gate covers content.

The sender

An SMS sender is configured per organization and environment under Settings, Notifications, or with POST /api/v1/notification-provider-configs (channel: "sms", provider, enabled, config, credentials), read with GET, changed with PATCH /{config_id} and removed with DELETE /{config_id}. There is no platform fallback: an environment with no enabled sender cannot send, and the readiness ladder reports it as the missing rung. Your provider account must point its inbound-message webhook at the URL the dashboard shows for your project (shape /webhooks/<sms-provider>/sms/{project_key}); that is how STOP reaches AnyBio.

Consent is recorded per channel and address (a phone number), not per person, and its only sendable state is opted_in. The ledger has two parts: a gate row per address with its current status (opted_in or opted_out) and any outstanding invitation, and an append-only evidence trail of what was asked, what was shown, and what the patient replied.

Two rules are enforced by the types, not by convention:

  • Nothing an organization does can grant consent. A form, an operator, or an API caller can only create a pending invitation. The grant is the patient's reply from the handset, verified against the carrier's signature.
  • Only a patient reply can reopen a closed door. Once an address is opted_out, the only path back to opted_in is the patient texting START from that number. There is no override.

Asking: POST /api/v1/public/sms-optin

The public, unauthenticated endpoint an enrollment page posts to. Body: program_handle (the program's public handle, the one its QR code encodes), phone (any format that normalizes to E.164), captcha_token (from the widget on the enrollment page; required in practice).

It creates a pending invitation and sends the patient a message that carries the identity disclosure and asks them to reply START. It never grants. The invitation expires after 24 hours.

The response is deliberately uniform so the endpoint cannot be used to learn about a phone number: 400 for a string that is not a phone number or a destination the form does not text, 401 for a token no captcha issued, and otherwise 202 with the same body every time:

{ "status": "accepted" }

A number that is already opted in, already opted out, never seen, over its rate limit, or attached to no program all receive that same 202.

Re-asking: POST /api/v1/communication_consent/sms/resend

Write scope. Body: { "address": "+15550100" }. Re-sends the invitation to an address the organization already asked, on the same per-handset budget as the public form, so a re-send is never a way around the cap. Returns invitation_id and correlation_id; it is not a delivery report.

The patient's reply

Inbound messages arrive at the per-project webhook, are verified against your provider account's signing token, and are classified by keyword:

ReplyEffect
STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, QUITConsent for the address is revoked and any outstanding invitation withdrawn. A STOP with nothing to revoke is recorded as evidence and changes no state.
START, UNSTOPConfirms an outstanding invitation, granting consent under the disclosure version that was shown. With no invitation outstanding, restores a consent this address previously gave. Never manufactures consent for an address that was shown nothing.
HELP, INFOAn informational reply; no state change.
anything else, YES includedNo consent change. The invitation asks for START, and only START confirms it.

A message that is not a keyword is persisted durably, deduplicated in the database, attributed to the patient by the address the carrier signed, and handed to the symptom interview if one is open for that patient. Nothing is dropped between the webhook and the record, because the provider does not redeliver a message it has already been told was received.

The inbound webhook is rate-limited on its own bucket and that limiter fails open when its store is unavailable: a missed STOP is worse than an extra request.

The identity disclosure

The consent invitation carries the AI-identity disclosure and is stamped with a fixed version identifier (sms-v1.2 today), so every consent record names the exact wording the patient was shown. The composer refuses to produce an invitation when a party the document names (the organization, the program) has no usable name; the honest surface for that is a page that offers no form, not a message with a blank in it.

Each program with an agent also carries its own disclosure in spec.disclosure (identity, human_contact); the symptom interview's opening message includes it, and an adherence nudge establishes it once per episode before saying anything else. GET /api/v1/anybio/programs/{handle}/disclosure returns a program's disclosure for a public enrollment page to render.

The per-patient message budget

Automated care SMS is bounded by the environment's patient messaging policy: max_messages per window_seconds, set by an organization admin with PUT /api/v1/patient-messaging-policy (see care hand-offs). Every automated send reserves a slot before it reaches the wire:

  • The policy counts logical messages, not carrier segments.
  • The patient's sends across all their addresses, and the address's sends across all patient records, must both fit the same allowance.
  • A reservation is committed before transport. An accepted or ambiguous send stays charged; only an established non-delivery releases the slot. A stale job cannot send a second copy after the window rolls over.
  • Consent invitations and the replies to HELP are service messages with their own controls and do not draw on this budget.

When the allowance is spent, or when no policy is configured, the send is not made and a care hand-off is opened for the patient with reason message_budget_exhausted or message_policy_missing, so a person owns the conversation from there. There is no numeric default; the numbers are agreed with the receiving team and written into the policy.

What a program needs before it can text

GET /api/v1/episode-profiles/{id}/symptom-readiness answers the program-scoped half of this list; the patient-scoped half is listed but not answered:

RungScope
A live project in the environmentprogram
A valid symptom protocol on the program, disclosure includedprogram
A rule that routes the reading to a symptom sessionprogram
An enabled SMS sender for the organization and environmentprogram
An active communication policyprogram
The patient exists and has an active episode that accepts the signalpatient
A phone number on file (PUT /api/v1/xusers/{xuser_id}/phone)patient
SMS consent open for that numberpatient
No symptom session already open for the patientpatient

What is recorded

Every step above leaves a row a reviewer can read without asking anyone: the invitation and its disclosure version, each inbound message with its classification, each consent grant and revocation with the address and the reply that caused it, each reservation against the budget and its settlement, and, for anything the gate touched, the decision record with the original and any rewritten content. Consent evidence is what the billing ledger reads to report consent_recorded_at per enrollment; see billing readiness.

On this page