Twilio Connector (experimental)
Send SMS / MMS and configure Twilio messaging from a Caffeine canister.
⚠️ Experimental (twilio-client@0.1.2) — no call has ever been made from this
client. Its write path could work at all only recently: before, every write
discarded its arguments and posted an empty body. The wire format now matches
what Twilio documents (form-encoded body, percent-encoded values, optional
fields omitted) and all 118 files typecheck, but structurally correct is not
verified. Treat the first successful send as the acceptance
test, and do not present Twilio to a user as a fully supported platform feature
until one has happened. Sends cost money, so a failed experiment is not free.
Scope — the package is the messaging surface only. twilio-client is
pruned to 35 API modules (all of Messaging v1 plus the v2010 messaging path:
Account, Message, Media, IncomingPhoneNumber and its variants,
AvailablePhoneNumber, the A2P registries). Voice/calls, recordings, conferences,
queues, applications, SIP and usage records are not in the package — if a
build needs those, they are outside this connector. (Counts: 35 API modules, 82
models, 118 files, all typechecking.)
Orchestrator routing notes
Load this skill when the user, spec, or a prior task mentions sending a text
message, SMS/MMS, notifying someone by phone, buying or listing phone numbers, or
any Twilio messaging concept. Raw ic.http_request to *.twilio.com is an
anti-pattern that re-implements auth, host routing, percent-encoding and JSON
parsing by hand — and, done naively, sends every message ~13 times.
Intent → capability mapping:
| User intent |
Capability |
| Send an SMS |
Api20100401MessageApi.createMessage with from = a Twilio number |
| Send an MMS (image) |
same, with mediaUrl = ["https://…"] and sendAsMms = true |
| Send via a Messaging Service (recommended for US traffic) |
same, from = "" + messagingServiceSid |
| Check delivery status |
fetchMessage (status, error_code) |
| List / search sent messages |
listMessage (paginated) |
| Own or browse phone numbers |
Api20100401IncomingPhoneNumberApi, …AvailablePhoneNumberCountryApi |
| Set up a Messaging Service |
MessagingV1ServiceApi.createService |
| Register for US A2P 10DLC |
MessagingV1BrandRegistrationApi → MessagingV1UsAppToPersonApi → MessagingV1PhoneNumberApi (in that order — see US A2P 10DLC) |
| Verify a toll-free number |
MessagingV1TollfreeVerificationApi |
Twilio credentials are something a human must go and fetch from a console, so
the build is not done when the backend compiles — it is done when the app tells
the admin where to get the credential and gives them somewhere to paste it. See
Auth model, then Frontend for the page that MUST ship, and repeat the steps in
the completion message.
Ask before writing code: which number sends? A US-bound production app needs a
Messaging Service + A2P registration (weeks of lead time, real fees); a
demo/internal app can send from a single trial number to verified recipients
only. Report the choice and its consequences back to the prompting user.
Auth model — HTTP Basic, two flavours
Both flavours are the same #basicAuth { user; password } credential and the
client treats them identically; they differ in blast radius.
| Flavour |
user / password |
When |
| API Key (default — prefer this) |
API Key SID (SK…) / its Secret |
Production. Revocable and scoped: leaking one does not surrender the account. |
| Account SID + Auth Token |
Account SID (AC…) / Auth Token |
Dev only. The Auth Token is the account — it can create sub-accounts, buy numbers, and spend money. |
The Account SID (AC…) is also a required positional argument to every
v2010 operation (it is in the URL path), regardless of which flavour is used. So
an app using an API Key stores three values: Account SID, Key SID, Key Secret.
Obtaining credentials
- Sign in at https://console.twilio.com.
- The Account SID (
AC…) is on the console dashboard — copy it.
- For production, Account → API keys & tokens → Create API key (Standard);
copy the SID (
SK…) and the Secret. The Secret is shown once — if
the admin navigates away it cannot be recovered, only replaced.
For dev only, take the Auth Token from the dashboard instead.
- Buy a sending number: Phone Numbers → Manage → Buy a number, with the
SMS capability ticked (not every number has it).
- On a trial account: verify each recipient under Phone Numbers → Verified
Caller IDs, or sends fail with
21608; trial messages also carry a
"Sent from your Twilio trial account" prefix.
Handing the credentials to the canister
The admin pastes them through an admin-gated setter — gated on
AccessControl.hasPermission(state, caller, #admin). They are held by the
canister only and never returned to the frontend.
⚠️ Never gate the setter on a first-caller-claims-ownership scheme. On the
IC every unauthenticated caller is the same anonymous principal, so if an
anonymous call claims ownership first, every anonymous caller passes the
caller == owner check and can overwrite the credential — and this one spends
money.
The canister hands them to the client only through
config.auth = ?#basicAuth { user; password }, which every method turns into an
Authorization: Basic … header. No method takes a credential argument and none
puts it in the URL, so it cannot leak through a logged query string.
Outcalls are already non-replicated — and this CORRECTS earlier guidance
defaultConfig ships is_replicated = ?false, so anything derived from it by
record update is correct as-is. Nothing to remember, nothing to add.
⚠️ Do not set it to ?true or null, and disregard any older advice to do
so. An older version of this SKILL claimed writes should stay
replicated "so IC consensus dedups retries". That is false and expensive. A
replicated outcall is performed by every node in the subnet: the request is
sent 13 times, so **13 SMS are sent and ~13 are billed**, the credential
leaves every node, and consensus fails anyway because Twilio stamps each reply
with a unique sid (so the responses never agree byte-for-byte). This is the
same defect that produced ~13 duplicate emails via the Gmail connector and drove
slack-client 0.1.0.
Reads (fetch* / list*) are equally fine non-replicated: one node's view of a
message log is what you want, and it is the cheaper path.
Backend
Add dependencies
The admin gate in the recipe below needs the authorization component alongside the
client:
mops add twilio-client@0.1.2
mops add caffeineai-authorization@1.0.1
Calling shape — free functions or the class facade
Every module offers both. The free function takes config first and is async*;
the module class captures config and is async:
// Illustrative sketch, not a file to copy: `cfg`/`accountSid` are assumed to
// exist and the argument lists are elided. Marked motoko-check:skip for that
// reason — the compiled examples are the three mixins below.
import MessageApi "mo:twilio-client/Apis/Api20100401MessageApi";
// free function — config passed explicitly
let m = await* MessageApi.createMessage(cfg, accountSid, /* … */);
// class facade — config captured once
let messages = MessageApi.Api20100401MessageApi(cfg);
let m2 = await messages.createMessage(accountSid, /* … */);
All parameters are positional and there are 27 of them on createMessage.
Pass "" / false / 0 / 0.0 / [] / null for the ones you do not
use — the optional enum parameters are ?T precisely so that null omits them
from the wire. Count carefully; a misplaced empty string silently sends the
wrong field. The order is:
config, accountSid, to, statusCallback, applicationSid, maxPrice, provideFeedback, attempt, validityPeriod, forceDelivery, contentRetention, addressRetention, smartEncoded, persistentAction, trafficType, shortenUrls, scheduleType, sendAt, sendAsMms, contentVariables, riskCheck, from, fallbackFrom, messagingServiceSid, body, mediaUrl, contentSid
The recipe
import AccessControl "mo:caffeineai-authorization/access-control";
import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
import MixinTwilioConfig "mixins/twilio-config";
import MixinTwilioMessaging "mixins/twilio-messaging";
actor {
let accessControlState : AccessControl.AccessControlState;
include MixinAuthorization(accessControlState, null);
// Admin-held Twilio credentials — never returned to the frontend.
let twilioConfig : {
var accountSid : Text; // AC… — also a positional arg on every v2010 call
var keySid : Text; // SK… (or the Account SID again, in dev)
var keySecret : Text; // the API-key secret (or the Auth Token, in dev)
var fromNumber : Text; // E.164, e.g. "+15551234567"
};
include MixinTwilioConfig(accessControlState, twilioConfig);
include MixinTwilioMessaging(twilioConfig);
};
The migration chain head:
import AccessControl "mo:caffeineai-authorization/access-control";
module {
type NewActor = {
accessControlState : AccessControl.AccessControlState;
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
};
};
public func migration(_old : {}) : NewActor {
{
accessControlState = AccessControl.initState();
twilioConfig = {
var accountSid = "";
var keySid = "";
var keySecret = "";
var fromNumber = "";
};
};
};
};
import AccessControl "mo:caffeineai-authorization/access-control";
import Runtime "mo:core/Runtime";
mixin (
accessControlState : AccessControl.AccessControlState,
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
},
) {
// All THREE are required, and this must agree with the guard in
// twilio-messaging.mo: `keySid` is the Basic-Auth *username*, so a blank one
// means every request goes out unauthenticated and Twilio answers 20003 —
// while the UI cheerfully reports "Configured".
public query func isTwilioConfigured() : async Bool {
twilioConfig.accountSid.size() > 0 and twilioConfig.keySid.size() > 0 and twilioConfig.keySecret.size() > 0;
};
// The sending number is not a secret — the UI may display it.
public query func getTwilioFromNumber() : async Text {
twilioConfig.fromNumber;
};
// Admin-only. NOTE `#admin` — never a first-caller-claims-ownership check,
// which the shared anonymous principal would defeat.
public shared ({ caller }) func setTwilioCredentials(
accountSid : Text,
keySid : Text,
keySecret : Text,
) : async () {
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
Runtime.trap("Unauthorized: Only admins can set Twilio credentials");
};
twilioConfig.accountSid := accountSid;
twilioConfig.keySid := keySid;
twilioConfig.keySecret := keySecret;
};
public shared ({ caller }) func setTwilioFromNumber(number : Text) : async () {
if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
Runtime.trap("Unauthorized: Only admins can set the sending number");
};
twilioConfig.fromNumber := number;
};
};
import Principal "mo:core/Principal";
import Runtime "mo:core/Runtime";
import { createMessage } "mo:twilio-client/Apis/Api20100401MessageApi";
import { defaultConfig; type Config } "mo:twilio-client/Config";
mixin (
twilioConfig : {
var accountSid : Text;
var keySid : Text;
var keySecret : Text;
var fromNumber : Text;
},
) {
// Credentials ride config.auth; defaultConfig is already non-replicated.
func twilioClientConfig() : Config {
{
defaultConfig with
auth = ?#basicAuth { user = twilioConfig.keySid; password = twilioConfig.keySecret };
max_response_bytes = ?(200_000 : Nat64);
};
};
/// Send an SMS to `to` (E.164). Returns the message SID.
public shared ({ caller }) func sendSms(to : Text, body : Text) : async Text {
if (caller.isAnonymous()) Runtime.trap("Sign in to send messages");
// Same three-way check as isTwilioConfigured(): accountSid goes in the URL
// path, keySid is the Basic-Auth user, keySecret the password. Missing any
// one of them fails at Twilio, not here, so check before spending cycles.
if (
twilioConfig.accountSid.size() == 0 or twilioConfig.keySid.size() == 0 or twilioConfig.keySecret.size() == 0
) {
Runtime.trap("Twilio is not configured (an admin must set all three credentials)");
};
let msg = await* createMessage(
twilioClientConfig(),
twilioConfig.accountSid, // accountSid — in the URL path, not the credential
to, // to (E.164)
"", "", // statusCallback, applicationSid
0.0, // maxPrice (0 = no cap)
false, // provideFeedback
0, 0, // attempt, validityPeriod
false, // forceDelivery
null, null, // contentRetention, addressRetention (omitted)
false, // smartEncoded
[], // persistentAction
null, // trafficType (omitted)
false, // shortenUrls
null, // scheduleType — MUST be null for an immediate send
"", // sendAt (scheduled sends only)
false, // sendAsMms
"", // contentVariables
null, // riskCheck (omitted)
twilioConfig.fromNumber, // from (use EITHER from OR messagingServiceSid)
"", // fallbackFrom
"", // messagingServiceSid
body, // body
[], // mediaUrl (set for MMS)
"", // contentSid (Content API templates)
);
// `sid` is optional in the generated model because the spec marks it
// nullable, though Twilio always sets it on a successful create. Fall back
// to "" rather than trapping: the outcall has already happened, so a trap
// would roll back this canister's own state while the SMS stays delivered.
switch (msg.sid) { case (?sid) sid; case null "" };
};
};
For MMS: mediaUrl = ["https://example.com/image.jpg"] and sendAsMms = true.
To send through a Messaging Service, leave from = "" and set
messagingServiceSid instead.
Addressing — E.164, and which sender
to must be E.164: +, country code, no spaces, dashes or parentheses —
"+15551234567". "555-1234" fails with 21211. Normalize in the frontend and
again in the canister; do not trust either alone.
from vs messagingServiceSid — exactly one. Setting both is an error.
A bare from number is fine for non-US traffic and demos; US-bound
production traffic should go through a Messaging Service (sender pool,
sticky sender, and it is what A2P registration attaches to).
- The sending number needs the SMS capability, which not every purchasable
number has. Filter on it when browsing
Api20100401AvailablePhoneNumberCountryApi.
US A2P 10DLC — three resources, in this order
Before any US long code can text US destinations, all three must exist. Without
them US carriers reject the traffic outright.
- Brand registration —
MessagingV1BrandRegistrationApi.createBrandRegistrations,
referencing Trust Hub customerProfileBundleSid + a2PProfileBundleSid
(created out of band). Pass mock = true in dev to skip the fee. Status starts
PENDING and settles to APPROVED / FAILED over hours to days; it fails if
business details are incomplete, inconsistently formatted, or do not match
registry data.
- A2P campaign —
MessagingV1UsAppToPersonApi.createUsAppToPerson,
referencing both the Messaging Service and the brand. Most onboarding
failures land here. T-Mobile rejects campaigns whose messageFlow does not
describe opt-in, or whose messageSamples do not match the declared
usAppToPersonUsecase.
- Number → service assignment —
MessagingV1PhoneNumberApi.createPhoneNumber(cfg, serviceSid, phoneNumberSid).
A number lives in exactly one Messaging Service at a time; reassignment needs
deletePhoneNumber first.
Registration deadline in force: campaigns without working privacyPolicyUrl
and termsAndConditionsUrl hard-400 since 2026-06-30. Both are positional
arguments on createUsAppToPerson and "" fails; the URLs must resolve to public
HTTPS pages, because Twilio fetches them during registration.
Toll-free numbers use a separate flow —
MessagingV1TollfreeVerificationApi — not A2P.
Available API surface
Documented and messaging-focused (this recipe):
| Module |
For |
Api20100401MessageApi |
send / fetch / list / update / delete messages |
Api20100401MediaApi, …MediaInstanceApi |
MMS media on a message |
Api20100401IncomingPhoneNumberApi (+ Local/Mobile/TollFree) |
numbers you own; delete = release |
Api20100401AvailablePhoneNumberCountryApi |
browse numbers to buy |
Api20100401BalanceApi, …AccountApi |
account balance and account records |
Api20100401UserDefinedMessageApi (+ Subscription) |
user-defined message events |
MessagingV1ServiceApi |
Messaging Services (sender pools) |
MessagingV1BrandRegistrationApi (+ Otp, BrandVettingApi) |
A2P brand |
MessagingV1UsAppToPersonApi (+ UsecaseApi) |
A2P campaigns |
MessagingV1PhoneNumberApi, …ShortCodeApi, …AlphaSenderApi, …ChannelSenderApi |
sender pool membership |
MessagingV1TollfreeVerificationApi |
toll-free verification |
MessagingV1Linkshortening*, …DomainConfig*, …DomainCertsApi |
branded link shortening |
MessagingV1DeactivationsApi |
carrier deactivation list |
Not in the package (pruned from the generated surface): calls, recordings,
conferences, participants, queues, applications, SIP domains and credentials,
usage records and triggers, addresses, keys, tokens, balance transactions. The
package ships the messaging surface only — for anything above, this connector is
not the path.
Errors and pagination
- Methods return the decoded record on 2xx and
throw Error.reject("HTTP <status> body[…]: …")
on 4xx/5xx. diagnostics is on, so the reject text carries Twilio's own error
body (code, message, more_info). Wrap in
try { … } catch (e) { Error.message(e) }.
- Codes worth mapping to real UI text: 20003 authenticate failed (bad
credential), 21211 invalid
To, 21408 region not permissioned (enable
the destination country's geo permissions in the console), 21608 unverified
recipient on a trial account, 21610 recipient has unsubscribed (STOP),
21703 sender pool exhausted, 21704 the Messaging Service has no numbers,
21714 pool size capped.
- A 2xx does not mean delivered.
createMessage returns status = #queued
or #accepted; delivery is asynchronous. Poll fetchMessage for
#delivered / #undelivered / #failed and read error_code, or configure a
statusCallback URL (needs an inbound HTTP endpoint — out of scope here).
- Pagination differs between the two API versions. v2010 lists —
listMessage
and every other Api20100401* list — return top-level next_page_uri /
previous_page_uri (?Text, and a path such as /2010-04-01/…, not a full
URL). Messaging v1 lists (listService, listPhoneNumber, the A2P registries)
instead nest pagination under meta, as next_page_url / previous_page_url
(full URLs) plus page_size. Only 10 of the 70 list responses use the meta
form; listMessage is not one of them. The meta field is typed
?ListAlphaSenderResponseMeta on every v1 list, including
ListServiceResponse — identical records are deduplicated to one shared module
at codegen time, so the name reflects whichever list sorted first, not the
endpoint you called.
pageSize defaults to 50 and caps at 1000. Bound every list call — an unbounded
listMessage on a busy account will blow max_response_bytes.
Reading the two shapes:
// Illustrative sketch, not a file to copy — `res` is assumed to be the decoded
// list response. Marked motoko-check:skip for that reason.
// v2010 (listMessage and every other Api20100401* list): top-level, a path
switch (res.next_page_uri) { case (?path) { /* fetch the next page */ }; case null {} };
// Messaging v1 (listService, listPhoneNumber, the A2P registries): nested, a full URL
switch (res.meta) { case (?m) { m.next_page_url }; case null null };
Field gotchas
usecase on createService is Text, not a variant. Valid: notifications,
marketing, verification, discussion, poll, undeclared. Anything else 400s.
usAppToPersonUsecase is a different, brand-tier-dependent enum — query
MessagingV1UsAppToPersonUsecaseApi.fetchUsAppToPersonUsecase for what a given
brand may use.
- Optional enum arguments are
?T — pass null to omit them, and prefer that.
The variants are closed: contentRetention #retain/#discard,
addressRetention #retain/#obfuscate, trafficType #free,
scheduleType #fixed, riskCheck #enable/#disable. There is no
#Text escape hatch — a value the spec does not list cannot be expressed.
Passing ?#fixed for scheduleType on an immediate send is a 400:
Twilio reads it as a scheduled message and then finds no SendAt. null is
the correct value for every one of these unless you specifically want the
behaviour.
maxPrice is omitted when 0.0, which is what you want. Sending
MaxPrice=0 would cap the message price at zero and make Twilio refuse paid
delivery; omitting it means "no cap". Pass 0.0 to omit.
xTwilioApiVersion (on the UsAppToPerson methods) — pass "" unless Twilio
support asks otherwise.
- Throughput is per sender: long code 1 message/second, toll-free ~3,
international long code ~10, short code 100. Per-number MPS cannot be raised —
scale by adding numbers to the Messaging Service's sender pool.
stickySender / areaCodeGeomatch are US + Canada only.
Config.baseUrl is unused. Every operation carries a hardcoded host
(api.twilio.com for v2010, messaging.twilio.com for v1), pinned at codegen
time from the merged spec. Do not set it and do not expect it to redirect
traffic.
Frontend
Twilio needs no OAuth: the credential is a long-lived pair the admin pastes,
so there is no redirect URI, no /connect/twilio route, and no per-user
handshake. Do not build one. What a Twilio build MUST ship is the page that lets
the admin get and enter the credentials — acceptance criteria, not
suggestions; a build missing them is broken, not merely incomplete:
- The credentials page exists and is reachable. A "send SMS" feature with
nowhere to enter a credential is unusable. A signed-in admin must reach
/settings/twilio from the nav or from the not-configured prompt.
- The console steps are in the UI, not only in the chat reply — the admin
returns weeks later, after the chat is gone.
- The API-Key secret is shown once by Twilio. Say so next to the input, or
admins will navigate away and have to create a second key.
A login flow — required. setTwilioCredentials gates on #admin, so the
app needs non-anonymous callers. Take login, useInternetIdentity / useActor
plumbing and the admin-role gate from
extension-authorization.
An admin settings page — /settings/twilio (admin-gated). Required:
- A "How to get your Twilio credentials" panel above the inputs, framed as
a one-time ~5-minute setup, with these numbered steps (the completion message
must repeat them verbatim):
- sign in at https://console.twilio.com;
- copy the Account SID (
AC…) from the dashboard;
- Account → API keys & tokens → Create API key (Standard); copy the
SID (
SK…) and the Secret — the Secret is displayed only once;
- Phone Numbers → Manage → Buy a number with the SMS capability;
- paste the three values plus the number below and save;
- on a trial account, verify each recipient under Verified Caller IDs.
Include a convenience link that opens the Twilio console.
- Three inputs: Account SID (plain text — not a secret), Key SID, Key
Secret (password input). Bound to
setTwilioCredentials; clear the secret on
success; keep the form re-submittable, because keys get rotated.
- A sending-number field bound to
setTwilioFromNumber, with an E.164
example (+15551234567) beside it and client-side validation.
- Status driven by
isTwilioConfigured() (Bool) — "Configured" / "Not
configured". That predicate requires all three values, Key SID
included: it is the Basic-Auth username, so a blank one means every request
is unauthenticated and Twilio answers 20003 while the page claims to be
configured. Never render the secret back, not even masked. The sending
number may be displayed (getTwilioFromNumber); it is not a secret.
- Make the page reachable. The shared Layout nav MUST link here when
isCallerAdmin is true and hide it otherwise. Add the link where the nav is
defined, not inside this page.
Empty-state nudges. When isTwilioConfigured() is false, never render a
dead "Send" button: admins get a "Set up Twilio" link to /settings/twilio;
non-anonymous non-admins get an explanation — e.g. "Texting isn't set up yet —
an administrator needs to add Twilio credentials in Settings."
Translate Twilio's errors. Failures arrive as rejected calls carrying
Twilio's code. Map at least these to an action rather than showing the raw
reject:
20003 → "The Twilio credentials are wrong — an admin should re-paste them"
21211 → "That phone number isn't valid — use the +15551234567 format"
21408 → "Texting that country isn't enabled on this Twilio account"
21608 → "On a trial account the recipient must be verified in Twilio first"
21610 → "That number has replied STOP and cannot be texted"
Never promise delivery. A successful call means queued, not delivered.
Word the UI accordingly ("Message queued") and, if delivery matters, show the
polled status from fetchMessage.
Suggested route layout:
/ → Main UI (any signed-in user; empty-state when unconfigured)
/settings/twilio → Admin credentials + sending number (admin-only)
# No /connect/twilio: Twilio uses pasted long-lived credentials, not a redirect flow.
What the composer must tell the Caffeine user
The app cannot send anything until a human creates a Twilio account, buys a
number and pastes credentials — so the completion message is part of the
deliverable, not a summary of it. It MUST contain, in this order:
- That credentials are required, and who enters them — an admin, on
/settings/twilio, reachable from the nav once signed in.
- The six numbered steps verbatim from Frontend item 2, including that the
API-key Secret is shown only once.
- That Twilio costs money — per-message pricing plus a monthly number fee,
and that a trial account can only text verified numbers and prefixes every
message with a trial notice.
- For US-bound traffic: the A2P 10DLC requirement, named as weeks of lead
time and additional fees, with the three ordered resources — otherwise the
user will ship an app that silently fails to reach US phones.
- The failure map, one line each:
20003 → re-paste credentials; 21211 →
E.164 format; 21408 → enable the destination country; 21608 → verify the
recipient (trial); 21610 → recipient unsubscribed.
Do not compress this to "configure Twilio in Settings" and do not substitute a
link to Twilio's documentation. Use the same wording here as in the settings-page
panel so the two cannot drift.
Known limitations
- Only the messaging surface is shipped. The package is pruned to the
messaging path; voice/recordings/SIP/usage and the rest are not in it.
- Inbound messages are out of scope. Receiving SMS, and
statusCallback
delivery receipts, need an inbound HTTP endpoint on the canister — a different
component, not this client.
- Binary media is not uploadable.
mediaUrl takes a public URL Twilio
fetches; the canister cannot POST image bytes through this client.
- No idempotency key. Twilio's messaging API has none, so a retry after a
timeout may send twice. Guard at the application level (a stable-variable
dedupe key per logical send) rather than retrying blindly. The non-replicated
default removes the ~13× amplification, not retry semantics.
- One dropped field.
POST …/IncomingPhoneNumbers/{Sid}.json accepts an
AccountSid form field (used to move a number between subaccounts) while
AccountSid is also its path parameter. The generator has a single namespace
for both, so the form copy is dropped and transferring a number to a
subaccount is not reachable through this client. Every other endpoint is
unaffected.
- Nothing here has been exercised against live Twilio. The wire format is at
least structurally right — writes send an
application/x-www-form-urlencoded
body with percent-encoded parameters, which is what Twilio requires — but no
call has been made. Treat a first successful send as the real acceptance test.
- Spec vintage: generated from Twilio's published OpenAPI specs merged by
spec-merge (Messaging v1 + API v2010), then pruned to the messaging surface.
Newer Twilio features absent from those specs are absent here.
Related
1---2name: connector-twilio3description: EXPERIMENTAL, NOT YET VERIFIED AGAINST LIVE TWILIO, and it spends real money — every message is billed, and a US-bound production number additionally needs A2P 10DLC registration (fees, weeks of lead time). Say both things to the user before building. That said, if a Caffeine build does send SMS or MMS, or configures Twilio messaging, from a canister, the `twilio-client` mops package (Twilio REST API) with a canister-held HTTP Basic credential is the only supported path. Hand-rolling `ic.http_request` calls to `api.twilio.com` or `messaging.twilio.com` is a FORBIDDEN anti-pattern — it bypasses the typed bindings, the per-operation host routing, the Basic-Auth header construction, and above all the non-replicated outcall default that stops one `send` from becoming ~13 billed messages. Load this skill whenever the user, spec, or any prior task mentions SMS, MMS, "text message", "send a text", phone numbers, Twilio, a Messaging Service, A2P 10DLC, toll-free verification, short codes, or an alphanumeric sender — 4---5
6# Twilio Connector (experimental)
7
8Send SMS / MMS and configure Twilio messaging from a Caffeine canister.
9
10> ⚠️ **Experimental (`twilio-client@0.1.2`) — no call has ever been made from this
11> client.** Its write path could work at all only recently: before, every write
12> discarded its arguments and posted an empty body. The wire format now matches
13> what Twilio documents (form-encoded body, percent-encoded values, optional
14> fields omitted) and all 118 files typecheck, but *structurally correct* is not
15> *verified*. Treat the first successful send as the acceptance
16> test, and do not present Twilio to a user as a fully supported platform feature
17> until one has happened. Sends cost money, so a failed experiment is not free.
18
19> **Scope — the package is the messaging surface only.** `twilio-client` is
20> pruned to **35 API modules** (all of Messaging v1 plus the v2010 messaging path:
21> Account, Message, Media, IncomingPhoneNumber and its variants,
22> AvailablePhoneNumber, the A2P registries). Voice/calls, recordings, conferences,
23> queues, applications, SIP and usage records are **not in the package** — if a
24> build needs those, they are outside this connector. (Counts: 35 API modules, 82
25> models, **118 files**, all typechecking.)
26
27## Orchestrator routing notes
28
29Load this skill when the user, spec, or a prior task mentions sending a text
30message, SMS/MMS, notifying someone by phone, buying or listing phone numbers, or
31any Twilio messaging concept. Raw `ic.http_request` to `*.twilio.com` is an
32anti-pattern that re-implements auth, host routing, percent-encoding and JSON
33parsing by hand — and, done naively, sends every message ~13 times.
34
35Intent → capability mapping:
36
37| User intent | Capability |
38| --- | --- |
39| Send an SMS | `Api20100401MessageApi.createMessage` with `from` = a Twilio number |
40| Send an MMS (image) | same, with `mediaUrl = ["https://…"]` and `sendAsMms = true` |
41| Send via a Messaging Service (recommended for US traffic) | same, `from = ""` + `messagingServiceSid` |
42| Check delivery status | `fetchMessage` (`status`, `error_code`) |
43| List / search sent messages | `listMessage` (paginated) |
44| Own or browse phone numbers | `Api20100401IncomingPhoneNumberApi`, `…AvailablePhoneNumberCountryApi` |
45| Set up a Messaging Service | `MessagingV1ServiceApi.createService` |
46| Register for US A2P 10DLC | `MessagingV1BrandRegistrationApi` → `MessagingV1UsAppToPersonApi` → `MessagingV1PhoneNumberApi` (in that order — see *US A2P 10DLC*) |
47| Verify a toll-free number | `MessagingV1TollfreeVerificationApi` |
48
49Twilio credentials are something a **human must go and fetch from a console**, so
50the build is not done when the backend compiles — it is done when the app tells
51the admin where to get the credential and gives them somewhere to paste it. See
52*Auth model*, then *Frontend* for the page that MUST ship, and repeat the steps in
53the completion message.
54
55**Ask before writing code:** which number sends? A US-bound production app needs a
56Messaging Service + A2P registration (weeks of lead time, real fees); a
57demo/internal app can send from a single trial number to *verified* recipients
58only. Report the choice and its consequences back to the prompting user.
59
60## Auth model — HTTP Basic, two flavours
61
62Both flavours are the same `#basicAuth { user; password }` credential and the
63client treats them identically; they differ in blast radius.
64
65| Flavour | `user` / `password` | When |
66| --- | --- | --- |
67| **API Key** *(default — prefer this)* | API Key **SID** (`SK…`) / its **Secret** | Production. Revocable and scoped: leaking one does not surrender the account. |
68| **Account SID + Auth Token** | Account **SID** (`AC…`) / **Auth Token** | Dev only. The Auth Token *is* the account — it can create sub-accounts, buy numbers, and spend money. |
69
70The **Account SID** (`AC…`) is *also* a required positional argument to every
71v2010 operation (it is in the URL path), regardless of which flavour is used. So
72an app using an API Key stores **three** values: Account SID, Key SID, Key Secret.
73
74### Obtaining credentials
75
761. Sign in at <https://console.twilio.com>.
772. The **Account SID** (`AC…`) is on the console dashboard — copy it.
783. For production, **Account → API keys & tokens → Create API key** (Standard);
79 copy the **SID** (`SK…`) and the **Secret**. **The Secret is shown once** — if
80 the admin navigates away it cannot be recovered, only replaced.
81 For dev only, take the **Auth Token** from the dashboard instead.
824. Buy a sending number: **Phone Numbers → Manage → Buy a number**, with the
83 **SMS** capability ticked (not every number has it).
845. On a **trial** account: verify each recipient under **Phone Numbers → Verified
85 Caller IDs**, or sends fail with `21608`; trial messages also carry a
86 "Sent from your Twilio trial account" prefix.
87
88### Handing the credentials to the canister
89
90The admin pastes them through an **admin-gated** setter — gated on
91`AccessControl.hasPermission(state, caller, #admin)`. They are held by the
92canister only and **never** returned to the frontend.
93
94> ⚠️ **Never gate the setter on a first-caller-claims-ownership scheme.** On the
95> IC every unauthenticated caller is the *same* anonymous principal, so if an
96> anonymous call claims ownership first, every anonymous caller passes the
97> `caller == owner` check and can overwrite the credential — and this one spends
98> money.
99
100The canister hands them to the client **only** through
101`config.auth = ?#basicAuth { user; password }`, which every method turns into an
102`Authorization: Basic …` header. No method takes a credential argument and none
103puts it in the URL, so it cannot leak through a logged query string.
104
105## Outcalls are already non-replicated — and this CORRECTS earlier guidance
106
107`defaultConfig` ships `is_replicated = ?false`, so anything derived from it by
108record update is correct as-is. Nothing to remember, nothing to add.
109
110> ⚠️ **Do not set it to `?true` or `null`, and disregard any older advice to do
111> so.** An older version of this SKILL claimed writes should stay
112> replicated "so IC consensus dedups retries". **That is false and expensive.** A
113> replicated outcall is performed by *every* node in the subnet: the request is
114> sent ~13 times, so **~13 SMS are sent and ~13 are billed**, the credential
115> leaves every node, and consensus fails anyway because Twilio stamps each reply
116> with a unique `sid` (so the responses never agree byte-for-byte). This is the
117> same defect that produced ~13 duplicate emails via the Gmail connector and drove
118> `slack-client` 0.1.0.
119
120Reads (`fetch*` / `list*`) are equally fine non-replicated: one node's view of a
121message log is what you want, and it is the cheaper path.
122
123# Backend
124
125## Add dependencies
126
127The admin gate in the recipe below needs the authorization component alongside the
128client:
129
130```bash
131mops add twilio-client@0.1.2
132mops add caffeineai-authorization@1.0.1
133```
134
135## Calling shape — free functions or the class facade
136
137Every module offers both. The free function takes `config` first and is `async*`;
138the `module class` captures `config` and is `async`:
139
140<!-- motoko-check:skip -->
141```motoko filepath=src/backend/calling-shape.mo
142// Illustrative sketch, not a file to copy: `cfg`/`accountSid` are assumed to
143// exist and the argument lists are elided. Marked motoko-check:skip for that
144// reason — the compiled examples are the three mixins below.
145import MessageApi "mo:twilio-client/Apis/Api20100401MessageApi";
146
147// free function — config passed explicitly
148let m = await* MessageApi.createMessage(cfg, accountSid, /* … */);
149
150// class facade — config captured once
151let messages = MessageApi.Api20100401MessageApi(cfg);
152let m2 = await messages.createMessage(accountSid, /* … */);
153```
154
155**All parameters are positional and there are 27 of them on `createMessage`.**
156Pass `""` / `false` / `0` / `0.0` / `[]` / **`null`** for the ones you do not
157use — the optional enum parameters are `?T` precisely so that `null` omits them
158from the wire. Count carefully; a misplaced empty string silently sends the
159wrong field. The order is:
160
161`config, accountSid, to, statusCallback, applicationSid, maxPrice,
162provideFeedback, attempt, validityPeriod, forceDelivery, contentRetention,
163addressRetention, smartEncoded, persistentAction, trafficType, shortenUrls,
164scheduleType, sendAt, sendAsMms, contentVariables, riskCheck, from, fallbackFrom,
165messagingServiceSid, body, mediaUrl, contentSid`
166
167## The recipe
168
169```motoko filepath=src/backend/main.mo
170import AccessControl "mo:caffeineai-authorization/access-control";
171import MixinAuthorization "mo:caffeineai-authorization/MixinAuthorization";
172import MixinTwilioConfig "mixins/twilio-config";
173import MixinTwilioMessaging "mixins/twilio-messaging";
174
175actor {
176 let accessControlState : AccessControl.AccessControlState;
177 include MixinAuthorization(accessControlState, null);
178
179 // Admin-held Twilio credentials — never returned to the frontend.
180 let twilioConfig : {
181 var accountSid : Text; // AC… — also a positional arg on every v2010 call
182 var keySid : Text; // SK… (or the Account SID again, in dev)
183 var keySecret : Text; // the API-key secret (or the Auth Token, in dev)
184 var fromNumber : Text; // E.164, e.g. "+15551234567"
185 };
186 include MixinTwilioConfig(accessControlState, twilioConfig);
187 include MixinTwilioMessaging(twilioConfig);
188};
189```
190
191The migration chain head:
192
193```motoko filepath=src/backend/migrations/00000000_000000.mo
194import AccessControl "mo:caffeineai-authorization/access-control";
195
196module {
197 type NewActor = {
198 accessControlState : AccessControl.AccessControlState;
199 twilioConfig : {
200 var accountSid : Text;
201 var keySid : Text;
202 var keySecret : Text;
203 var fromNumber : Text;
204 };
205 };
206
207 public func migration(_old : {}) : NewActor {
208 {
209 accessControlState = AccessControl.initState();
210 twilioConfig = {
211 var accountSid = "";
212 var keySid = "";
213 var keySecret = "";
214 var fromNumber = "";
215 };
216 };
217 };
218};
219```
220
221```motoko filepath=src/backend/mixins/twilio-config.mo
222import AccessControl "mo:caffeineai-authorization/access-control";
223import Runtime "mo:core/Runtime";
224
225mixin (
226 accessControlState : AccessControl.AccessControlState,
227 twilioConfig : {
228 var accountSid : Text;
229 var keySid : Text;
230 var keySecret : Text;
231 var fromNumber : Text;
232 },
233) {
234 // All THREE are required, and this must agree with the guard in
235 // twilio-messaging.mo: `keySid` is the Basic-Auth *username*, so a blank one
236 // means every request goes out unauthenticated and Twilio answers 20003 —
237 // while the UI cheerfully reports "Configured".
238 public query func isTwilioConfigured() : async Bool {
239 twilioConfig.accountSid.size() > 0 and twilioConfig.keySid.size() > 0 and twilioConfig.keySecret.size() > 0;
240 };
241
242 // The sending number is not a secret — the UI may display it.
243 public query func getTwilioFromNumber() : async Text {
244 twilioConfig.fromNumber;
245 };
246
247 // Admin-only. NOTE `#admin` — never a first-caller-claims-ownership check,
248 // which the shared anonymous principal would defeat.
249 public shared ({ caller }) func setTwilioCredentials(
250 accountSid : Text,
251 keySid : Text,
252 keySecret : Text,
253 ) : async () {
254 if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
255 Runtime.trap("Unauthorized: Only admins can set Twilio credentials");
256 };
257 twilioConfig.accountSid := accountSid;
258 twilioConfig.keySid := keySid;
259 twilioConfig.keySecret := keySecret;
260 };
261
262 public shared ({ caller }) func setTwilioFromNumber(number : Text) : async () {
263 if (not AccessControl.hasPermission(accessControlState, caller, #admin)) {
264 Runtime.trap("Unauthorized: Only admins can set the sending number");
265 };
266 twilioConfig.fromNumber := number;
267 };
268};
269```
270
271```motoko filepath=src/backend/mixins/twilio-messaging.mo
272import Principal "mo:core/Principal";
273import Runtime "mo:core/Runtime";
274import { createMessage } "mo:twilio-client/Apis/Api20100401MessageApi";
275import { defaultConfig; type Config } "mo:twilio-client/Config";
276
277mixin (
278 twilioConfig : {
279 var accountSid : Text;
280 var keySid : Text;
281 var keySecret : Text;
282 var fromNumber : Text;
283 },
284) {
285 // Credentials ride config.auth; defaultConfig is already non-replicated.
286 func twilioClientConfig() : Config {
287 {
288 defaultConfig with
289 auth = ?#basicAuth { user = twilioConfig.keySid; password = twilioConfig.keySecret };
290 max_response_bytes = ?(200_000 : Nat64);
291 };
292 };
293
294 /// Send an SMS to `to` (E.164). Returns the message SID.
295 public shared ({ caller }) func sendSms(to : Text, body : Text) : async Text {
296 if (caller.isAnonymous()) Runtime.trap("Sign in to send messages");
297 // Same three-way check as isTwilioConfigured(): accountSid goes in the URL
298 // path, keySid is the Basic-Auth user, keySecret the password. Missing any
299 // one of them fails at Twilio, not here, so check before spending cycles.
300 if (
301 twilioConfig.accountSid.size() == 0 or twilioConfig.keySid.size() == 0 or twilioConfig.keySecret.size() == 0
302 ) {
303 Runtime.trap("Twilio is not configured (an admin must set all three credentials)");
304 };
305 let msg = await* createMessage(
306 twilioClientConfig(),
307 twilioConfig.accountSid, // accountSid — in the URL path, not the credential
308 to, // to (E.164)
309 "", "", // statusCallback, applicationSid
310 0.0, // maxPrice (0 = no cap)
311 false, // provideFeedback
312 0, 0, // attempt, validityPeriod
313 false, // forceDelivery
314 null, null, // contentRetention, addressRetention (omitted)
315 false, // smartEncoded
316 [], // persistentAction
317 null, // trafficType (omitted)
318 false, // shortenUrls
319 null, // scheduleType — MUST be null for an immediate send
320 "", // sendAt (scheduled sends only)
321 false, // sendAsMms
322 "", // contentVariables
323 null, // riskCheck (omitted)
324 twilioConfig.fromNumber, // from (use EITHER from OR messagingServiceSid)
325 "", // fallbackFrom
326 "", // messagingServiceSid
327 body, // body
328 [], // mediaUrl (set for MMS)
329 "", // contentSid (Content API templates)
330 );
331 // `sid` is optional in the generated model because the spec marks it
332 // nullable, though Twilio always sets it on a successful create. Fall back
333 // to "" rather than trapping: the outcall has already happened, so a trap
334 // would roll back this canister's own state while the SMS stays delivered.
335 switch (msg.sid) { case (?sid) sid; case null "" };
336 };
337};
338```
339
340For MMS: `mediaUrl = ["https://example.com/image.jpg"]` and `sendAsMms = true`.
341To send through a Messaging Service, leave `from = ""` and set
342`messagingServiceSid` instead.
343
344## Addressing — E.164, and which sender
345
346- **`to` must be E.164**: `+`, country code, no spaces, dashes or parentheses —
347 `"+15551234567"`. `"555-1234"` fails with `21211`. Normalize in the frontend and
348 again in the canister; do not trust either alone.
349- **`from` vs `messagingServiceSid` — exactly one.** Setting both is an error.
350 A bare `from` number is fine for non-US traffic and demos; **US-bound
351 production traffic should go through a Messaging Service** (sender pool,
352 sticky sender, and it is what A2P registration attaches to).
353- **The sending number needs the SMS capability**, which not every purchasable
354 number has. Filter on it when browsing
355 `Api20100401AvailablePhoneNumberCountryApi`.
356
357## US A2P 10DLC — three resources, in this order
358
359Before any US long code can text US destinations, all three must exist. Without
360them US carriers reject the traffic outright.
361
3621. **Brand registration** — `MessagingV1BrandRegistrationApi.createBrandRegistrations`,
363 referencing Trust Hub `customerProfileBundleSid` + `a2PProfileBundleSid`
364 (created out of band). Pass `mock = true` in dev to skip the fee. Status starts
365 `PENDING` and settles to `APPROVED` / `FAILED` over hours to days; it fails if
366 business details are incomplete, inconsistently formatted, or do not match
367 registry data.
3682. **A2P campaign** — `MessagingV1UsAppToPersonApi.createUsAppToPerson`,
369 referencing both the Messaging Service and the brand. **Most onboarding
370 failures land here.** T-Mobile rejects campaigns whose `messageFlow` does not
371 describe opt-in, or whose `messageSamples` do not match the declared
372 `usAppToPersonUsecase`.
3733. **Number → service assignment** —
374 `MessagingV1PhoneNumberApi.createPhoneNumber(cfg, serviceSid, phoneNumberSid)`.
375 A number lives in exactly one Messaging Service at a time; reassignment needs
376 `deletePhoneNumber` first.
377
378**Registration deadline in force:** campaigns without working `privacyPolicyUrl`
379*and* `termsAndConditionsUrl` hard-400 since 2026-06-30. Both are positional
380arguments on `createUsAppToPerson` and `""` fails; the URLs must resolve to public
381HTTPS pages, because Twilio fetches them during registration.
382
383Toll-free numbers use a **separate** flow —
384`MessagingV1TollfreeVerificationApi` — not A2P.
385
386## Available API surface
387
388Documented and messaging-focused (this recipe):
389
390| Module | For |
391| --- | --- |
392| `Api20100401MessageApi` | send / fetch / list / update / delete messages |
393| `Api20100401MediaApi`, `…MediaInstanceApi` | MMS media on a message |
394| `Api20100401IncomingPhoneNumberApi` (+ `Local`/`Mobile`/`TollFree`) | numbers you own; delete = release |
395| `Api20100401AvailablePhoneNumberCountryApi` | browse numbers to buy |
396| `Api20100401BalanceApi`, `…AccountApi` | account balance and account records |
397| `Api20100401UserDefinedMessageApi` (+ `Subscription`) | user-defined message events |
398| `MessagingV1ServiceApi` | Messaging Services (sender pools) |
399| `MessagingV1BrandRegistrationApi` (+ `Otp`, `BrandVettingApi`) | A2P brand |
400| `MessagingV1UsAppToPersonApi` (+ `UsecaseApi`) | A2P campaigns |
401| `MessagingV1PhoneNumberApi`, `…ShortCodeApi`, `…AlphaSenderApi`, `…ChannelSenderApi` | sender pool membership |
402| `MessagingV1TollfreeVerificationApi` | toll-free verification |
403| `MessagingV1Linkshortening*`, `…DomainConfig*`, `…DomainCertsApi` | branded link shortening |
404| `MessagingV1DeactivationsApi` | carrier deactivation list |
405
406**Not in the package** (pruned from the generated surface): calls, recordings,
407conferences, participants, queues, applications, SIP domains and credentials,
408usage records and triggers, addresses, keys, tokens, balance transactions. The
409package ships the messaging surface only — for anything above, this connector is
410not the path.
411
412## Errors and pagination
413
414- Methods return the decoded record on 2xx and `throw Error.reject("HTTP <status> body[…]: …")`
415 on 4xx/5xx. `diagnostics` is on, so the reject text carries Twilio's own error
416 body (`code`, `message`, `more_info`). Wrap in
417 `try { … } catch (e) { Error.message(e) }`.
418- Codes worth mapping to real UI text: **20003** authenticate failed (bad
419 credential), **21211** invalid `To`, **21408** region not permissioned (enable
420 the destination country's geo permissions in the console), **21608** unverified
421 recipient on a trial account, **21610** recipient has unsubscribed (STOP),
422 **21703** sender pool exhausted, **21704** the Messaging Service has no numbers,
423 **21714** pool size capped.
424- **A 2xx does not mean delivered.** `createMessage` returns `status = #queued`
425 or `#accepted`; delivery is asynchronous. Poll `fetchMessage` for
426 `#delivered` / `#undelivered` / `#failed` and read `error_code`, or configure a
427 `statusCallback` URL (needs an inbound HTTP endpoint — out of scope here).
428- **Pagination differs between the two API versions.** v2010 lists — `listMessage`
429 and every other `Api20100401*` list — return **top-level** `next_page_uri` /
430 `previous_page_uri` (`?Text`, and a *path* such as `/2010-04-01/…`, not a full
431 URL). Messaging v1 lists (`listService`, `listPhoneNumber`, the A2P registries)
432 instead nest pagination under `meta`, as `next_page_url` / `previous_page_url`
433 (full URLs) plus `page_size`. Only 10 of the 70 list responses use the `meta`
434 form; `listMessage` is **not** one of them. The `meta` field is typed
435 `?ListAlphaSenderResponseMeta` on *every* v1 list, including
436 `ListServiceResponse` — identical records are deduplicated to one shared module
437 at codegen time, so the name reflects whichever list sorted first, not the
438 endpoint you called.
439- `pageSize` defaults to 50 and caps at 1000. Bound every list call — an unbounded
440 `listMessage` on a busy account will blow `max_response_bytes`.
441
442Reading the two shapes:
443
444<!-- motoko-check:skip -->
445```motoko filepath=src/backend/pagination-shape.mo
446// Illustrative sketch, not a file to copy — `res` is assumed to be the decoded
447// list response. Marked motoko-check:skip for that reason.
448
449// v2010 (listMessage and every other Api20100401* list): top-level, a path
450switch (res.next_page_uri) { case (?path) { /* fetch the next page */ }; case null {} };
451
452// Messaging v1 (listService, listPhoneNumber, the A2P registries): nested, a full URL
453switch (res.meta) { case (?m) { m.next_page_url }; case null null };
454```
455
456## Field gotchas
457
458- `usecase` on `createService` is **`Text`, not a variant**. Valid: `notifications`,
459 `marketing`, `verification`, `discussion`, `poll`, `undeclared`. Anything else 400s.
460- `usAppToPersonUsecase` is a *different*, brand-tier-dependent enum — query
461 `MessagingV1UsAppToPersonUsecaseApi.fetchUsAppToPersonUsecase` for what a given
462 brand may use.
463- **Optional enum arguments are `?T` — pass `null` to omit them, and prefer that.**
464 The variants are *closed*: `contentRetention` `#retain`/`#discard`,
465 `addressRetention` `#retain`/`#obfuscate`, `trafficType` `#free`,
466 `scheduleType` `#fixed`, `riskCheck` `#enable`/`#disable`. There is **no
467 `#Text` escape hatch** — a value the spec does not list cannot be expressed.
468 Passing `?#fixed` for `scheduleType` on an immediate send is a **400**:
469 Twilio reads it as a scheduled message and then finds no `SendAt`. `null` is
470 the correct value for every one of these unless you specifically want the
471 behaviour.
472- **`maxPrice` is omitted when `0.0`, which is what you want.** Sending
473 `MaxPrice=0` would cap the message price at zero and make Twilio refuse paid
474 delivery; omitting it means "no cap". Pass `0.0` to omit.
475- `xTwilioApiVersion` (on the `UsAppToPerson` methods) — pass `""` unless Twilio
476 support asks otherwise.
477- **Throughput is per sender:** long code 1 message/second, toll-free ~3,
478 international long code ~10, short code 100. Per-number MPS cannot be raised —
479 scale by adding numbers to the Messaging Service's sender pool.
480- `stickySender` / `areaCodeGeomatch` are US + Canada only.
481- `Config.baseUrl` is **unused**. Every operation carries a hardcoded host
482 (`api.twilio.com` for v2010, `messaging.twilio.com` for v1), pinned at codegen
483 time from the merged spec. Do not set it and do not expect it to redirect
484 traffic.
485
486# Frontend
487
488Twilio needs **no OAuth**: the credential is a long-lived pair the admin pastes,
489so there is no redirect URI, no `/connect/twilio` route, and no per-user
490handshake. Do not build one. What a Twilio build MUST ship is the page that lets
491the admin *get* and *enter* the credentials — **acceptance criteria, not
492suggestions**; a build missing them is **broken, not merely incomplete**:
493
494- **The credentials page exists and is reachable.** A "send SMS" feature with
495 nowhere to enter a credential is unusable. A signed-in admin must reach
496 `/settings/twilio` from the nav or from the not-configured prompt.
497- **The console steps are in the UI**, not only in the chat reply — the admin
498 returns weeks later, after the chat is gone.
499- **The API-Key secret is shown once by Twilio.** Say so next to the input, or
500 admins will navigate away and have to create a second key.
501
5021. **A login flow — required.** `setTwilioCredentials` gates on `#admin`, so the
503 app needs non-anonymous callers. Take login, `useInternetIdentity` / `useActor`
504 plumbing and the admin-role gate from
505 [`extension-authorization`](../extension-authorization/SKILL.md).
506
5072. **An admin settings page** — `/settings/twilio` (admin-gated). Required:
508 - A "How to get your Twilio credentials" panel **above** the inputs, framed as
509 a one-time ~5-minute setup, with these numbered steps (the completion message
510 must repeat them verbatim):
511 1. sign in at <https://console.twilio.com>;
512 2. copy the **Account SID** (`AC…`) from the dashboard;
513 3. **Account → API keys & tokens → Create API key** (Standard); copy the
514 **SID** (`SK…`) and the **Secret** — *the Secret is displayed only once*;
515 4. **Phone Numbers → Manage → Buy a number** with the **SMS** capability;
516 5. paste the three values plus the number below and save;
517 6. on a trial account, verify each recipient under **Verified Caller IDs**.
518 Include a convenience link that opens the Twilio console.
519 - **Three inputs**: Account SID (plain text — not a secret), Key SID, Key
520 Secret (password input). Bound to `setTwilioCredentials`; clear the secret on
521 success; keep the form re-submittable, because keys get rotated.
522 - **A sending-number field** bound to `setTwilioFromNumber`, with an E.164
523 example (`+15551234567`) beside it and client-side validation.
524 - Status driven by `isTwilioConfigured()` (`Bool`) — "Configured" / "Not
525 configured". That predicate requires **all three** values, Key SID
526 included: it is the Basic-Auth username, so a blank one means every request
527 is unauthenticated and Twilio answers `20003` while the page claims to be
528 configured. **Never** render the secret back, not even masked. The sending
529 number may be displayed (`getTwilioFromNumber`); it is not a secret.
530 - **Make the page reachable.** The shared Layout nav MUST link here when
531 `isCallerAdmin` is true and hide it otherwise. Add the link where the nav is
532 defined, not inside this page.
533
5343. **Empty-state nudges.** When `isTwilioConfigured()` is `false`, never render a
535 dead "Send" button: admins get a "Set up Twilio" link to `/settings/twilio`;
536 non-anonymous non-admins get an explanation — e.g. "Texting isn't set up yet —
537 an administrator needs to add Twilio credentials in Settings."
538
5394. **Translate Twilio's errors.** Failures arrive as *rejected* calls carrying
540 Twilio's `code`. Map at least these to an action rather than showing the raw
541 reject:
542 - `20003` → "The Twilio credentials are wrong — an admin should re-paste them"
543 - `21211` → "That phone number isn't valid — use the +15551234567 format"
544 - `21408` → "Texting that country isn't enabled on this Twilio account"
545 - `21608` → "On a trial account the recipient must be verified in Twilio first"
546 - `21610` → "That number has replied STOP and cannot be texted"
547
5485. **Never promise delivery.** A successful call means *queued*, not delivered.
549 Word the UI accordingly ("Message queued") and, if delivery matters, show the
550 polled `status` from `fetchMessage`.
551
552Suggested route layout:
553
554```
555/ → Main UI (any signed-in user; empty-state when unconfigured)
556/settings/twilio → Admin credentials + sending number (admin-only)
557# No /connect/twilio: Twilio uses pasted long-lived credentials, not a redirect flow.
558```
559
560## What the composer must tell the Caffeine user
561
562The app cannot send anything until a human creates a Twilio account, buys a
563number and pastes credentials — so the **completion message is part of the
564deliverable**, not a summary of it. It MUST contain, in this order:
565
5661. **That credentials are required, and who enters them** — an admin, on
567 `/settings/twilio`, reachable from the nav once signed in.
5682. **The six numbered steps verbatim** from *Frontend* item 2, including that the
569 API-key **Secret is shown only once**.
5703. **That Twilio costs money** — per-message pricing plus a monthly number fee,
571 and that a trial account can only text **verified** numbers and prefixes every
572 message with a trial notice.
5734. **For US-bound traffic: the A2P 10DLC requirement**, named as weeks of lead
574 time and additional fees, with the three ordered resources — otherwise the
575 user will ship an app that silently fails to reach US phones.
5765. **The failure map, one line each**: `20003` → re-paste credentials; `21211` →
577 E.164 format; `21408` → enable the destination country; `21608` → verify the
578 recipient (trial); `21610` → recipient unsubscribed.
579
580Do not compress this to "configure Twilio in Settings" and do not substitute a
581link to Twilio's documentation. Use the same wording here as in the settings-page
582panel so the two cannot drift.
583
584## Known limitations
585
586- **Only the messaging surface is shipped.** The package is pruned to the
587 messaging path; voice/recordings/SIP/usage and the rest are not in it.
588- **Inbound messages are out of scope.** Receiving SMS, and `statusCallback`
589 delivery receipts, need an inbound HTTP endpoint on the canister — a different
590 component, not this client.
591- **Binary media is not uploadable.** `mediaUrl` takes a *public URL* Twilio
592 fetches; the canister cannot POST image bytes through this client.
593- **No idempotency key.** Twilio's messaging API has none, so a retry after a
594 timeout may send twice. Guard at the application level (a stable-variable
595 dedupe key per logical send) rather than retrying blindly. The non-replicated
596 default removes the ~13× amplification, not retry semantics.
597- **One dropped field.** `POST …/IncomingPhoneNumbers/{Sid}.json` accepts an
598 `AccountSid` *form* field (used to move a number between subaccounts) while
599 `AccountSid` is also its path parameter. The generator has a single namespace
600 for both, so the form copy is dropped and **transferring a number to a
601 subaccount is not reachable** through this client. Every other endpoint is
602 unaffected.
603- **Nothing here has been exercised against live Twilio.** The wire format is at
604 least structurally right — writes send an `application/x-www-form-urlencoded`
605 body with percent-encoded parameters, which is what Twilio requires — but no
606 call has been made. Treat a first successful send as the real acceptance test.
607- **Spec vintage:** generated from Twilio's published OpenAPI specs merged by
608 `spec-merge` (Messaging v1 + API v2010), then pruned to the messaging surface.
609 Newer Twilio features absent from those specs are absent here.
610
611## Related
612
613- [`mops add twilio-client@0.1.2`](https://mops.one/twilio-client) — the generated Twilio REST bindings (35 messaging modules).
614- [Twilio Messaging docs](https://www.twilio.com/docs/messaging) — the API this wraps.
615- [`chat`-free quickstart: sending SMS](https://www.twilio.com/docs/messaging/api/message-resource) — the `Message` resource, its fields and statuses.
616- [Twilio error codes](https://www.twilio.com/docs/api/errors) — the numeric codes surfaced in reject messages.
617- [A2P 10DLC overview](https://www.twilio.com/docs/messaging/compliance/a2p-10dlc) — brand, campaign, number assignment.
618- [API keys vs Auth Token](https://www.twilio.com/docs/iam/api-keys) — why production uses `SK…`.
619- [extension-authorization](../extension-authorization/SKILL.md) — **required prerequisite**. Internet Identity login, `useInternetIdentity` / `useActor` plumbing, and the `#admin` gate the credential setter needs.