SMS & IVR
SMS and IVR share a single turn-by-turn conversation engine. Outbound origination goes through the Twilio adapter (packages/adapters/src/twilio.ts); inbound turns arrive on the public Twilio webhooks (api/src/routes/twilio.ts) and are driven by ChannelConversationService (api/src/domains/channel-conversation.ts).
The Twilio adapter
makeTwilioSms(cfg) and makeTwilioVoice(cfg) call the Twilio REST API with HTTP basic auth (AccountSid:AuthToken), no SDK. send() posts a message; call() originates a call whose Url points at our voice webhook. Calls have a bounded abort signal. A definitive non-timeout 4xx response is returned as a rejection; a timeout, network error or 5xx remains ambiguous and throws so the durable caller never assumes that Twilio did not accept it. Provider bodies are not logged because they may echo recipient or message data. Twilio status vocabulary is mapped onto narrow result statuses (queued/sent/rejected, initiated/queued/failed). When credentials are absent, the composition root wires a stub that refuses to send.
Outbound dispatch
In DispatchService.invite():
sms— if the survey has SMS-answerable questions,startConversation(questions, 'sms')produces the first prompt; the service opens a channel session (channelSessions.start(surveyId, 'sms', hash)) and sends that prompt. If there are no SMS-answerable questions, it mints a short URL to the web runtime and texts the link instead.ivr_outbound— requires a public base URL; originates a call viaports.voice.call(...)whosetwimlUrlpoints at<base>/public/twilio/voice/<surveyId>. When answered, Twilio fetches that webhook and the turn-by-turn TwiML runs.
Suppression, consent, send-window and frequency rules run before provider work. The frequency contact slot is reserved atomically under a tenant/contact advisory lock, so concurrent invitations cannot both pass the cap.
For SMS and outbound IVR, the database then reserves an immutable response.attempt intent before Twilio is called. The intent carries an opaque UUID, a tenant-local idempotency key, a request fingerprint and the one-way recipient hash. A compare-and-set transition grants exactly one caller the provider fence; exact replays return the recorded accepted/rejected/uncertain outcome without sending again. Twilio receives the opaque intent UUID in its status-callback URL, never the tenant or recipient.
The synchronous provider response and the signed status callback converge on the same state machine. A Twilio SID is globally unique and first-write immutable; an intent/SID or channel/SID mismatch is rejected. Acceptance transactionally creates one PII-free tenant.survey_delivery fact, which stages any survey.sent notification work in the durable Postgres outbox. Queue publication is a latency wake only; the 15-minute notification sweep repairs missed wakes and any delivery fact deliberately deferred while a subject erasure was active.
Subject erasure and retention retain only a SHA-256 request-key tombstone. This also covers deferred-only tenant.scheduled_invite rows that have not created an attempt yet: scrubbing or deleting one records a hash-only tombstone, and a later attempt deletion upgrades it with the callback UUID. An erased request cannot recreate a raw recipient or reuse that UUID.
The conversation engine
The engine is pure (startConversation / nextTurn from @voc/core); ChannelConversationService wraps it with persistence. The key idea: the webhook has no auth context, so the owning tenant is resolved from the open session row (or, for a cold start, from the number→survey mapping) — mirroring how respondent capture resolves the tenant from the survey.
applyTurn advances one reply: it loads the survey, computes the next turn, then either updates the session or — on the final turn — completes the session and captures a full response with an idempotency key derived from the session id:
const turn = nextTurn(detail.questions, channel, { step: open.step, answers: open.answers }, input);
await ports.db.forTenant(ctx).transaction(async (r) => {
if (turn.done) {
await r.channelSessions.complete(open.sessionId);
await r.responses.capture({
surveyId: open.surveyId,
channel,
answers: turn.state.answers,
idempotencyKey: `chan:${open.sessionId}`,
});
} else {
await r.channelSessions.update(open.sessionId, turn.state.step, turn.state.answers);
}
});
Inbound SMS
POST /public/twilio/sms verifies the signature, then calls handleInbound('sms', from, body, to):
- If an open session exists for the hashed sender address, the reply advances it.
- Otherwise a cold start: the destination number (
to) is looked up viasurveyForInboundNumber(toAddress, channel); if mapped to a survey, the conversation starts and the first prompt is returned. Unknown numbers are ignored.
The reply is wrapped in TwiML (<Message>…</Message>) and returned with text/xml.
Inbound IVR
POST /public/twilio/voice/:surveyId verifies the signature, then keys the session by the Twilio CallSid:
- No open session →
startVoice(surveyId, callSid)resolves the survey's tenant, opens a session, and speaks the first prompt. - Open session →
advanceVoice(callSid, input)advances usingDigitsorSpeechResult.
Each turn is rendered as TwiML: a <Gather input="speech dtmf"> that speaks the prompt and posts the reply back to the same action path, or a closing <Say>…</Say><Hangup/> on the final turn.
Outbound status callbacks
POST /public/twilio/sms/status?intent=<uuid> and POST /public/twilio/voice/status?intent=<uuid> receive delivery/lifecycle receipts. Both verify Twilio's signature against the full callback URL, including the intent query. The owner lookup returns only the tenant capability for a live opaque intent; the mutation then re-enters that tenant through forTenant. Erased intents are an idempotent no-op, while a provider-SID or channel collision returns 409.
Inbound number → survey mapping
The cold-start mapping is managed under /v1 (api/src/routes/inbound-numbers.ts). Managing it requires surveys.distribute.
| Method | Path | Permission | Body |
|---|---|---|---|
GET | /v1/inbound-numbers | tenant member | — |
POST | /v1/inbound-numbers | surveys.distribute | { phone, surveyPublicId, channel: 'sms'|'ivr' } |
DELETE | /v1/inbound-numbers/:id | surveys.distribute | — |
The mapping carries the channel match so an inbound SMS resolves only against sms mappings and an inbound call against ivr.
Agent PIN
ChannelConversationService resolves agents by PIN in the ADEPT agent-initiated flow (api/src/domains/adept.ts): an agent identifies by PIN (CTI/phone) or public id, and for the ivr_inbound mode the service returns the mapped inbound number plus the agent PIN to quote. PIN resolution is agents.findByPin(pin); the response is attributed to that agent.