Appearance
SMS
SMS is a route provider like telegram and webhook: a route's target is an E.164 number, and the backend for that number delivers it. It costs money per message and anyone can text a number, so it is stricter than the other providers.
Rendering diagram…
Backends
Two are supported, and one server can use both:
- Telnyx, through the Messages API v2.
+1is all of NANP, so Canada and the Caribbean go here too. - Twilio, which works worldwide.
A backend is available once its credentials are set (see the configuration table). Each number is routed like this:
- The longest prefix in
SMS_PROVIDER_BY_PREFIXthat matches the number picks the backend. The default is+1=telnyx. An entry from that built-in default counts only when its backend is configured. An entry you set yourself must be configured, or startup fails. - Otherwise the number goes to
SMS_PROVIDER, if it is set. - Otherwise the number is refused. Creating a route to it returns
409, and the error names what to set. A delivery to it becomes a dead letter.
A deployment that sets only SMS_PROVIDER=twilio sends everything through Twilio. Adding Telnyx credentials moves only +1 to Telnyx. The startup log prints the routing it resolved, e.g. routing="+1=telnyx(not configured) *=twilio".
US registration
US carriers filter application-to-person SMS from unregistered senders. Before a US number delivers reliably, it needs either a registered 10DLC brand and campaign (for a local long code) or a verified toll-free number. Toll-free verification covers only toll-free (8XX) numbers; a local number can only be sent from through 10DLC. For an operator outside the US, a verified toll-free number is usually the easier path: one registration, no brand or campaign.
Telnyx reports unregistered traffic as 40010 (no 10DLC campaign) or 40329/40330 (toll-free not verified). Hooka turns those codes, and STOP opt-outs (40300), into dead letters that name the fix and are never retried. The same codes can arrive later in a delivery report; Hooka then logs telnyx sms delivery failed with a hint.
What is sent
One plain-text message: the title, a blank line, and the body. An HTML body (Telegram tags, or raw_text) is flattened: tags removed, entities decoded, <a href> kept as label (url). format: raw bodies stay literal. Inline keyboard buttons with a url become label: url lines at the end; callback buttons, rich_message blocks, and other Telegram-only features are dropped (text is the fallback).
The body is cut to fit 1600 characters (Twilio's cap). On Telnyx it is cut to 10 billed segments (Telnyx refuses more): about 1520 characters of GSM-7 text, or about 660 once any character falls outside GSM-7 (an emoji, a curly quote, most non-Latin scripts). A cut message ends in ...[truncated], and the title and links survive it. POST /publish/{topic}?dry_run=true shows the result as rendered.sms_text, cut to the strictest backend among the destinations.
Spending guards
Every SMS (route deliveries and replies to inbound commands) passes a per-number token bucket (SMS_BURST, then SMS_RATE_PER_HOUR) and a global SMS_DAILY_CAP per UTC day. Over either limit the delivery fails at once and becomes a dead letter to replay later; it never waits.
Retries are narrower than for webhooks because a duplicate costs money: only 429 and 5xx answers are retried. A timeout or dropped connection may come after the provider accepted the message, so it becomes a dead letter instead of a second send. Each attempt that reaches the provider counts against the daily cap unless the provider answered 4xx. The cap counts messages, not billed segments, so size it with long messages in mind, and restrict Twilio's Messaging Geographic Permissions (or the Telnyx messaging profile's allowed destinations) to the countries you text.
The guards are global: one bucket per number and one daily cap across all backends, kept in process memory (the daily count is seeded from today's deliveries after a restart), so they assume one replica. sent means the provider accepted the message. The carrier result arrives later and is not tracked, except that a failed Telnyx delivery report is logged.
Who can be texted
Only numbers with an sms route, and only the master key can add or remove one: a route is a standing permission to text that number, and scoped tokens (every app's token can publish to its own out topic) must not grant it to themselves. A publish fans out to the topic's sms routes like any other route. A chat_id of sms:+12125550123 (what app replies use) reaches that number only when the topic has an sms route to it, and is refused with 403 otherwise; /send can never reach SMS. Dead-letter replay re-checks the route. Anonymous publishing (anonymous_publish, ingest URLs) is refused on topics with sms routes, as it is for webhook routes. Photos, documents, and voice notes to an sms: chat are a 400, and /send-chat-action is a no-op 200.
Inbound
Two webhooks receive texts, each verified before anything else happens:
- Twilio: point the number's "A message comes in" webhook at
POST {SMS_PUBLIC_BASE_URL}/sms/twilio(HTTP POST). Hooka verifiesX-Twilio-SignaturewithTWILIO_AUTH_TOKENover that URL and the sorted form parameters (403on mismatch). - Telnyx: set the messaging profile's webhook URL to
{SMS_PUBLIC_BASE_URL}/sms/telnyx. Hooka verifiestelnyx-signature-ed25519over<telnyx-timestamp>|<raw body>withTELNYX_PUBLIC_KEY. It answers403when a header is missing, the signature does not verify, or the timestamp is more than 5 minutes old or more than 1 minute in the future. Onlymessage.receivedis handled; every other event (such as the status reports Hooka's own messages ask for) gets a200and is only logged. A webhook URL is per messaging profile: if the number's profile points at another service, inbound texts keep going there and Hooka only sends from that number.
A verified text is then handled the same way from either provider:
- It is ignored, without a reply, unless the sender is in
SMS_INBOUND_ALLOWLISTor already has an sms route on some topic. /myapp what is new, ormyapp what is newwhenmyappis an app installed for that number, is an app command (see Apps over SMS).- Anything else is stored on
SMS_INBOUND_TOPICwith the payload{"kind":"sms_inbound","from":"+1…","to":"+1…","text":"…","provider":"twilio","message_sid":"SM…"}(provideristelnyxandmessage_sidthe Telnyx message id for a text to a Telnyx number). Like Telegram inbound capture, it is stored for SSE and history readers, not fanned out to the topic's routes.
Hooka's own answers (help, "not installed", "not running") go through the backend for the sender's number, not necessarily the number they texted.
Providers retry webhooks, and Telnyx says to expect duplicates. Each text is handled once per provider message id (MessageSid, or the Telnyx message id). The id is recorded only after handling succeeds, and the record is kept for IDEMPOTENCY_RETENTION_DAYS. A redelivery of a handled text gets 200 and nothing else. A redelivery that arrives while the first is still being handled gets 503, so the provider comes back once the result is known (Telnyx retries any webhook not answered within 2 seconds, and a reply SMS can take longer). A failed handling gets 500 and is redone in full on the retry. Logs show only the last 4 digits of a number.
Going live
Telnyx: In Mission Control:
- Create an API key and choose the sending number.
- Make sure the number is on a messaging profile that allows your destinations.
- Register it (toll-free verification or a 10DLC brand and campaign; see US registration). Until then, expect
40010or40329, usually in atelnyx sms delivery failedlog line after the publish already reportedsent. - Copy the account's public key (Account Settings → Keys & Credentials → Public Key).
- Set
TELNYX_API_KEY,TELNYX_FROM(and optionallyTELNYX_MESSAGING_PROFILE_ID), andTELNYX_PUBLIC_KEYtogether. SetSMS_PUBLIC_BASE_URLtoo, unlessHOOKA_PUBLIC_URLorHOOKA_URLalready gives the API origin.
With a public base URL set, each message sends its status reports to
{SMS_PUBLIC_BASE_URL}/sms/telnyxwithuse_profile_webhooks: false, so a profile shared with another service sees none of Hooka's reports. To receive texts, set a Hooka-owned profile's webhook URL to the same address.Twilio: set
TWILIO_ACCOUNT_SID, eitherTWILIO_AUTH_TOKENor an API key pair (TWILIO_API_KEY_SID/TWILIO_API_KEY_SECRET), andTWILIO_FROMorTWILIO_MESSAGING_SERVICE_SID. Inbound needsTWILIO_AUTH_TOKENandSMS_PUBLIC_BASE_URLmatching the URL in the Twilio console exactly.Then add the sms routes (
409names anything still missing), and check the startup log'srouting=.
Metrics: hooka_provider_deliveries_{sent,failed}_total{provider="sms"} and hooka_sms_inbound_total{outcome="accepted|rejected|ignored"}.