Channels overview
A survey can be distributed over several channels. Each channel is reached through a provider port — an interface in @voc/ports with a swappable adapter — so the domain logic never touches a vendor SDK directly. The orchestration that decides what to send lives in DispatchService (api/src/domains/dispatch.ts); the adapters decide how to send it.
Ports and adapters
| Port | Adapter | File |
|---|---|---|
EmailProvider | Resend (official SDK) | packages/adapters/src/resend.ts |
SmsProvider | Twilio (REST) | packages/adapters/src/twilio.ts |
VoiceProvider | Twilio (REST) — outbound IVR; stub when unconfigured | packages/adapters/src/twilio.ts |
Adapters are wired by the composition root only when their credentials exist; otherwise a stub refuses to send (e.g. makeStubVoice throws). Short URLs are not a provider port — they are a tenant database service (api/src/domains/short-url.ts) resolved at /r/:slug.
The Channel type
DispatchService.invite() accepts a Channel. The relevant values:
email— Resend, with optional template, open pixel, and click-wrapped tracking.sms— Twilio SMS; turn-by-turn conversation when the survey has SMS-answerable questions, else a (shortened) link.ivr_outbound— Twilio originates a call that fetches the voice webhook (turn-by-turn TwiML).ivr_inbound— the respondent calls a mapped number; the inbound webhook starts the conversation.web— the public survey runtime at/s/:surveyId(often reached via a short link or badge).
Capability matrix
| Capability | sms | ivr_outbound | ivr_inbound | web | |
|---|---|---|---|---|---|
Outbound dispatch (invite) | yes | yes | yes | n/a (respondent-initiated) | link only |
| Turn-by-turn conversation engine | — | yes | yes | yes | — (page runtime) |
Templates ({{link}} / {{surveyUrl}}) | yes | — | — | — | — |
| Open/click tracking | yes | — | — | — | — |
| Short-URL wrapping | — | yes (link fallback) | — | — | yes |
Agent attribution (?agent=) | yes | via link | — | via PIN | yes |
Custom data (cd.<key>) on the link | yes | via link | — | — | yes |
| Bounce → suppress | yes | — | — | — | — |
Cross-cutting send rules
Every outbound invite passes through the same compliance gate before any provider is called:
- Phone canonicalisation —
sms/ivr_outboundrecipients are normalised to E.164 so suppression, the frequency cap, and the send all key on the same value. Malformed numbers raise400 invalid_request. - Geo gating — if the workspace restricts dial codes (
allowedDialCodes), out-of-region numbers are suppressed. - Suppression — a hashed contact on the opt-out/suppression lists, or matched by a pattern rule, returns
{ status: 'suppressed' }and is never messaged. - Frequency cap — a re-invite inside the workspace's re-contact window (any channel) is suppressed.
These run in DispatchService.invite() regardless of channel. See Email, SMS & IVR, and Short URLs for the per-channel detail.