Providers
Twilio
Uses the Ahena CLI (beta). Install it with npm install -g @ahena/cli, or prefix commands with npx @ahena/cli. See Installation. The dashboard covers projects, connections, Doctor, the Stack Graph, plans and approvals without it.
Overview
Ahena checks that your Twilio account can actually text your users (not a trial, not suspended,
not test credentials), creates and configures the messaging service your app sends through,
adds numbers you already own to its sender pool, points its inbound and delivery-status webhooks
at your route, and reports A2P 10DLC registration for US long codes. It generates a small
provider-neutral ahena.sms adapter on the official twilio SDK. Your app sends straight to
Twilio with its own API key. Ahena is never in the sending path, never sends a message, and never
reads message bodies or message logs.
Package: @ahena/provider-twilio · Category: sms
Capabilities: account, messaging-services, sender-pool, phone-numbers, webhooks, a2p-10dlc
Maturity: verified against a fake of the API; not yet verified against the real service.
Connection
| Field | Secret | Used for |
|---|---|---|
TWILIO_API_KEY_SID |
yes | The API key SID (SK…), HTTP Basic user name. |
TWILIO_API_KEY_SECRET |
yes | The API key secret (shown once at creation), HTTP Basic password. |
TWILIO_ACCOUNT_SID |
no | The Account SID (AC…) the key belongs to; needed in every api.twilio.com path. |
TWILIO_API_KEY_SID=… TWILIO_API_KEY_SECRET=… ahena connect twilio -e production --set TWILIO_ACCOUNT_SID=AC…
Ahena calls api.twilio.com (2010-04-01 API: the account and its phone numbers),
messaging.twilio.com (v1: messaging services, sender pools, A2P campaigns and brands) and
iam.twilio.com (v1: the key's own policy). Nothing else is reachable with the key.
An Account SID with its Auth Token in the two key fields also works (it's the account's master credential, so Doctor warns). Test credentials (test Account SID + test Auth Token) are refused: Twilio answers error 20008 to every read, so Ahena could check nothing, and they never deliver.
Permissions
Use a Standard or Restricted API key made for Ahena (Twilio Console → Account → API keys &
tokens). Ahena reports permissionCheck: checked, and here is exactly how:
- Reads are probed with one-item
GETs: the services list, the account's phone numbers, the Account resource, and (when a service exists) one service's phone numbers and A2P campaigns. Answered = granted, refused = missing. - Writes are read from the key's own policy (
GET iam.twilio.com/v1/Keys/{sid}). A Main key's policy isnull(full access); a Restricted key's policy is its allow-list. Standard keys can't read Keys (or the Account resource), so for them the write permissions are reported…:unknown. Ahena never attempts a write to find out.
| Permission (Twilio's name) | Kind | Why |
|---|---|---|
twilio/messaging/services/list and twilio/messaging/services/read |
read | Find the messaging service. Required. |
twilio/messaging/services.phonenumbers/list |
read | Sender pool. |
twilio/messaging/services.usa2p-campaign/list |
read | A2P 10DLC campaign status. |
twilio/phone-numbers/active-numbers/list |
read | Numbers the account owns (for adding to the pool). |
/twilio/iam/accounts/read |
read, optional | Trial vs upgraded, active vs suspended. Without it Doctor says "Not verified". |
/twilio/iam/api-keys/read |
read, optional | Lets Ahena read the key's own policy, so write permissions are checked instead of unknown. |
twilio/messaging/services/create |
write | Create the messaging service. |
twilio/messaging/services/update |
write | Set its webhooks. |
twilio/messaging/services.phonenumbers/create |
write | Add an owned number to the pool. |
Optional reads Ahena uses when allowed: twilio/messaging/services.shortcodes/list,
twilio/messaging/services.alphasenders/list. Never grant twilio/messaging/messages/*,
twilio/phone-numbers/active-numbers/create (buying) or …/delete: Ahena doesn't use them.
Ahena drops the account's auth_token from Account responses, hides user names, passwords and
query values in webhook URLs it shows (https://***@host/path?key=…), and drops A2P message
samples and descriptions.
Capabilities
| Capability | Access | Verification | Changes | Refuses |
|---|---|---|---|---|
account |
read-only | Upgrading a trial or changing billing; subaccounts | ||
messaging-services |
writable | read-back | CONFIRMATION_REQUIRED | Deleting or renaming services |
sender-pool |
writable | read-back | CONFIRMATION_REQUIRED | Removing senders; moving a number from another service; adding short codes or sender IDs |
phone-numbers |
manual | Buying (billable) or releasing numbers; changing a number's own webhooks | ||
webhooks |
writable | read-back | CONFIRMATION_REQUIRED | URLs that aren't public https or embed credentials; clearing a webhook |
a2p-10dlc |
manual | Registering brands or campaigns (billable; needs business details) |
ahena lock records the account's status and type, the service's webhook settings (redacted) and
its sender pool, so ahena drift sees a webhook or sender changed in the Console.
Configure
sms: {
provider: "twilio",
messagingService: "Leo notifications", // exact friendly name (created if absent) or "MG…"
from: { production: "+15551234567" }, // number(s) the account already owns
webhook: { production: { endpoint: "https://leo.example.com/api/webhooks/twilio" } },
usecase: "notifications", // optional; used only when creating the service
},
webhook.endpoint becomes both the service's inbound request URL (POST) and its status
callback; add statusCallback: "https://…" to send delivery statuses elsewhere. from may be a
string or a list, per environment.
| Change | Classification | Notes |
|---|---|---|
Create the messaging service (POST /v1/Services) |
CONFIRMATION_REQUIRED | Free. Created with the declared webhooks, InboundMethod=POST and UseInboundWebhookOnNumber=false. Matched by exact name, so never duplicated; two services with the same name are refused as ambiguous. |
Set the inbound request URL and status callback (POST /v1/Services/{sid}) |
CONFIRMATION_REQUIRED | Also in production. Only differing fields are sent. If the service defers to each number's own webhook, Ahena turns that off (said in the plan). |
Add a number to the sender pool (POST /v1/Services/{sid}/PhoneNumbers) |
CONFIRMATION_REQUIRED | Only a number the account already owns, that can send SMS, and that isn't in another service. No purchase. |
| Buy a phone number | MANUAL (refused) | Billable every month. Ahena tells you to buy it in the Console, then adds it. |
| A2P 10DLC brand and campaign | MANUAL | Billable and needs business details; Doctor gives the steps. |
Refused outright: releasing numbers, deleting services, removing senders, moving a number out of another service (error 21712 territory), sending messages, webhook URLs that aren't public https or that carry credentials.
Generate
ahena generate twilio [--framework nextjs]:
src/ahena/sms/index.ts: the provider-neutral interface every SMS provider generates, implemented withclient.messages.create:/** One text message. */ export interface SendSms { /** Recipient in E.164 format, e.g. "+15551234567". */ to: string; /** The message text. Long texts are split into segments by the provider (and billed per segment). */ body: string; /** Sender: an E.164 number or an alphanumeric sender ID. Defaults to the configured sender. */ from?: string; } /** What sms.send resolves to: the provider's id for the accepted message. */ export interface SentSms { id: string; } export interface SmsClient<Raw> { /** Sends one text message. Throws with the provider's message if it's rejected. */ send(message: SendSms): Promise<SentSms>; /** The provider's own SDK client, for anything not wrapped here. */ readonly raw: Raw; } export const sms: SmsClient<TwilioClient>;sendusesTWILIO_MESSAGING_SERVICE_SID(recommended) orSMS_FROM, and resolves to the message SID.src/ahena/providers/twilio.ts: the SDK client fromTWILIO_ACCOUNT_SID,TWILIO_API_KEY_SID,TWILIO_API_KEY_SECRET(the app's own key)..env.example.twilio.src/app/api/webhooks/twilio/route.ts(Next.js): verifiesX-Twilio-Signaturewith the SDK'stwilio.validateRequest(authToken, signature, url, params). Answers 403 when the signature is missing or wrong, 500 whenTWILIO_AUTH_TOKENisn't set, 204 for status callbacks and empty TwiML (200) for inbound messages. SetTWILIO_WEBHOOK_BASE_URLwhen a proxy changes the URL your app sees, because Twilio signs the public URL.
The webhook secret is the Auth Token. Twilio signs webhooks with the account's Auth Token
(HMAC-SHA1 of the URL and POST parameters), not with an API key secret. Ahena doesn't hold the
Auth Token and never reads it. If you declare a webhook, Doctor checks that TWILIO_AUTH_TOKEN is
stored in the environment (by name only); store it yourself with
ahena env set <environment> TWILIO_AUTH_TOKEN (copy it from Console → Account → API keys & tokens).
Limitations
- Ahena doesn't buy, release or configure phone numbers themselves, register A2P 10DLC brands or campaigns, verify toll-free numbers, or manage short codes and alphanumeric sender IDs (it lists them).
- Toll-free verification status isn't read; Doctor reports US toll-free senders as "Not verified".
- With a Standard key, the account's type and status and the key's write permissions can't be read (Twilio doesn't allow Standard keys on Accounts or Keys); Doctor says "Not verified".
- A2P brand status (
/v1/a2p/BrandRegistrations) isn't in Twilio's Restricted-key permission tables; with a Restricted key it may be unreadable, and Doctor falls back to the campaign status. - Only Twilio's default (US1) API hosts; regional edges aren't supported.
- A sender number's own SMS webhook (
sms_url) is shown but never changed. The service's inbound URL wins while the service doesn't defer to numbers. - Not yet run against the real Twilio API. Taken from the docs, not observed: the HTTP status that accompanies error 70051 for a Restricted key missing a permission (the fake uses 401), whether a suspended account can still read its Account resource, and the exact status for errors 21710/21712 (the fake uses 409).
Manual steps
- Production on a trial account: upgrade it (Console → Upgrade; Admin → Account billing).
- A number Ahena should add but the account doesn't own: buy or port it in the Console
(Phone Numbers → Manage → Buy a number), then
ahena configure twilio. - US long codes: register an A2P 10DLC brand and campaign for the messaging service (Console → Messaging → Regulatory Compliance → Onboarding). Both are billed.
- US toll-free numbers: submit toll-free verification in the Console.
- Webhook route: store the Auth Token with
ahena env set <environment> TWILIO_AUTH_TOKENand in your app's environment.
Doctor checks
| Id | Severity | Meaning |
|---|---|---|
twilio.credentials |
PASS / FAIL | The credentials work, and which kind (Main, Restricted, API key, Auth Token). |
twilio.credentials.test |
FAIL / WARNING | Test credentials (error 20008): FAIL in production, WARNING elsewhere. Nothing else is checked. |
twilio.credentials.kind |
WARNING / INFO | Connected with the Auth Token instead of an API key. |
twilio.account |
PASS / INFO / FAIL | Active; suspended or closed (FAIL, manual); unreadable with this key (INFO, "Not verified"). |
twilio.account.type |
PASS / INFO / FAIL | Trial in production FAILs with manual upgrade steps; trial elsewhere is INFO. |
twilio.key.permissions |
PASS / INFO / WARNING | The key can create and update the service and add numbers; missing (WARNING, which permissions to add) or unknown (INFO). |
twilio.service |
PASS / FAIL | The declared messaging service exists (missing: CONFIRMATION_REQUIRED fix); duplicate names (MANUAL). |
twilio.service.undeclared |
INFO | No sms.messagingService; lists the services in Twilio. |
twilio.service.senders |
PASS / FAIL | The pool isn't empty and has an SMS-capable sender. |
twilio.service.sender[.<digits>] |
PASS / FAIL | Each sms.from number is in the pool; owned but missing is plannable, not owned is MANUAL (buying is billable). |
twilio.webhook.inbound |
PASS / WARNING / FAIL | The service's inbound request URL is the endpoint (POST); WARNING if it defers to each number's webhook. |
twilio.webhook.status |
PASS / WARNING | The service's status callback is the declared URL. |
twilio.webhook.endpoint |
PASS / WARNING / FAIL | One unsigned POST to your route: 401/403 is right; 2xx means it doesn't verify X-Twilio-Signature; 404/405 offers a SAFE generate fix. |
twilio.a2p |
PASS / WARNING | US long codes in the pool have a VERIFIED A2P 10DLC campaign; otherwise WARNING with MANUAL steps (in review, failed or missing). |
twilio.tollfree |
INFO | US toll-free senders: verification isn't checked. |
Core also checks that TWILIO_AUTH_TOKEN is stored (by name) when a webhook is declared.
Disconnect behavior
Removes the connection and the key Ahena stored. Ahena doesn't delete API keys: delete Ahena's key in Console → Account → API keys & tokens (if your app uses the same key, it stops sending, which is why Ahena should have its own). Messaging services, sender pools, numbers, webhooks and A2P registrations stay in Twilio.
Set up step by step
Create the key. Twilio Console → Account → API keys & tokens → Create API key. Name it "Ahena", choose Standard or Restricted, and copy the SID (
SK…) and the secret (shown once). Copy the Account SID (AC…) from the Console home page.Minimum permissions. For a Restricted key, allow the reads and writes in the Permissions table above (add
/twilio/iam/api-keys/readso Ahena can check write permissions, and/twilio/iam/accounts/readso it can tell a trial account). A Standard key works too; Doctor then reports the account type and write access as "Not verified". Give your app a different key.Connect.
TWILIO_API_KEY_SID=… TWILIO_API_KEY_SECRET=… ahena connect twilio -e development --set TWILIO_ACCOUNT_SID=AC… TWILIO_API_KEY_SID=… TWILIO_API_KEY_SECRET=… ahena connect twilio -e production --set TWILIO_ACCOUNT_SID=AC…Pipe the values or type them at the hidden prompt; never pass them as arguments.
Verify the connection.
ahena inspect twilio -e productionlists the account, messaging services with their senders, webhooks and A2P status, and the account's numbers (no key, no Auth Token, no message content).Run Doctor.
ahena doctor -e production.Plan. Add the
smssection above toahena.config.ts, thenahena plan -e production(orahena configure twilio -e productionto plan and apply one provider).Apply.
ahena apply -e production. Creating the service, setting its webhooks and adding an owned number each need confirmation. Nothing billable is ever planned.Verify.
ahena plan -e productionshows no Twilio changes andahena doctor -e productionpasses. Store the Auth Token for the route withahena env set production TWILIO_AUTH_TOKEN, runahena generate twilio -e production --framework nextjs, set the variables from.env.example.twilio(the service SID is inahena inspect twilio -e production) and deploy.Troubleshooting.
Symptom Cause Fix "Twilio rejected the API key" Wrong or deleted key, secret mistyped, or the account is suspended Create a new key and reconnect. "The API key belongs to a different account" TWILIO_ACCOUNT_SIDis another account or subaccountReconnect with the key's own account SID. "These are Twilio test credentials" Test Account SID / test Auth Token Create a live API key and reconnect. "This API key can't list messaging services" Restricted key without twilio/messaging/services/listAdd it (and services/read) to the key.Apply fails naming twilio/messaging/services/createRestricted key without write permissions Add the write permissions from the table. "isn't a phone number this Twilio account owns" sms.fromnot bought or portedBuy it in the Console (billable), then plan again. "is in messaging service …" A number can be in one service only Remove it from the other service's Sender Pool in the Console. twilio.account.typeFAILTrial account in production Upgrade the account. twilio.a2pWARNINGUS long codes without a verified A2P 10DLC campaign Register a brand and campaign (billed), wait for review. Route returns 403 to real Twilio requests TWILIO_AUTH_TOKENwrong, or the app sees a different URL than Twilio signedUse the account's Auth Token; set TWILIO_WEBHOOK_BASE_URLto your public origin.Disconnect and revoke.
ahena disconnect twilio -e production, then delete Ahena's API key in Console → Account → API keys & tokens.