# Send a text

Send from a texting number on your account to a US or Canadian number. Try a dry run first: with `"test": true` the API checks everything and sends nothing. A dry run is never sent or billed, but it counts as a request toward the rate limits.

**curl**

```bash
# 1. Dry run: checks everything, sends nothing
curl -X POST https://api.vocatech.com/v1/messages \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "message": "Hi Jordan, your appointment is confirmed for Friday at 9 AM.",
    "test": true
  }'

# 2. Send it: the same body without "test"
curl -X POST https://api.vocatech.com/v1/messages \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "message": "Hi Jordan, your appointment is confirmed for Friday at 9 AM."
  }'
```

**Node**

```javascript
import { vt } from './vocatech.mjs';

const text = {
  platform: 'text',
  from: '7185550100', // a texting number on your account
  to: '7185550142',
  message: 'Hi Jordan, your appointment is confirmed for Friday at 9 AM.',
};

// 1. Dry run: checks everything, sends nothing
const check = await vt('POST', '/v1/messages', { ...text, test: true });
console.log('valid:', check.message.valid, 'parts:', check.message.messages_counted);

// 2. Send it
try {
  const res = await vt('POST', '/v1/messages', text);
  if (res.status === 'queued') {
    console.log('queued at position', res.message.position); // 202, API Messaging keys
  } else if (res.message.message_sent) {
    console.log('sent:', res.status); // 201, or 200 into an existing Webex space
  } else {
    console.warn('taken but not sent:', res.message);
  }
} catch (err) {
  if (err.status === 503) console.warn('Not sent. Safe to retry.');
  else if (err.status === 504) console.warn('May have gone out. Check GET /v1/messages first.');
  else throw err;
}
```

**Python**

```python
from vocatech import VocatechError, vt

text = {
    "platform": "text",
    "from": "7185550100",  # a texting number on your account
    "to": "7185550142",
    "message": "Hi Jordan, your appointment is confirmed for Friday at 9 AM.",
}

# 1. Dry run: checks everything, sends nothing
check = vt("POST", "/v1/messages", {**text, "test": True})
print("valid:", check["message"]["valid"], "parts:", check["message"]["messages_counted"])

# 2. Send it
try:
    res = vt("POST", "/v1/messages", text)
    if res["status"] == "queued":
        print("queued at position", res["message"]["position"])  # 202, API Messaging keys
    elif res["message"].get("message_sent"):
        print("sent:", res["status"])  # 201, or 200 into an existing Webex space
    else:
        print("taken but not sent:", res["message"])
except VocatechError as err:
    if err.status == 503:
        print("Not sent. Safe to retry.")
    elif err.status == 504:
        print("May have gone out. Check GET /v1/messages first.")
    else:
        raise
```

**PHP**

```php
<?php
require __DIR__ . '/vocatech.php';

$text = [
    'platform' => 'text',
    'from'     => '7185550100', // a texting number on your account
    'to'       => '7185550142',
    'message'  => 'Hi Jordan, your appointment is confirmed for Friday at 9 AM.',
];

// 1. Dry run: checks everything, sends nothing
$check = vt('POST', '/v1/messages', $text + ['test' => true]);
echo 'parts: ', $check['message']['messages_counted'], PHP_EOL;

// 2. Send it
try {
    $res = vt('POST', '/v1/messages', $text);
    if ($res['status'] === 'queued') {
        echo 'queued at position ', $res['message']['position'], PHP_EOL; // 202
    } elseif (!empty($res['message']['message_sent'])) {
        echo 'sent: ', $res['status'], PHP_EOL; // 201, or 200 into an existing Webex space
    } else {
        echo "taken but not sent\n";
    }
} catch (VocatechError $e) {
    if ($e->status === 503) {
        echo "Not sent. Safe to retry.\n";
    } elseif ($e->status === 504) {
        echo "May have gone out. Check GET /v1/messages first.\n";
    } else {
        throw $e;
    }
}
```

**Dry run response200 OKCopy**

```json
{
  "status": "test",
  "message": {
    "mode": "api",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "name": "Vocatech Text",
    "email_recipient": null,
    "valid": true,
    "messages_counted": 1
  }
}
```

**Send response201 CreatedCopy**

```json
{
  "status": "created",
  "message": {
    "message_id": "msg_7f3a9c",
    "mode": "api",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "name": "Vocatech Text",
    "email_recipient": null,
    "message_sent": true,
    "email_sent": false,
    "created_at": "2026-09-24T09:15:02-04:00",
    "messages_counted": 1
  }
}
```

