---
name: connector-twilio
description: >-
  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 — and BEFORE writing any code that touches a Twilio
  endpoint.
version: 0.1.1
caffeineai-subscription: [none]
compatibility:
  mops:
    twilio-client: "~0.1.2"
    caffeineai-authorization: "~1.0.1"
---

# 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

1. Sign in at <https://console.twilio.com>.
2. The **Account SID** (`AC…`) is on the console dashboard — copy it.
3. 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.
4. Buy a sending number: **Phone Numbers → Manage → Buy a number**, with the
   **SMS** capability ticked (not every number has it).
5. 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:

```bash
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`:

<!-- motoko-check:skip -->
```motoko filepath=src/backend/calling-shape.mo
// 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

```motoko filepath=src/backend/main.mo
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:

```motoko filepath=src/backend/migrations/00000000_000000.mo
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 = "";
      };
    };
  };
};
```

```motoko filepath=src/backend/mixins/twilio-config.mo
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;
  };
};
```

```motoko filepath=src/backend/mixins/twilio-messaging.mo
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.

1. **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.
2. **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`.
3. **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, appl