---
title: "How sending works"
description: "The three outcomes of sms.send(), the order a send runs in, and every default with the option that changes it."
---

Every send ends one of three ways, and SMS SDK treats each one differently. Learn these and the rest of the docs follow.

## Three outcomes

| Outcome | What happened | What `send()` does |
| --- | --- | --- |
| Accepted | The provider took the message and queued it. The phone may not have it yet. | Returns a result with the provider's message ID (`handoff: "accepted"`). |
| Rejected | The provider said no and did not create the message: bad credentials, an unregistered sender, an invalid number, an opted-out recipient. | Throws `ProviderRejectedError` (or a subclass). Sending again cannot cause a duplicate, so `retrySafe` is `true`. |
| Unknown | The request may have reached the provider, but the answer does not prove what happened: a timeout, a network error, a 5xx, a garbled success response. | The provider may have sent it, so the SDK stops and tells you (`HandoffUnknownError`, `retrySafe: false`). It never retries or tries another provider. |

There is no `{ ok: false }` result: `send()` returns only when the message was accepted. Whether the phone received it is a separate question, answered later by a [delivery webhook](/receiving/webhooks).

:::warning[Unknown outcomes are never retried]
On `HandoffUnknownError`, check the provider console or wait for a status webhook before you send again. Resending blindly can text the recipient twice. No option changes this.
:::

## The order a send runs in

1. **Local checks.** The recipient must be an E.164 number, the sender must suit the first adapter, and the adapter must support every field you set (media, scheduling, expiry, callback URL). A failure throws before any request, so nothing is silently dropped. See [Message length and cost](/sending/length-and-cost).
2. **Duplicate check.** If the same `idempotencyKey` was already accepted, you get the stored result back with `replayed: true` instead of a second text. See [Prevent duplicate sends](/sending/idempotency).
3. **Your policy.** A `beforeSend` callback can block the message, for example for a suppressed recipient. See [Block and log sends](/advanced/policy-and-hooks).
4. **The request.** The first adapter sends. A documented rate limit is retried on the same adapter. If you turned fallback on and the provider clearly rejected the message, the next adapter tries. See [Fallback and retries](/sending/fallback-and-retries).
5. **The result.** The first accepted response resolves the call. Anything else throws.

## Defaults

SMS SDK picks the safe option when you pass nothing, even when that means an error instead of an automatic retry.

| Behavior | Default | Change it in |
| --- | --- | --- |
| Fallback | Off. With several adapters, only the first is used. Turn on with `fallback: "on-known-rejection"`. | [Fallback and retries](/sending/fallback-and-retries) |
| Retries | Rate limits only, at most 2 requests per adapter (`retry.maxAttempts`). Backoff starts at 500 ms and caps at 10 s (`retry.baseDelayMs`, `retry.maxDelayMs`). A `Retry-After` longer than the cap stops retrying. A 429 without the provider's documented rate-limit error counts as unknown. | [Fallback and retries](/sending/fallback-and-retries) |
| Timeout | 10 seconds per request (`timeoutMs`). A timeout after the request starts is an unknown outcome. | [createSmsClient](/reference/create-sms-client) |
| Duplicate protection | Without a store, an `idempotencyKey` only joins identical calls running at the same time in one process. With `idempotency.store`, accepted results are kept 24 hours (`ttlSec`), unknown outcomes until you delete them, and a send stuck for 5 minutes (`staleReservationSec`) becomes unknown, never a resend. | [Prevent duplicate sends](/sending/idempotency) |
| Duplicate store | `memoryIdempotencyStore()` protects one process. It is lost on restart and not shared across instances or serverless invocations. | [Multiple instances](/advanced/multiple-instances) |
| Sender | A message without `from` uses the adapter's `from`. With neither, `send()` throws `InvalidSenderError`. | [Send a message](/sending/send-a-message) |
| Segment count | `result.segments` and `validate()` estimate GSM-7 and UCS-2 segments. The provider decides what it bills. | [Length and cost](/sending/length-and-cost) |
| Hooks | Phone numbers are masked, bodies are never included, and a hook that throws is ignored. | [Block and log sends](/advanced/policy-and-hooks) |
| Webhook verification | On. An unsigned or altered request throws `WebhookSignatureError`. `unsafeSkipVerification: true` is for tests only. | [Webhook security](/receiving/webhook-security) |
| Proxy headers | Ignored. Pass `publicUrl`, or `trustProxy: true` only if a proxy you control sets `X-Forwarded-Proto` and `X-Forwarded-Host`. | [Webhook security](/receiving/webhook-security) |
| Replay window | Signed timestamps must be within 5 minutes (`toleranceSec: 300`) for Telnyx, Vonage, and Sinch HMAC. Twilio, Plivo, and Bandwidth sign no timestamp, so deduplicate with `dedupeKey`. | [Webhook security](/receiving/webhook-security) |
| Keyword detection | On. An inbound message that is only `STOP`, `UNSUBSCRIBE`, `HELP`, or a similar keyword becomes `recipient.opted_out` or `recipient.help`. Turn off with `detectKeywords: false`. SMS SDK never replies itself. | [Opt-outs](/receiving/opt-outs) |
| Unknown webhook statuses | A verified event the SDK does not map becomes `{ type: "unrecognized" }` with the payload in `raw`. It does not throw. | [Webhook events](/reference/webhook-events) |

## Next steps

- [Send a message](/sending/send-a-message)
- [Fallback and retries](/sending/fallback-and-retries)
- [createSmsClient reference](/reference/create-sms-client)
