> ## Documentation Index
> Fetch the complete documentation index at: https://docs.orbit.devotel.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Sender pool selection — how a pool picks one sender and stays healthy

> What a sender pool is and how it behaves: the four selection strategies (sticky, round_robin, random, geomatch) with their exact fail-safes, the per-member health ledger and the rotation scheduler's auto-swap, the SENDER_POOL_EMPTY failure class, member lifecycle, and a worked example.

# Sender pool selection

A sender pool is a named set of member senders — owned numbers, short codes, or alphanumeric sender IDs — with one persisted selection strategy and one health ledger per member. For each message you point at it with `sender_pool_id`, the pool picks exactly one member as the `from`. Which construct to build (pool, messaging service, or inline sender) is decided on [Choosing a sender construct](/concepts/sender-identity-decision-model); where a pool id sits in the full precedence chain against `from`, services, and fallbacks is owned by [Sender resolution](/concepts/sender-resolution). This page is the missing middle: how the pool itself behaves once traffic hits it.

## Where this fits

* **This page (the pool entity)** — what a pool member set is, exactly how each strategy picks, what the health model does to membership, what failure classes fire, and how pool lifecycle events (member add/remove, delete) interact with in-flight selection state. Read this when the question is "I have a pool; how does it actually pick, and how does it keep picking senders that deliver."
* **[Sender resolution](/concepts/sender-resolution)** — where `sender_pool_id` wins over a bare `from`, where per-country pool overrides sit in a messaging service, and the 3-step fallback chain. This page defers to it for precedence rather than re-telling the chain.
* **The API reference and guide** — the [Sender Pools endpoint reference](/api-reference/endpoints/sender-pools) owns request/response schemas; the [Sender pools guide](/guides/sender-pools) walks through creation and preview. This page explains behaviour, so it duplicates neither.

## 1. What a pool is

Three facts define the entity:

* **A named member set.** Between 1 and 50 sender identities — E.164 long codes, numeric short codes, alphanumeric sender IDs, or a mix. Duplicate entries are rejected on write; the dashboard pool editor also excludes senders another pool already owns, so a sender rotates in one pool at a time.
* **One persisted strategy.** `sticky` (default), `round_robin`, `random`, or `geomatch`. The strategy is the pool's own property — change it with `PATCH` and every subsequent send through the pool picks by the new rule, without touching any send code.
* **One health ledger per member.** Tenant-owned DIDs accumulate a reputation score from delivery outcomes (a daily scheduler recomputes it from DLRs); the pool reads this for auto-rotation, below.

Between a send request and the wire, the pool sits after the precedence chain resolves `sender_pool_id` to this pool and before the message gets a concrete `from`. The pick it returns is then the identity the rest of the pipeline treats as yours.

## 2. The four strategies, with exact semantics and fail-safes

| Strategy           | Pick semantics                                                                                                                                                                       | Fail-safe — when its own mechanism cannot pick                                                                                                            |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `sticky` (default) | Deterministic hash of the recipient over the member set; the assignment is persisted per-recipient so a repeat send returns the same sender                                          | Stale assignment (the stored sender was removed from the pool) heals on the next send: the pool re-picks via the hash and rewrites the row                |
| `round_robin`      | Cycles through the member set in order via a shared counter                                                                                                                          | When the counter is briefly unavailable, falls back to a deterministic hash pick — the send never blocks                                                  |
| `random`           | Uniform random pick, no state                                                                                                                                                        | Stateless by design — nothing to fall back from                                                                                                           |
| `geomatch`         | Resolves the recipient's country from the E.164 prefix, filters members to same-country senders, then picks deterministically inside that subset (sticky semantics within the match) | Zero same-country members, or an unparseable recipient country → falls back to `sticky` over the whole pool — the send never fails on unmatched geography |

Three rules worth internalizing:

* **Deterministic within a subset.** Both `sticky` and `geomatch`'s match-branch use the same deterministic hash pick, so "same recipient → same sender" holds inside whatever subset the filter produced. A conversation stays on one local number for geo-matched traffic, not just on any one of all members.
* **Alphanumeric members are geomatch-hidden.** A short code or alphanumeric sender ID has no resolvable country, so it never appears in a `geomatch` match set — it is only ever picked via the whole-pool fallback when no E.164 member matches. Keep alphanumeric members in a dedicated pool (or accept them as last-resort senders) in corridors where they apply.
* **One overrides the stored strategy.** A parent messaging service with `area_code_geomatch: true` forces `geomatch` on the pool regardless of the pool's own stored `sticky` / `round_robin` / `random`. Same precedence rule as the wire behaviour — service flag beats pool default — stated once here because it changes how your pool actually picks under a service. Full service semantics live in [Messaging services](/concepts/messaging-services-model).

## 3. The member health model

Health is the pool's closed loop with actual delivery:

1. **Per-member accrual.** Each tenant-owned DID member's health tier (`excellent`, `good`, `fair`, `poor`, `critical`) recomputes daily from a rolling 30-day DLR window. Unknown reputation (never-sent, or a too-thin sample) is deliberately *not* a swap signal. [Sender warming and reputation](/concepts/sender-warming-and-reputation) owns the accrual kernel — this page owns only how the pool reacts to it.
2. **The rotation scheduler.** A 6-hour autopilot reconciles pool membership against health: a member that dropped to `poor` or `critical` is quarantined out of the set and, when a warmed non-degraded replacement exists, substituted one-for-one.
3. **Safety rails on the swap.** The planner never empties a pool — a degraded member is only removed when at least one member remains, or kept if dropping it would empty the pool. One replacement member is consumed at most once per tick across all of a tenant's pools, so two pools cannot both claim the same warmed number.
4. **Distribution, not just health.** Beyond swapping, the same tick flags a pool that is too concentrated for its projected daily volume (risks per-member throttling) by expanding it with warmed members, and *surfaces* a too-spread pool (a snowshoe-risk pattern) for an operator to consolidate — auto-consolidation is deliberately not done, because removing live sending capacity is operator intent.

