Skip to main content

Choosing a sender construct

Three different objects identify the sender of an outbound message — a sender pool, a messaging service’s default config, and an inline sender on the request body. On the inbound side, three different layers decide where a received message lands. Every one of them is correct somewhere; the hard part is choosing. This page is the decision model: it compares the constructs head-to-head, orders them, and says which one wins each kind of traffic. It defers every per-construct detail to its authoritative page rather than restating it.

Where this fits

  • This page (the decision) — which construct to pick for a pattern of traffic, and the precedence rules that decide what happens when you pass several at once. Read this before configuring anything; it is the entry point for sender setup.
  • Sender resolution — the authoritative outbound precedence chain in full depth: every pool strategy with its fail-safes, the per-country pool map, the fallback chain. Read that page when a specific send used an unexpected sender.
  • Messaging services — the service entity definition: what the bundle contains, when each default fills in, and the lifecycle.
  • How routing picks a sender — the compact two-sided reference: the outbound chain summary plus the inbound MO rule model in one place.
  • Inbound message routing — the tenant-level rule engine for received messages: match types, targets, priority ordering, failure tracking.

1. The three sender-identification surfaces

Sender pools. A pool holds member senders — DIDs, short codes, alphanumeric sender IDs — and picks exactly one per message by its strategy: sticky (a deterministic per-recipient pick, persisted so a recipient keeps one sender), round_robin, random, or geomatch (recipient-country match with a sticky fail-safe). Pool members accumulate a health tier from delivery outcomes, and the rotation scheduler swaps a degraded member for a warmed one, so rotation keeps preferring senders that deliver. The pool owns member-picking: given a set of identities, choose one. Everything about strategies, health, and fail-safes lives on Sender resolution. Messaging services. A service is a named bundle of defaults — a default sender pool, a per-country country_sender_pools override map, a status callback, a validity period, an inbound webhook, an opt-out list, a per-second throughput cap (mps_cap), and behaviour flags. It has no member-selection logic of its own; it points at pools, and the pool it lands on does the picking. The service owns defaults: it fills in only what the request left blank. The entity definition, the per-country map, and the throughput cap live on Messaging services. Inline senders. Two shapes, both passed on the request body and both beating every service default: a bare from (a number your tenant owns, a short code, or a registered alphanumeric sender ID) and from_extension (a UCaaS extension resolved to the DID on that extension). An inline sender owns this one send — no rotation, no defaults, no bundle. They are not the same object, and none of them is the inbound side. Received messages (MO) are routed by three separate layers — tenant-level inbound rules that match on number, sender, keyword, or regex and dispatch to a webhook, queue, inbox, team, or SMS menu; the messaging service’s own inbound webhook binding for its attached numbers; and the per-DID hook underneath both. Those layers are covered on Inbound message routing and How routing picks a sender; section 2’s decision tree says which one should own a given reply pattern.

2. The decision tree an operator actually runs

Work through these questions in order; the first yes wins.
  • Is this a UCaaS per-user send?from_extension. The extension’s DID is the sender. It cannot combine with a pool id — passing both returns 422.
  • Should this one send use exactly one fixed identity, no rotation? → inline from. It beats every service default and the fallback chain. Pass it alone: if you also pass sender_pool_id, the pool wins and your from is silently ignored.
  • Should traffic rotate across a fleet of senders?sender_pool_id on the request, or the pool a messaging service points at. Passing a pool id on the request also beats the service’s own pool config outright.
  • Do different countries need different sender fleets from one integration id? → a messaging service whose country_sender_pools maps each country to the pool maintained for that corridor. The per-country override beats the service’s default pool — but only when the request carries no sender_pool_id of its own. An explicit pool on the body skips the map entirely.
  • Do you need a bundle beyond the sender — status callback, validity period, inbound webhook, per-second send cap, opt-out list? → a messaging service. Even when its sender config is the least interesting part, one id gives you the rest. Remember the rule: a service is fill-in-only-on-blank — it never displaces anything the request set.
  • Is it a plain two-way reply on an existing conversation, with no opinion about the sender? → pass nothing. The conversation’s sticky sender pins the reply to the DID the contact originally texted.
