Skip to main content

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

PortAdapterFile
EmailProviderResend (official SDK)packages/adapters/src/resend.ts
SmsProviderTwilio (REST)packages/adapters/src/twilio.ts
VoiceProviderTwilio (REST) — outbound IVR; stub when unconfiguredpackages/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

Capabilityemailsmsivr_outboundivr_inboundweb
Outbound dispatch (invite)yesyesyesn/a (respondent-initiated)link only
Turn-by-turn conversation engineyesyesyes— (page runtime)
Templates ({{link}} / {{surveyUrl}})yes
Open/click trackingyes
Short-URL wrappingyes (link fallback)yes
Agent attribution (?agent=)yesvia linkvia PINyes
Custom data (cd.<key>) on the linkyesvia linkyes
Bounce → suppressyes

Cross-cutting send rules

Every outbound invite passes through the same compliance gate before any provider is called:

  1. Phone canonicalisationsms/ivr_outbound recipients are normalised to E.164 so suppression, the frequency cap, and the send all key on the same value. Malformed numbers raise 400 invalid_request.
  2. Geo gating — if the workspace restricts dial codes (allowedDialCodes), out-of-region numbers are suppressed.
  3. Suppression — a hashed contact on the opt-out/suppression lists, or matched by a pattern rule, returns { status: 'suppressed' } and is never messaged.
  4. 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.