> ## 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.

# Your first SMS, end to end

> Walk the full first-SMS path in one page — API key and scopes, buy a number, send with POST /messages/sms, track the delivery receipt over webhooks, receive the reply, answer it in-thread, and honor opt-outs.

# Your first SMS, end to end

The SMS building blocks each have their own page — buying numbers, sending, delivery receipts, two-way threading, opt-outs. This one walks the whole arc in order, first message through first reply, so you leave with a working two-way loop instead of five disconnected checkboxes. Build on a sandbox key the whole way through, then swap a live key in for production.

**You will:**

1. [Get an API key](#1-get-an-api-key)
2. [Buy a number](#2-buy-a-number)
3. [Send the SMS](#3-send-the-sms)
4. [Track the delivery receipt](#4-track-the-delivery-receipt)
5. [Receive the reply](#5-receive-the-reply)
6. [Answer in the same thread](#6-answer-in-the-same-thread)
7. [Handle opt-outs](#7-handle-opt-outs)
8. [Fix the common first-send failures](#8-fix-the-common-first-send-failures)

## 1. Get an API key

Create a key in **Settings → API Keys** and scope it down to messaging: `messages:read` and `messages:write` cover every call in this walkthrough. The send step exercises both halves — everything outside the scope `403`s — so a narrow key proofs your least-privilege setup from the start. Scope families per product surface are tabulated in [Choose and scope API keys](/guides/api-keys-messaging-ccaas-cdp).

Carry a sandbox key (`dv_test_sk_…`) while you build; swap a live key (`dv_live_sk_…`) for production. Sandbox sends are free and simulated, and the delivery receipts are deterministic, so you can exercise the delivered and the failure paths against the same walkthrough.

Every request posts to one base URL and carries the key in the `X-API-Key` header — sandbox is the same host, selected by the key type, not a different domain:

```
https://api.orbit.devotel.io/api/v1
```

## 2. Buy a number

SMS needs an SMS-capable sender you own. Search live inventory, then buy with the `sms` capability in the request:

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    # 1. Search inventory — pick an id to forward into the purchase
    curl "https://api.orbit.devotel.io/api/v1/numbers/available?country=US&type=local&capabilities=sms" \
      -H "X-API-Key: dv_test_sk_YOUR_KEY"

    # 2. Purchase the row you picked
    curl -X POST https://api.orbit.devotel.io/api/v1/numbers/purchase \
      -H "X-API-Key: dv_test_sk_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "phone_number": "+18005551234",
        "country_code": "US",
        "capabilities": ["sms"],
        "id": "did_8f2a91c4"
      }'
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    from orbit_sdk import OrbitClient

    client = OrbitClient.from_env()  # reads ORBIT_API_KEY

    purchase = client.request(
        "POST",
        "/numbers/purchase",
        json_body={
            "phone_number": "+18005551234",
            "country_code": "US",
            "capabilities": ["sms"],
        },
    )
    print(purchase["data"]["phone_number"])
    ```
  </Tab>
</Tabs>

Passing an explicit `capabilities` list matters: omit it and Orbit provisions what the carrier advertises, which can leave a voice-capable DID without the SMS flag your send needs. The full buy loop — single purchase, bulk orders, polling, regulatory holds — is in [Buy and provision numbers](/guides/buy-numbers). Numbers reads need `numbers:read`, purchases `numbers:write`; mint those on the same key or a second one.

If you would rather exercise the path before spending, claim the one free trial number instead — a 24-hour lease from the shared pool that converts to a permanent purchase with `POST /numbers/purchase-trial` before it lapses. Trial lifecycle is under [Number Lifecycle](/numbers/lifecycle).

## 3. Send the SMS

`POST /messages/sms` takes `to` and `body`; `from` is optional (omit it and Orbit picks an eligible sender — pass yours explicitly once you own one). Always send an `Idempotency-Key`: a retry of the same key and body returns the original response instead of a duplicate.

<Tabs>
  <Tab title="cURL">
    ```bash theme={null}
    curl -X POST https://api.orbit.devotel.io/api/v1/messages/sms \
      -H "X-API-Key: dv_test_sk_YOUR_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: first-send-98421" \
      -d '{
        "to": "+14155552671",
        "from": "+18005551234",
        "body": "Acme Test Shop: your order #1234 has shipped. Reply STATUS for tracking."
      }'
    ```
  </Tab>

  <Tab title="Node.js">
    ```typescript theme={null}
    import { Orbit } from "@devotel-orbit/node";

    const orbit = new Orbit({ apiKey: process.env.ORBIT_API_KEY });

    const { data: message } = await orbit.messages.sms.send({
      to: "+14155552671",
      from: "+18005551234",
      body: "Acme Test Shop: your order #1234 has shipped. Reply STATUS for tracking.",
    });
    console.log(message.id); // msg_…
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    from orbit_sdk import OrbitClient

    client = OrbitClient.from_env()  # reads ORBIT_API_KEY

    message = client.messages.send_sms(
        to="+14155552671",
        from_="+18005551234",
        body="Acme Test Shop: your order #1234 has shipped. Reply STATUS for tracking.",
    )
    print(message["data"]["id"])  # msg_…
    ```
  </Tab>
</Tabs>

The response is `202 Accepted` — persisted and queued, not yet handed to the carrier. Hold the `data.id` (a `msg_` + 32 hex characters): every follow-up, including the webhooks below, keys off it.

## 4. Track the delivery receipt

A queued message walks `queued → sending → sent → delivered` (or terminates in `failed` / `undelivered`). Do not poll — register one webhook endpoint subscribed to the lifecycle events, and Orbit POSTs each transition to you:

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/webhooks \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://yourapp.com/webhooks/orbit",
    "events": ["message.delivered", "message.failed", "message.received"]
  }'
```

The created endpoint returns a generated signing secret — store it; you verify every inbound POST against it. A delivered receipt reads:

```json theme={null}
{
  "id": "evt_abc123",
  "type": "message.delivered",
  "created_at": "2026-07-20T12:00:00Z",
  "data": {
    "message_id": "msg_a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "channel": "sms",
    "status": "delivered",
    "is_terminal": true,
    "timestamp": "2026-07-20T12:00:00Z"
  }
}
```

On a sandbox key those receipts are deterministic, so you can force both the delivered and the failed path without spending. Before you wire a live endpoint, push a test event through the [Webhook tester](/guides/webhook-tester) — it replays payloads against your URL so the receiver logic is debugged before real traffic arrives. A full receiver loop (tunnel → register → verify → replay) is in the [first webhook quickstart](/guides/first-webhook-quickstart); the receiver below is the same shape pulled in here.

## 5. Receive the reply

Replies to your number arrive as `message.received` events on the same endpoint. Inbound routing for numbers you own is automatic — no per-number URL wiring. Here is a minimal receiver that verifies the signature, deduplicates on the event `id`, and returns `200` before doing any work:

<Tabs>
  <Tab title="Node.js (Express)">
    ```typescript theme={null}
    import crypto from "node:crypto";
    import express from "express";

    const app = express();
    app.post(
      "/webhooks/orbit",
      express.raw({ type: "application/json" }),
      (req, res) => {
        const sig = req.get("X-Orbit-Signature") ?? "";
        const expected = crypto
          .createHmac("sha256", process.env.ORBIT_WEBHOOK_SECRET)
          .update(req.body)
          .digest("hex");
        if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
          res.sendStatus(401);
          return;
        }
        const event = JSON.parse(req.body.toString());
        // deduplicate on event.id — delivery is at-least-once
        res.sendStatus(200);
        handleInbound(event); // your thread/bookkeeping work, async
      },
    );
    ```
  </Tab>

  <Tab title="Python (Flask)">
    ```python theme={null}
    import os

    from flask import Flask, jsonify, request
    from orbit_sdk import OrbitWebhookSignatureError, verify_webhook

    app = Flask(__name__)

    @app.post("/webhooks/orbit")
    def on_event():
        try:
            event = verify_webhook(
                payload=request.get_data(),
                signature=request.headers.get("X-Orbit-Signature", ""),
                secret=os.environ["ORBIT_WEBHOOK_SECRET"],
            )
        except OrbitWebhookSignatureError:
            return jsonify(error="bad signature"), 401
        # deduplicate on event["id"] — delivery is at-least-once
        handle_inbound(event)  # your thread/bookkeeping work
        return jsonify(ok=True)
    ```
  </Tab>
</Tabs>

Verify the signature before reading the body, and treat a retried event as a duplicate — that pair is the whole honesty rule for a receiver. Signature verification shapes per language are in [Webhooks security](/webhooks/security).

## 6. Answer in the same thread

Replying is another `POST /messages/sms` with the endpoints swapped — there is no reply endpoint and no header to set. Reply on the same `from` the conversation started with; swapping senders mid-thread splits the conversation into two threads on the customer's handset.

```bash theme={null}
curl -X POST https://api.orbit.devotel.io/api/v1/messages/sms \
  -H "X-API-Key: dv_test_sk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "+14155552671",
    "from": "+18005551234",
    "body": "Order #1234 is out for delivery, arriving today by 5pm."
  }'
```

SMS carries no reply metadata — thread identity comes from the direction-aware endpoint pair (`customer → your-number`, ordered the same on inbound and outbound). When you outgrow a `Map` in your receiver, the [Two-way SMS conversations](/guides/sms-two-way-conversation) guide keys the thread map, covers keyword auto-reply, and plans the Inbox upgrade; both sides of a conversation also land on one `conversation_id` you can pull via `GET /messages`.

## 7. Handle opt-outs

Carrier-mandated `STOP` / `HELP` / `START` handling is on for every tenant by default: a STOP inbound writes a contact opt-out, auto-replies, and every later send to that recipient is rejected at dispatch instead of leaking through. Your receiver's job is bookkeeping — treat STOP as a terminal thread state — not filtering.

Once you send under a second brand or language, add a [custom opt-out list](/guides/opt-out-lists): tenant-owned keyword aliases and branded auto-response copy, attached to a messaging service. The aliases widen the trigger surface; the mandatory defaults can never be removed. Register aliases ahead of your first campaign, not after the first unsubscribe.

## 8. Fix the common first-send failures

Match on `error.code`; log `meta.request_id` for support. The first-send failures almost everyone hits once:

| Symptom               | Code                           | Cause                                                                                       | Fix                                                                                                                                              |
| --------------------- | ------------------------------ | ------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| 422 on send           | `NO_SENDER_CONFIGURED`         | No sender resolved — you omitted `from` and no owned number is eligible for the destination | Pass an owned `from`, or complete the purchase in step 2                                                                                         |
| 422 on send           | `SENDER_NOT_OWNED`             | The E.164 `from` is not on your account                                                     | Buy the number, or fix a typo'd `from`                                                                                                           |
| 422 on send           | `NOT_SMS_CAPABLE`              | Sender was provisioned without the `sms` capability (or a toll-free used for two-way)       | Re-buy with `capabilities: ["sms"]`; use a local/mobile DID for two-way                                                                          |
| 403/422 on send       | `SMS_BLOCKED_DESTINATION`      | Destination prefix on the platform blocklist (premium / pumped routes)                      | Send to another destination; check [SMS pumping protection](/guides/sms-pumping-protection) before widening coverage                             |
| 422 on sandbox send   | `SANDBOX_MAGIC_NUMBER_BLOCKED` | Recipient is a vendor-reserved sandbox magic number                                         | Use the number sandbox docs mark as deliverable; see [Sandbox](/sandbox/overview)                                                                |
| 402 on send           | `INSUFFICIENT_BALANCE`         | Wallet below the channel minimum                                                            | Top up the wallet; the response names required and available                                                                                     |
| Never `delivered`     | — (stays `sent`)               | Carrier receipt timeout — the handset leg never came back                                   | Treat as non-terminal, not delivered; the full status set per channel is in [Message status lifecycle](/api-reference/messages-status-lifecycle) |
| Webhooks never arrive | —                              | Endpoint not subscribed, or receiver rejected the POST                                      | Re-check the `events` list in step 4, and replay through the [Webhook tester](/guides/webhook-tester)                                            |

The remaining send-path codes (`INVALID_API_KEY`, `INVALID_PHONE_NUMBER`, `RATE_LIMITED`, `VALIDATION_ERROR`) are tabulated with every other code in [Error codes](/reference/error-codes).

## Next steps

* [Buy and provision numbers](/guides/buy-numbers) — bulk orders, polling, regulatory holds, post-purchase wiring
* [Send & Receive Messages](/guides/send-receive-messages) — the same loop generalized to WhatsApp, RCS, Viber, and Email
* [Two-way SMS conversations](/guides/sms-two-way-conversation) — thread maps, keyword auto-reply, Inbox upgrade
* [First webhook quickstart](/guides/first-webhook-quickstart) — tunnel, register, verify, replay
* [Webhook tester](/guides/webhook-tester) — replay payloads against your receiver before live traffic
* [Opt-out lists](/guides/opt-out-lists) — per-brand STOP/HELP/START keywords and copy
* [Error codes](/reference/error-codes) — every `error.code` the send path can return