Inbound side, three layers own where a reply lands; pick one per pattern:
  • Tenant-level inbound rules — the conditional layer. “Bodies containing appointment go to the scheduling webhook; messages to the VIP line go to the VIP inbox.” First-match-wins in priority order; exceptions at lower priority values than catch-alls.
  • The messaging service’s inbound webhook — the per-numbers address layer. Attach numbers to the service; everything arriving on them POSTs to inbound_webhook_url. This sits underneath the tenant rules — a matching rule is consulted before the service binding fires.
  • The per-DID hook / default path — the catch-all. When no rule matches and no service binding claims the number, the message persists to the inbox and fires the message.received event. Keep this layer for the fall-through; add rules and service bindings for the exceptions.

3. Channel guardrails that override the tree

  • WhatsApp has no pools. Meta owns the sender — it is your WABA phone number. A business-initiated message outside the 24-hour session window must use an approved template. Sender pools, from, and from_extension do not exist on this channel; the decision tree applies to SMS.
  • US and Canada corridors reject alphanumeric MT. A +1 destination can never be sent from an alphanumeric sender ID. Keep numeric-only pools for +1 entries in any per-country map, and expect the platform fallback to substitute a phone number for +1 recipients if nothing resolves earlier.
  • Two-way SMS replies pin to the original DID. When you reply on an existing conversation without naming a sender, the sender is the DID the contact texted. If your traffic depends on contacts texting the same identity back, a rotating default sender breaks that; use a sticky pool or reply on the conversation.

4. Failure shapes to plan for

  • 422 SENDER_POOL_EMPTY — the pool has no members. Add at least one DID or sender ID before routing traffic through it.
  • 422 on mutual exclusionfrom_extension with sender_pool_id is never both. Send exactly one sender construct per request.
  • Ambiguous-body precedencefrom + sender_pool_id is not an error; the pool wins silently. A send that names both does not fail — it surprises.
  • The +1 fallback substitution — with no explicit sender, a US/Canada recipient gets a platform phone number even when your org default is alphanumeric, because +1 carriers reject the shared alphanumeric sender. Pin an identity (from or pool) when the sender matters.
  • 404 MESSAGING_SERVICE_NOT_FOUND — a send referencing a deleted service id fails from the next request. There is no silent skip-a-service path.

5. When you should not use a pool or a service at all

  • Voice-only DIDs — a pool is an SMS construct provisioned for its members’ messaging capability. Numbers that exist for voice do not belong in one; pass the voice DID as an inline from only if it is genuinely SMS-capable.
  • One fixed sender forever — if the answer to the tree’s rotation question is durably “no rotation,” an inline from is the whole model. A one-member pool is a stillborn pool.
  • UCaaS extension traffic — per-user business SMS runs on from_extension; the pool/service layer is never in that path.

Worked example — US + GB order notifications

A tenant sends order updates to US and GB customers and wants replies threaded per country with sensible sender hygiene. Three candidate designs, chosen against the tree: Option A — inline from. The US traffic goes out from a single owned DID, GB from a single registered alphanumeric sender ID (where GB corridors accept alphanumeric). Simple and correct — until the fixed sender degrades or volume needs spreading, and there is nothing to fail over to. Option B — a sticky pool. Two pools: pool_us holding US DIDs, pool_gb holding the alphanumeric sender plus UK numbers, both sticky so each recipient keeps one sender. Rotation-ready and corridor-safe — but two pool ids to pass per request. Option C — a messaging service with country_sender_pools. One service ms_orders with country_sender_pools: { "GB": "pool_gb" } and default pool pool_us. Every send passes just messaging_service_id: "ms_orders": GB recipients take the GB override pool, US recipients fall to the default pool, and the service’s inbound webhook handles replies on the attached numbers. This is the chosen design — one integration id, per-country sender hygiene, and the rule from the tree: “different countries need different fleets from one integration id → service with a per-country map.” The same to a GB customer, one request through that service:
The map lookup resolves GB → pool_gb; pool_gb picks one member. A US recipient on the same body pattern instead takes the service’s default pool_us. Either way, naming a sender_pool_id on the body would skip the map entirely — the tree’s precedence rules are what keep the interpretation unambiguous.

See also