---
title: Twilio
description: Twilio SMS, MMS, and RCS adapter for Chat SDK.
tagline: Build SMS, MMS, and RCS bots with Twilio Messaging webhooks and the Messages API.
package: @chat-adapter/twilio
---

# Twilio



## Install

<PackageInstall package="@chat-adapter/twilio" />

## Quick start

<Callout type="info">
  The adapter auto-detects `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER`, and `TWILIO_MESSAGING_SERVICE_SID` from the environment.
</Callout>

```typescript title="lib/bot.ts" lineNumbers
import { createTwilioAdapter } from "@chat-adapter/twilio";
import { Chat } from "chat";

const bot = new Chat({
  userName: "mybot",
  adapters: {
    twilio: createTwilioAdapter(),
  },
});

bot.onDirectMessage(async (thread, message) => {
  await thread.post(`You said: ${message.text}`);
});
```

```typescript title="app/api/webhooks/twilio/route.ts" lineNumbers
import { bot } from "@/lib/bot";

export async function POST(request: Request): Promise<Response> {
  return bot.webhooks.twilio(request);
}
```

Configure your Twilio Messaging webhook URL to:

```text
https://your-domain.com/api/webhooks/twilio
```

## Configuration

<TypeTable
  type={{
  accountSid: {
    type: "string | (() => string | Promise<string>)",
    description:
      "Twilio Account SID. Auto-detected from `TWILIO_ACCOUNT_SID`.",
  },
  authToken: {
    type: "string | (() => string | Promise<string>)",
    description:
      "Twilio Auth Token for API calls and webhook verification. Auto-detected from `TWILIO_AUTH_TOKEN`.",
  },
  phoneNumber: {
    type: "string",
    description:
      "Default sender phone number for `openDM`. Auto-detected from `TWILIO_PHONE_NUMBER`.",
  },
  messagingServiceSid: {
    type: "string",
    description:
      "Default Messaging Service SID for `openDM`. Auto-detected from `TWILIO_MESSAGING_SERVICE_SID`.",
  },
  rcsSenderId: {
    type: "string",
    description:
      "Direct RCS sender address (e.g. `rcs:MyBrand`) for `openDM` when targeting RCS. Auto-detected from `TWILIO_RCS_SENDER_ID`.",
  },
  contentApiUrl: {
    type: "string",
    description:
      "Override the Twilio Content API base URL. Defaults to `apiUrl` when that is set, otherwise `https://content.twilio.com`.",
  },
  webhookUrl: {
    type: "string | ((request: Request) => string | Promise<string>)",
    description:
      "Public webhook URL to use for Twilio signature validation when the runtime request URL differs from the URL configured in Twilio.",
  },
  webhookVerifier: {
    type: "(request: Request, body: string) => boolean | string | Promise<boolean | string>",
    description:
      "Custom verifier for runtimes that terminate or transform Twilio requests before they reach the adapter.",
  },
  statusCallbackUrl: {
    type: "string",
    description: "Optional status callback URL for outbound messages.",
  },
  apiUrl: {
    type: "string",
    description:
      "Override the Twilio API base URL and trusted origin for media downloads.",
  },
}}
/>

## Authentication

1. Create or open a Twilio account.
2. Copy the **Account SID** to `TWILIO_ACCOUNT_SID`.
3. Copy the **Auth Token** to `TWILIO_AUTH_TOKEN`.
4. Copy a sender phone number to `TWILIO_PHONE_NUMBER`, or copy a Messaging Service SID to `TWILIO_MESSAGING_SERVICE_SID`.

## Webhooks

Twilio sends Messaging webhooks as form-encoded requests and signs them with the `X-Twilio-Signature` header. The adapter validates the exact public URL plus the submitted form parameters before dispatching an inbound message.

If your framework rewrites the request URL before it reaches the adapter, pass `webhookUrl` with the public URL configured in Twilio:

```typescript title="lib/bot.ts" lineNumbers
createTwilioAdapter({
  webhookUrl: "https://your-domain.com/api/webhooks/twilio",
});
```

## RCS setup

<Callout type="info">
  RCS uses the same webhook URL and adapter as SMS/MMS — no separate endpoint is needed.
</Callout>

To enable RCS rich messaging:

1. **Register an RCS Sender** in the Twilio Console under Messaging → RCS Senders. Carrier approval typically takes 4–6 weeks.
2. **Add the RCS Sender and an SMS phone number** to a Messaging Service so Twilio can auto-fallback to SMS when RCS is unavailable.
3. **Set `TWILIO_MESSAGING_SERVICE_SID`** to the Messaging Service SID (starts with `MG`).
4. Point the Messaging Service webhook to the same URL as your SMS webhook — the adapter handles both channels.

When an RCS-capable sender is detected (`MG…` Messaging Service or `rcs:` address), the adapter automatically:

* Sends cards as Twilio Content API templates (rich cards with buttons) over RCS
* Includes an SMS text fallback in every template so non-RCS recipients get a usable message
* Routes inbound taps of buttons rendered by Chat SDK to `onAction` handlers

