Route texts by NUMBER, and add Telnyx on both sides of it

FeatureVOIPService
Shipped
September 6, 2026 at 8:36 PM UTC
Author
Kamo
Commit
482c565

An organization can buy its DIDs and its texting from one company and answer the calls with another. Kamo could not express that: a member's texting was a property of their PHONE SERVER, so an org whose numbers came from Telnyx and whose calls were answered by their own FreePBX could not text at all — FreePBX declares sms(false), the gateway refused before any provider was reached, and the advice "add an SMS provider" led to a rail that could not open a conversation. So the question moved from the provider to the number. ORG_PHONE_NUMBER says who owns a number, what answers its calls and what carries its texts. TEXT_ROUTE_KIND sits beside the instance id rather than being inferred from it, because the two candidate tables are both UUID-keyed and handing an id to the wrong factory does not fail loudly — it fails as "no such instance", and the text is never sent. InboundRail already documented that hazard. TextRouter resolves in three tiers: the number's own route, then a default named by the phone server (so 200 DIDs on one PBX is one setting, not 200 rows), then the phone server itself — which is exactly today's behaviour, so an org that never opens the Numbers screen keeps working having configured nothing. The bulk rail finally inherits the send contract. TextSender has two adapters and SmsGateway talks to it, so BulkTextProvider.sendText — whose errorMessage is always some carrier's e.getMessage() — can no longer put a SocketTimeoutException in front of somebody trying to text a customer. And BulkTextInboundController now hands its messages to VoipMessageService.ingest instead of recording a keyword and stopping, so a reply on a carrier number becomes a conversation. Keyword handling did not move out; it moved DOWN, into the sequence every other rail already shares. VoipNumberOwnership gains SOURCE_ORG_NUMBER at highest precedence: the other two sources infer ownership from a column that happens to hold a number, while this one reads a row whose whole purpose is to say who owns it. Telnyx arrives as both a phone server and a carrier. As a phone server it is the first one honest about not being a PBX: no voicemail, no devices, no queues, no agent states — and every one of those false flags now carries a sentence a member can read, because a capability that is false with no written reason renders as a working-looking empty screen. That is what CapabilityLimit and PhoneCapability are for; SMS has had this treatment since smsUnavailableReason(), and the rest of the surface now has it too. Its softphone credential is minted with the ORG's own API key, so unlike RingCentral nobody has to onboard each member into a carrier portal. Two things worth knowing: - OrgPhoneNumberService takes PhoneProviderFactory @Lazy. The cycle is real and legitimate, and Spring Boot has forbidden circular references by default since 2.6 — without it the context does not start, and no unit test here would catch that, because they all wire their Docstors by hand. - A text-only org (numbers and a carrier, no phone server) can send but cannot thread a reply: VOIP_CONVERSATIONS.INSTANCE_ID is NOT NULL and part of the dedupe index. That drop is now an ERROR naming the number instead of a warn. ORG_PHONE_NUMBER's DDL is applied by hand — docs/sql/. InitializerService is not deployed in this cluster.

All changes

Like what you see shipping?

Every one of these updates lands in your workspace automatically. Start free and watch it grow week after week.

Start Free ForeverView Pricing