# Send SMS

Send SMS and MMS from your numbers, handle replies and delivery receipts, respect opt-outs, look up line types and send one-time codes.

Send a text from any SMS-enabled number you own with one request. Replies land in the same [inbox](https://pozy.ai/docs/inbox/messages) as your other channels, and delivery receipts arrive as webhooks. Outside the US a number can send as soon as SMS is on. In the US it must pass [10DLC registration](https://pozy.ai/docs/telephony/10dlc) first.

## Turn SMS on for a number

Not every number can text. Buy with `wantsSms: true` (or search with `sms=true`) to get one that can, then enable SMS on it:

```bash
curl -X POST https://api.pozy.ai/v1/phone-numbers/6650d2e1b3c4d5e6f7081923/sms \
  -H "Authorization: Bearer $POZY_API_KEY"
```

```json
{
  "enabled": true,
  "id": "6650e3f2c4d5e6f708192a34",
  "phoneNumber": "+14155550142",
  "isActive": true,
  "country": "US",
  "smsCapable": true,
  "mmsCapable": true,
  "needsRegistration": true,
  "alreadyRegistered": false,
  "registrationStatus": null,
  "reusable": null
}
```

| Field | Meaning |
| --- | --- |
| `isActive` | SMS is on. The number receives texts right away. |
| `needsRegistration` | A US number that cannot send until a [10DLC or toll-free registration](https://pozy.ai/docs/telephony/10dlc) is approved |
| `reusable` | You already hold an approved registration this number can join for free |
| `smsCapable: false` with `enabled: false` | This number cannot text. Buy an SMS-capable one. |
| `notReady: true` | Still provisioning at the carrier. Retry in a minute. |

`DELETE /v1/phone-numbers/{id}/sms` turns SMS off again.

## Send a text

`POST /v1/sms/messages` takes `from`, `to` and `text`. Both numbers are normalized to E.164, so formatting does not matter.

```bash cURL
curl -X POST https://api.pozy.ai/v1/sms/messages \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Idempotency-Key: order-1234-shipped" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+14155550142",
    "to": "+13125550188",
    "text": "Acme: order #1234 has shipped. Reply STOP to opt out."
  }'
```
```ts Node
const res = await fetch("https://api.pozy.ai/v1/sms/messages", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Idempotency-Key": "order-1234-shipped",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "+14155550142",
    to: "+13125550188",
    text: "Acme: order #1234 has shipped. Reply STOP to opt out.",
  }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
console.log(data.id, data.conversationId, data.status);
```
```python Python
import os, requests

resp = requests.post(
    "https://api.pozy.ai/v1/sms/messages",
    headers={
        "Authorization": f"Bearer {os.environ['POZY_API_KEY']}",
        "Idempotency-Key": "order-1234-shipped",
    },
    json={
        "from": "+14155550142",
        "to": "+13125550188",
        "text": "Acme: order #1234 has shipped. Reply STOP to opt out.",
    },
    timeout=30,
)
print(resp.json())
```

```json
{ "id": "67c9d0e1f2a3b4c5d6e7f8a9", "conversationId": "66c3d2ae7b4f6c8d0e1f2a3b", "status": "sent" }
```

`sent` means the carrier accepted the message. The final outcome comes by webhook. Send an `Idempotency-Key` so a retry returns the first response instead of texting twice (see [Idempotency](https://pozy.ai/docs/getting-started/idempotency)).

| Body field | What it does |
| --- | --- |
| `from` | One of your SMS-enabled numbers, or an [alphanumeric sender ID](https://pozy.ai/docs/telephony/sender-ids) |
| `to` | Recipient number |
| `text` | Message body, up to 10 segments. Optional when you send media. |
| `mediaUrls` | Up to 10 public URLs. Turns the message into an MMS. |
| `sendAt` | ISO 8601 time with offset, in the future, to schedule the message |

## Segments and encoding

Carriers bill and deliver SMS in segments. The encoding decides how many characters fit in each one.

| Encoding | Used when | One segment | Per segment of a longer message |
| --- | --- | --- | --- |
| GSM-7 | Plain Latin letters, digits and common symbols | 160 characters | 153 characters |
| UCS-2 | Any emoji, accented letters outside GSM-7, non-Latin scripts | 70 characters | 67 characters |

One emoji switches the whole message to UCS-2. A message is capped at 10 segments: 1,530 GSM-7 characters or 670 UCS-2 characters. Prices are per segment, by destination, and the carrier rates each message after it is sent, so there is no price quote endpoint for SMS.

## Send MMS

Add `mediaUrls` to attach images or video. `text` becomes the caption, or leave it out to send media only.

```bash
curl -X POST https://api.pozy.ai/v1/sms/messages \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "+14155550142",
    "to": "+13125550188",
    "text": "Here is your receipt",
    "mediaUrls": ["https://acme.example.com/receipts/1234.png"]
  }'
```

MMS is not available from sender IDs. To send the same message to many people, use an SMS [broadcast](https://pozy.ai/docs/automations/broadcasts) instead of looping over this endpoint.

## Replies and delivery

| What happens | Event |
| --- | --- |
| The carrier delivered your message | `message.delivered` |
| Delivery failed (carrier error code included) | `message.failed` |
| Someone texts your number | `message.received` with `platform: "sms"` |
| The first message of a new thread | `conversation.started` |

Replies thread into the same inbox conversation as the message you sent (`conversationId`). Answer them with the inbox endpoints or another `POST /v1/sms/messages`. See [Messages](https://pozy.ai/docs/inbox/messages) and [Webhooks](https://pozy.ai/docs/webhooks).

## Opt-outs

When a recipient replies STOP, the carrier opts them out. Further sends to them return `409`: nothing is dropped silently. Only the recipient can opt back in, by replying START.

Keep your own suppression list in sync with `GET /v1/sms/opt-outs`:

```bash
curl "https://api.pozy.ai/v1/sms/opt-outs?limit=1000" \
  -H "Authorization: Bearer $POZY_API_KEY"
```

```json
{
  "optOuts": [
    { "phoneNumber": "+13125550199", "optedOutAt": "2026-10-28T09:30:00Z", "keyword": "STOP", "from": "+14155550142" }
  ],
  "count": 1
}
```

The list is most recent first. `limit` defaults to 500 and goes up to 5,000. There is no cursor, so raise `limit` instead of paging. Add `format=csv` for a file.

> **Tip:**
> 
> Put "Reply STOP to opt out" in the first message you send to a new contact. US carriers expect it, and it is one of the things 10DLC reviewers check.

## Look up a number

`GET /v1/sms/lookup?number=%2B13125550188` returns the carrier, the line type (`mobile`, `landline`, `voip`, `toll-free` or `unknown`) and `smsReachable`. Each lookup is billed by the carrier-data source, so run it when you clean an opt-in list, not before every send.

## Send one-time codes

> **Not available yet:**
> 
> The Verify API is not available yet. Its endpoints answer `403` with `code: "feature_not_available"`. Until it opens, send your own codes with [`POST /v1/sms/messages`](https://pozy.ai/docs/api/sendSms) and check them on your side.

The Verify API sends a one-time code by SMS or WhatsApp and checks it for you. You never store the code.

```bash cURL
curl -X POST https://api.pozy.ai/v1/verify/verifications \
  -H "Authorization: Bearer $POZY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "channel": "sms", "to": "+13125550188", "brandName": "Acme", "codeLength": 6, "ttlMinutes": 10 }'
```
```ts Node
const vRes = await fetch("https://api.pozy.ai/v1/verify/verifications", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ channel: "sms", to: "+13125550188", brandName: "Acme" }),
});
const v = await vRes.json();
if (!vRes.ok) throw new Error(v.error);

// later, with the code the user typed
const resultRes = await fetch(`https://api.pozy.ai/v1/verify/verifications/${v.id}/check`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.POZY_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ code: "482913" }),
});
const result = await resultRes.json();
if (!resultRes.ok) throw new Error(result.error);
console.log(result.valid, result.status);
```
```python Python
v = requests.post(
    "https://api.pozy.ai/v1/verify/verifications",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={"channel": "sms", "to": "+13125550188", "brandName": "Acme"},
    timeout=30,
).json()

check = requests.post(
    f"https://api.pozy.ai/v1/verify/verifications/{v['id']}/check",
    headers={"Authorization": f"Bearer {os.environ['POZY_API_KEY']}"},
    json={"code": "482913"},
    timeout=30,
).json()
print(check["valid"], check["status"])
```

| Field | Default | Notes |
| --- | --- | --- |
| `channel` | required | `sms` or `whatsapp` |
| `codeLength` | 6 | Up to 8 digits |
| `ttlMinutes` | 10 | Up to 15 minutes |
| `brandName` | none | Up to 30 characters, shown in the message |
| `from` | auto | A number of yours to send from |

A verification moves through `pending`, then `approved`, `expired`, `max_attempts_reached`, `canceled` or `delivery_failed`. `verification.approved` fires when the right code is checked, and `verification.failed` (with `reason: "max_attempts_reached"`) when attempts run out.

## Limits

| Limit | Value |
| --- | --- |
| Text length | 10 segments (1,530 GSM-7 or 670 UCS-2 characters) |
| MMS media | 10 public URLs per message |
| Destinations | Only countries on the SMS rate card. Others are blocked at send time, not billed. |
| Opt-out list | 500 by default, 5,000 at most, no cursor |

## Errors

| Status | Cause | Fix |
| --- | --- | --- |
| `403` | A US number whose registration is not approved yet | Wait for `approved` on the [registration](https://pozy.ai/docs/telephony/10dlc) |
| `404` | No SMS-enabled number you own matches `from` | Send from one of your numbers |
| `409` | The recipient replied STOP, or the same `Idempotency-Key` is still in flight | Drop the recipient, or wait for the first request |
| `422` | The `Idempotency-Key` was reused with a different body | Use a new key for a new message |
| `402` | No payment method on file | Add a payment method |

## Endpoints

| Endpoint | What it does |
| --- | --- |
| [`POST /v1/phone-numbers/{id}/sms`](https://pozy.ai/docs/api/enableSmsOnNumber) | Enable SMS on a number |
| [`DELETE /v1/phone-numbers/{id}/sms`](https://pozy.ai/docs/api/disableSmsOnNumber) | Disable SMS on a number |
| [`POST /v1/sms/messages`](https://pozy.ai/docs/api/sendSms) | Send an SMS/MMS |
| [`GET /v1/sms/opt-outs`](https://pozy.ai/docs/api/listSmsOptOuts) | List SMS opt-outs |
| [`GET /v1/sms/lookup`](https://pozy.ai/docs/api/lookupSmsNumber) | Look up carrier + line type |
| [`POST /v1/verify/verifications`](https://pozy.ai/docs/api/createVerification) | Send a verification code (not available yet) |
| [`GET /v1/verify/verifications/{verificationId}`](https://pozy.ai/docs/api/getVerification) | Get a verification (not available yet) |
| [`POST /v1/verify/verifications/{verificationId}/check`](https://pozy.ai/docs/api/checkVerification) | Check a verification code (not available yet) |

## Webhook events

| Event | When it fires |
| --- | --- |
| [`message.received`](https://pozy.ai/docs/webhooks/events#message-received) | Message received. |
| [`conversation.started`](https://pozy.ai/docs/webhooks/events#conversation-started) | Conversation started. |
| [`message.delivered`](https://pozy.ai/docs/webhooks/events#message-delivered) | Message delivered. |
| [`message.failed`](https://pozy.ai/docs/webhooks/events#message-failed) | Message delivery failed. |
| [`verification.approved`](https://pozy.ai/docs/webhooks/events#verification-approved) | Verification approved. |
| [`verification.failed`](https://pozy.ai/docs/webhooks/events#verification-failed) | Verification failed. |