Inbound RCS messages are keyed to the Messaging Service (or configured RCS sender), so replies go back out over RCS. Plain SMS threads keep their phone-number thread ids even when the number belongs to a Messaging Service, and `openDM` prefers `phoneNumber` over `messagingServiceSid` and `rcsSenderId`, so enabling RCS does not change the thread ids of existing conversations.

Button taps from templates that Chat SDK did not send (for example Studio flows or pre-created WhatsApp templates) are not turned into actions: when they carry a message body they arrive as regular messages, matching the adapter's behavior before RCS support.

### Handling button taps

```typescript title="lib/bot.ts" lineNumbers
bot.onAction(async (action) => {
  if (action.actionId === "approve") {
    await action.thread.post(`Approved: ${action.value}`);
  }
});
```

### Sending rich cards

```typescript title="send-card.ts" lineNumbers
import { Actions, Button, Card, CardText } from "chat";

await thread.post({
  card: Card({
    title: "Deploy v1.2.3",
    children: [
      CardText("Ready to deploy to production?"),
      Actions([
        Button({ id: "approve", label: "Approve", value: "v1.2.3" }),
        Button({ id: "reject", label: "Reject" }),
      ]),
    ],
  }),
});
```

Over RCS, this renders as a rich card with tappable buttons. Over SMS, it falls back to plain text.

## Media

Inbound MMS media is exposed as message attachments. Twilio media URLs are not treated as public files, so each attachment includes `fetchData()` and `fetchMetadata` for authenticated downloads and queue rehydration.

Outbound media supports attachments that already have a public `url`. Chat SDK cannot upload arbitrary binary files to Twilio for you because the Messages API expects each `MediaUrl` to be reachable by Twilio.

```typescript title="send-photo.ts" lineNumbers
await thread.post({
  markdown: "photo attached",
  attachments: [
    {
      type: "image",
      url: "https://example.com/photo.jpg",
    },
  ],
});
```

## Advanced

### Messaging services

When a thread sender starts with `MG`, outbound messages use `MessagingServiceSid` instead of `From`:

```typescript title="send-with-service.ts" lineNumbers
const threadId = twilio.encodeThreadId({
  sender: "MGXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  recipient: "+15555550100",
});

await bot.adapters.twilio.postMessage(threadId, "hello");
```

### Low-level helpers

The package includes runtime-light subpaths for apps that only need Twilio primitives:

```typescript title="twilio-primitives.ts" lineNumbers
import { sendTwilioMessage } from "@chat-adapter/twilio/api";
import { truncateTwilioText } from "@chat-adapter/twilio/format";
import { gatherSpeechTwilioResponse } from "@chat-adapter/twilio/voice";
import { readTwilioWebhook } from "@chat-adapter/twilio/webhook";
```

These subpaths do not import the full Chat SDK adapter or the `twilio` npm package.

### Voice helpers

Twilio voice calls are exposed as low-level primitives, not routed through the SMS/MMS adapter. Use them when your app owns the voice route and wants reusable TwiML or call-update helpers:

```typescript title="app/api/webhooks/twilio/voice/route.ts" lineNumbers
import {
  gatherSpeechTwilioResponse,
  parseTwilioVoiceCall,
} from "@chat-adapter/twilio/voice";
import { verifyTwilioRequest } from "@chat-adapter/twilio/webhook";

export async function POST(request: Request): Promise<Response> {
  const verified = await verifyTwilioRequest(request);
  const call = parseTwilioVoiceCall(verified.params);

  if (!call) {
    return new Response("Invalid voice webhook", { status: 400 });
  }

  return gatherSpeechTwilioResponse({
    actionUrl: "https://your-domain.com/api/webhooks/twilio/voice/result",
    prompt: "How can I help?",
  });
}
```

Custom voice routes should verify the Twilio signature and apply your own caller allow-list before returning TwiML.

For live calls, `updateTwilioCall()` in `@chat-adapter/twilio/api` can post replacement TwiML or redirect the call to another URL.

### Notes

* Twilio does not support message edits, reactions, modals, or typing indicators for SMS.
* Cards render as rich RCS content when the sender is a Messaging Service (`MG…`) or RCS address; otherwise they fall back to plain text.
* RCS read receipts (`EventType=READ`) are parsed and logged but not surfaced to Chat SDK handlers (no delivery API exists today).
* `fetchMessages` uses the Messages API and is best for phone-number based threads. Messaging Service history can be less precise because inbound webhooks identify the receiving phone number, not only the Messaging Service SID.
* Content templates are created on demand and reused by a stable name derived from the card's content, so identical cards share one template across restarts. Cards that embed changing values (timestamps, order ids, user names) produce a new template per unique body, and Twilio keeps Content resources until you delete them. Keep card bodies stable, or pre-create templates, for high-volume use.

## Feature support

<FeatureSupport />