Check `message.message_sent`. A `201` with `message_sent: false` was taken but did not go out. `messages_counted` is the number of parts the text is paced and billed as.

## What the status means

| Status | Means | Do this |
| --- | --- | --- |
| `200` | A dry run passed (`status: "test"`), or a Webex number posted the text into the contact's existing conversation (`status: "sent"`). | After a dry run, send for real. Otherwise check `message_sent`. |
| `201` | Sent (`status: "created"`). | Check `message_sent`. |
| `202` | Queued (`status: "queued"`). API Messaging keys only: it goes out in order at the key's pace. | Track it with `GET /v1/messages/queue`. |
| `400` | A field is wrong: a number, the length, an attachment link. | Fix it. The message names it. |
| `403` | Refused: `from` is not a texting number on your account, the recipient opted out, or the text is over the carrier's limit. | Do not retry as is. |
| `422` | WhatsApp sending through the API is not available. | Send WhatsApp from Webex or the portal. |
| `429` | A limit was reached. | Wait `Retry-After` seconds. |
| `502` | The messaging service answered and did not send. | Read the message. Retry later if it is temporary. |
| `503` | The messaging service could not be reached. Nothing was sent. | Safe to retry. |
| `504` | The send was not confirmed. It may have gone out. | Check `GET /v1/messages` before you retry. |

## Pictures and files (MMS)

Add `media`: up to 10 `https` links to files your server hosts on a public address. Every file goes out in one MMS, and an MMS counts as one message however long its text. Each link must end in one of these: `jpg`, `jpeg`, `png`, `gif`, `bmp`, `webp`, `mp4`, `3gp`, `mov`, `mp3`, `wav`, `amr`, `ogg`, `pdf`, `vcf` or `txt`. Carriers cap MMS size, often between 500 KB and 1 MB, so keep files small.

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/messages \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "message": "Here is the map to our new office.",
    "media": ["https://YOUR_DOMAIN/files/office-map.png"]
  }'
```

**Node**

```javascript
import { vt } from './vocatech.mjs';

const res = await vt('POST', '/v1/messages', {
  platform: 'text',
  from: '7185550100',
  to: '7185550142',
  message: 'Here is the map to our new office.',
  media: ['https://YOUR_DOMAIN/files/office-map.png'], // up to 10 links
});
console.log(res.message.mms, res.message.attachments_sent);
```

**Python**

```python
from vocatech import vt

res = vt("POST", "/v1/messages", {
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "message": "Here is the map to our new office.",
    "media": ["https://YOUR_DOMAIN/files/office-map.png"],  # up to 10 links
})
print(res["message"]["mms"], res["message"]["attachments_sent"])
```

**PHP**

```php
<?php
require __DIR__ . '/vocatech.php';

$res = vt('POST', '/v1/messages', [
    'platform' => 'text',
    'from'     => '7185550100',
    'to'       => '7185550142',
    'message'  => 'Here is the map to our new office.',
    'media'    => ['https://YOUR_DOMAIN/files/office-map.png'], // up to 10 links
]);
echo $res['message']['attachments_sent'], " attachment(s) sent\n";
```

**Response201 CreatedCopy**

```json
{
  "status": "created",
  "message": {
    "message_id": "msg_7f3a9d",
    "mode": "api",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "mms": true,
    "attachments_sent": 1,
    "mirrored_to_webex": false,
    "message_sent": true,
    "created_at": "2026-09-24T09:18:40-04:00",
    "messages_counted": 1
  }
}
```

## Long texts

A text can run to 1,600 characters. Carriers split it into parts: 160 characters fit in one part, and 153 fit in each part once it splits. One character outside the basic set, such as an emoji or a curly quote, moves the whole text to 70 per part, 67 once it splits. `messages_counted` says how many parts you sent.

## Delivery receipts

A `201` means the text left us. Whether the phone got it arrives later as a `message.status_updated` webhook. `GET /v1/messages` lists texts as `delivered` and does not carry carrier receipts.

## WhatsApp

Sending WhatsApp through the API is not available. `"platform": "whatsapp"` answers `422`, dry runs included. WhatsApp history is in `GET /v1/messages`, and replies go out from Webex or the portal.

**Response422 Unprocessable EntityCopy**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Sending WhatsApp messages through the API is not available yet. WhatsApp history is in GET /v1/messages; reply from Webex or the portal."
  }
}
```