The distinguishable pair to keep straight: **warming** accrues reputation on a single sender over time (that page); **the rotation** consumes already-accrued reputation to move members in and out of a pool (this page). At send time, a send-time gate on inventory-bound pools also re-picks away from a quarantined first pick when a healthy member remains — sending from a degraded number only when no healthy option exists, and never failing the send on reputation alone.

## 4. Failure classes: empty pool and no healthy members

The one hard failure a pool can produce is **`SENDER_POOL_EMPTY` (422)**: the pool exists but has zero member senders. (The country filter can never empty a pool — `geomatch` falls back to `sticky` over the whole member set instead, per section 2.) The two shapes of the failure:

* **Empty pool at send time.** `sender_dids` is empty (no members ever added, or all members removed). Sends through the pool fail fast with `422 SENDER_POOL_EMPTY`.
* **Pool miss.** A deleted pool id, or a pool in another tenant, returns `404 SENDER_POOL_NOT_FOUND` — same class, different cause.

What the platform does next: **nothing implicit.** An empty pool does not silently fall through to the org default or platform fallback chain — the send rejects with the 422 so you see the mis-route instead of a silent substitution into the 3-step fallback. Restock the pool (add at least one DID), and retry; the same send then picks a member fine. The full symptom-cause-fix walkthrough lives in [Fix an empty sender pool](/troubleshooting/sender-pool-empty).

"No healthy members" is **not** an empty pool and does **not** raise `SENDER_POOL_EMPTY`: a pool whose every member is degraded still resolves — sending from a degraded member beats failing the send. `fully_degraded` on the health endpoint is the operator-visible warning that the pool has no healthy member to prefer.

## 5. Lifecycle: create → members → preview → health drain → delete

* **Create** — `POST /api/v1/messaging/sender-pools` with a label, 1–50 member sender ids, and an optional strategy (default `sticky`). Owner/admin only.
* **Add/remove members** — `PATCH` replaces the member list. Removing a member that held in-flight sticky assignments does not strand anyone: the next send through the pool detects the stale assignment (the stored sender is no longer in the member set), re-picks deterministically, and rewrites the row. The replaced list propagates to the send path within \~10s (the pool row is cached briefly).
* **Preview** — `GET .../sender-pools/{id}/preview?recipient=<E.164>` answers "which sender would this recipient get" without committing anything: no sticky row written, no round-robin counter advanced, no production routing mutated. Use it to verify exactly which sender a recipient would get before wiring the pool into a campaign; the dashboard pool editor exposes the same dry run visually.
* **Health drain** — `GET .../sender-pools/{id}/health` rolls up per-member tiers and counts (`at_risk`, `fully_degraded`); the dashboard rotation-alerts panel renders the same roll-up.
* **Delete** — `DELETE .../sender-pools/{id}` removes the pool and its sticky-assignment rows. New sends referencing the id start failing `404 SENDER_POOL_NOT_FOUND` from the next request; messages already sent are unaffected. Re-map usages first if they exist.

## 6. Worked example — pool plus per-country override, with a preview

A messaging service `ms_ops01` carries `sender_pool_id: pool_default` (a `sticky` pool of US numbers) and a `country_sender_pools` map `{ "GB": "pool_gb" }` (`pool_gb` is `geomatch`, holding two UK numbers plus one DE number). A send to a GB recipient comes in with no explicit sender field:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_live_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+447700900123",
    "messaging_service_id": "ms_ops01",
    "body": "Your reference update"
  }'
```

The per-country map fires first (`GB` entry beats the service default pool), `pool_gb` resolves via `geomatch`, and the pick lands on one of the UK numbers — the same chain walked end-to-end on [Sender resolution](/concepts/sender-resolution#6-worked-example--one-send-through-the-whole-chain); here the focus is the pool step.

Precheck the pick without sending:

```bash theme={null}
curl "https://api.orbit.devotel.io/api/v1/messaging/sender-pools/pool_gb/preview?recipient=%2B447700900123" \
  -H "X-API-Key: dv_live_sk_your_key_here"
```

```json theme={null}
{
  "data": {
    "pool_id": "pool_gb",
    "recipient": "+447700900123",
    "assigned_sender_did": "+447700900456",
    "strategy": "geomatch"
  }
}
```

The preview never commits: the same recipient on a real send returns the same member only because the pick is deterministic within the GB-matched subset, not because the preview wrote anything.

## See also

* [Sender resolution](/concepts/sender-resolution) — the full outbound precedence chain this pool plugs into, including `sender_pool_id` vs `from` and the 3-step fallback
* [Choosing a sender construct](/concepts/sender-identity-decision-model) — decide between pool, messaging service, and inline sender
* [Messaging services](/concepts/messaging-services-model) — the defaults + per-country override map that target pools
* [Sender pools guide](/guides/sender-pools) — create pools, preview picks, read health end-to-end
* [Sender Pools API](/api-reference/endpoints/sender-pools) — endpoint reference and error surfaces
* [Sender warming and reputation](/concepts/sender-warming-and-reputation) — the reputation accrual kernel the rotation scheduler consumes
* [Fix an empty sender pool](/troubleshooting/sender-pool-empty) — resolve a `SENDER_POOL_EMPTY`
