# Messages

Send texts and picture messages. Read text and WhatsApp history. Metered API Messaging keys also get a send queue.

## Send a text

`POST /v1/messages`

Sends one text (SMS) from one of your numbers, or a picture message (MMS) when you add `media`. `from` must be a number on your account with texting on.

**How it goes out depends on the number's mode**, set in the portal:

- `api` or `email`: straight to the carrier. The answer is `201` with the carrier's `message_id`, the same id the `message.sent` and `message.status_updated` webhooks and `GET /v1/messages` carry. In `email` mode a copy also goes to the number's email address.
- `webex`: into the contact's Webex space, and from there to the phone. The answer is `201` when a new space was made, or `200` with `status: sent` when it went into the existing space. This answer has no carrier `message_id`.
- With `media`, in any mode: one MMS with every attachment, straight to the carrier. A Webex-mode number also gets a copy in its space.

Always check `message.message_sent`. `false` means the send was tried and did not leave.

**Dry run.** `test: true` runs every check and sends nothing. A dry run is never billed or recorded, but the request still counts toward your rate limits. On a metered key it also says `would_queue`.

**Metered keys.** A key with the `messaging` scope does not get a per-minute `429`. When its pace is used up, the message is queued and the answer is `202` with a `queue_id`. It goes out automatically, in order (see `GET /v1/messages/queue`). Only its day limit refuses a send (`429`), counting what is already queued.

**Counting.** `messages_counted` is how many message parts (segments) the send counts as: 160 plain characters fit in one part and 153 in each part after that; a text that needs Unicode fits 70, then 67. A picture message counts as one.

**WhatsApp.** Sending WhatsApp through the API is not available. `platform: whatsapp` answers `422`, dry runs too. WhatsApp history is in `GET /v1/messages`.

**When a send fails:**

- `503`: the messaging service was not reached. Nothing was sent. It is safe to retry.
- `504`: the send was not confirmed. It may have gone out. Check `GET /v1/messages` before you retry.
- `502`: the messaging service answered and did not send.
- `403`: the recipient opted out, the number is on your blocked list, the text is too long once the signature is added, or an attachment was refused.
- The messaging service's own `400`, `404` or `429` is passed on as it is.

A main key may send 20 a minute and 200 a day, inside the account's included texting limit (50 an hour and 150 a day as standard). For more volume, use a metered API Messaging key: paid per message and outside that limit.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/messages \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "message": "Hi Jordan, your appointment is confirmed for Friday at 10 AM. Reply C to confirm."
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/messages", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    platform: "text",
    from: "7185550100",
    to: "7185550142",
    message: "Hi Jordan, your appointment is confirmed for Friday at 10 AM. Reply C to confirm.",
  }),
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/messages",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "platform": "text",
        "from": "7185550100",
        "to": "7185550142",
        "message": "Hi Jordan, your appointment is confirmed for Friday at 10 AM. Reply C to confirm.",
    },
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->post('/v1/messages', [
    'json' => [
        'platform' => 'text',
        'from' => '7185550100',
        'to' => '7185550142',
        'message' => 'Hi Jordan, your appointment is confirmed for Friday at 10 AM. Reply C to confirm.',
    ],
]);

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `platform` | string | yes | Use `text`. `whatsapp` is not available through the API and answers `422`. One of `text` or `whatsapp`. |
| `from` | string | yes | One of your texting numbers. Punctuation and a leading 1 are ignored. For example `7185550100`. |
| `to` | string | yes | The recipient: a 10-digit US or Canadian number. The area code cannot start with 0 or 1. Punctuation and a leading 1 are ignored. For example `7185550142`. |
| `message` | string | yes | The text, up to 1,600 characters. Required with `media` too. Up to 1,600 characters. |
| `media` | string (uri) or array, each string (uri) |  | Makes it a picture message (MMS): one URL, or a list of up to 10. The carrier fetches each file, so it must be reachable without a login. Size is limited by the carriers (often around 1 MB). |
| `media_url` | string (uri) or array, each string (uri) |  | Another name for `media`. |
| `name` | string |  | The recipient's name, used the first time we name this contact. Defaults to `Vocatech Text`. |
| `members` | array, each string (email) |  | Webex mode only: the Webex users to add when a new space is made. Each must be allowed on the number. Leave it out to use every allowed member. |
| `test` | boolean |  | A dry run: check everything and send nothing. Default `false`. |
| `company_id` | integer |  | Optional. If you send it, it must be your own company id. |

**A text**

```json
{
  "platform": "text",
  "from": "7185550100",
  "to": "7185550142",
  "message": "Hi Jordan, your appointment is confirmed for Friday at 10 AM. Reply C to confirm."
}
```

**A picture message**

```json
{
  "platform": "text",
  "from": "7185550100",
  "to": "7185550142",
  "message": "Here is the estimate we discussed.",
  "media": [
    "https://files.example.com/estimates/estimate-2291.pdf"
  ]
}
```

**A dry run**

```json
{
  "platform": "text",
  "from": "7185550100",
  "to": "7185550142",
  "message": "Hi Jordan, your appointment is confirmed for Friday at 10 AM. Reply C to confirm.",
  "test": true
}
```

### Answers

#### 200

A dry run passed, or the text went into the contact's existing Webex space.

**When `status` is `test`** A dry run passed every check. Nothing was sent.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | Always `test`. |
| `message` | object | yes |  |
| `message.mode` | string | yes | One of `api`, `email` or `webex`. |
| `message.platform` | string | yes | Always `text`. |
| `message.from` | string | yes |  |
| `message.to` | string | yes |  |
| `message.valid` | boolean | yes | Always `true`. |
| `message.messages_counted` | integer | yes | Message parts (segments) the send would count as. |
| `message.would_queue` | boolean |  | Metered keys only: true when a real send would be queued now. |
| `message.name` | string |  | `api` and `email` modes. |
| `message.email_recipient` | string or null |  | `api` and `email` modes. |
| `message.mms` | boolean |  | Picture messages. |
| `message.attachments` | integer |  | Picture messages: how many files would go. |
| `message.existing_space` | boolean |  | Webex mode, when the contact has a space. |
| `message.room_uuid` | string |  | Webex mode, when the contact has a space. |
| `message.members` | array of strings |  | Webex mode, when a new space would be made. |

**When `status` is `created` or `sent`**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | `created` (`201`), or `sent` (`200`) when it went into an existing Webex space. One of `created` or `sent`. |
| `message` | object | yes | What was sent. Which fields appear depends on the number's mode. |
| `message.message_id` | string or null |  | The carrier's message id (`api` and `email` modes, and every picture message). It matches `message_id` on the `message.sent` and `message.status_updated` webhooks and in `GET /v1/messages`. Not present in Webex mode. |
| `message.mode` | string | yes | How the number sends. One of `api`, `email` or `webex`. |
| `message.platform` | string | yes | Always `text`. |
| `message.from` | string | yes | Your number, 10 digits. |
| `message.to` | string | yes | The recipient, 10 digits. |
| `message.message_sent` | boolean | yes | True when the message left. False means it was tried and did not leave. |
| `message.created_at` | string | yes | When we took it, ISO 8601 with the Eastern offset, for example `2026-09-24T10:05:09-04:00`. |
| `message.messages_counted` | integer | yes | Message parts (segments) this send counts as. A picture message counts as one. |
| `message.name` | string |  | `api` and `email` modes, and a new Webex space: the contact's name. |
| `message.email_recipient` | string or null |  | `api` and `email` modes: the number's email address, which gets a copy in `email` mode. |
| `message.email_sent` | boolean |  | `api` and `email` modes: true when the email copy went out. |
| `message.mms` | boolean |  | Picture messages: always true. |
| `message.attachments_sent` | integer |  | Picture messages: how many files went out. |
| `message.mirrored_to_webex` | boolean |  | Picture messages on a Webex-mode number: true when a copy was posted in the contact's space. |
| `message.room_uuid` | string |  | Webex mode: the id of the contact's space. |
| `message.room_title` | string |  | Webex mode, new space: the space's title. |
| `message.members` | array of strings |  | Webex mode, new space: the members added. |
| `message.existing_space` | boolean |  | Webex mode: true when the message went into the contact's existing space. |

**Dry run**

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

**Dry run on a metered key**

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

**Webex mode, existing space**

```json
{
  "status": "sent",
  "message": {
    "mode": "webex",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "room_uuid": "0f6e2a91-7c4b-4d3e-9a58-1b2c3d4e5f60",
    "message_sent": true,
    "existing_space": true,
    "created_at": "2026-09-24T10:08:02-04:00",
    "messages_counted": 1
  }
}
```

#### 201

Sent.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | `created` (`201`), or `sent` (`200`) when it went into an existing Webex space. One of `created` or `sent`. |
| `message` | object | yes | What was sent. Which fields appear depends on the number's mode. |
| `message.message_id` | string or null |  | The carrier's message id (`api` and `email` modes, and every picture message). It matches `message_id` on the `message.sent` and `message.status_updated` webhooks and in `GET /v1/messages`. Not present in Webex mode. |
| `message.mode` | string | yes | How the number sends. One of `api`, `email` or `webex`. |
| `message.platform` | string | yes | Always `text`. |
| `message.from` | string | yes | Your number, 10 digits. |
| `message.to` | string | yes | The recipient, 10 digits. |
| `message.message_sent` | boolean | yes | True when the message left. False means it was tried and did not leave. |
| `message.created_at` | string | yes | When we took it, ISO 8601 with the Eastern offset, for example `2026-09-24T10:05:09-04:00`. |
| `message.messages_counted` | integer | yes | Message parts (segments) this send counts as. A picture message counts as one. |
| `message.name` | string |  | `api` and `email` modes, and a new Webex space: the contact's name. |
| `message.email_recipient` | string or null |  | `api` and `email` modes: the number's email address, which gets a copy in `email` mode. |
| `message.email_sent` | boolean |  | `api` and `email` modes: true when the email copy went out. |
| `message.mms` | boolean |  | Picture messages: always true. |
| `message.attachments_sent` | integer |  | Picture messages: how many files went out. |
| `message.mirrored_to_webex` | boolean |  | Picture messages on a Webex-mode number: true when a copy was posted in the contact's space. |
| `message.room_uuid` | string |  | Webex mode: the id of the contact's space. |
| `message.room_title` | string |  | Webex mode, new space: the space's title. |
| `message.members` | array of strings |  | Webex mode, new space: the members added. |
| `message.existing_space` | boolean |  | Webex mode: true when the message went into the contact's existing space. |

**api mode**

```json
{
  "status": "created",
  "message": {
    "message_id": "msg_9b2e5a",
    "mode": "api",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "name": "Jordan Reyes",
    "email_recipient": null,
    "message_sent": true,
    "email_sent": false,
    "created_at": "2026-09-24T10:05:09-04:00",
    "messages_counted": 1
  }
}
```

**A picture message**

```json
{
  "status": "created",
  "message": {
    "message_id": "msg_5d8f1b",
    "mode": "webex",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "mms": true,
    "attachments_sent": 1,
    "mirrored_to_webex": true,
    "message_sent": true,
    "created_at": "2026-09-24T10:06:44-04:00",
    "messages_counted": 1
  }
}
```

**Webex mode, new space**

```json
{
  "status": "created",
  "message": {
    "mode": "webex",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "name": "Jordan Reyes",
    "room_title": "Jordan Reyes - 7185550142 - 7185550100",
    "room_uuid": "0f6e2a91-7c4b-4d3e-9a58-1b2c3d4e5f60",
    "members": [
      "maya.chen@example.com"
    ],
    "message_sent": true,
    "created_at": "2026-09-24T10:07:30-04:00",
    "messages_counted": 1
  }
}
```

#### 202

Metered key: accepted and queued. It goes out automatically, in order.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | Always `queued`. |
| `message` | object | yes | A message a metered key handed over that waits its turn. |
| `message.queue_id` | integer | yes | The id in your queue. Cancel with `DELETE /v1/messages/queue/{queue_id}`. |
| `message.position` | integer | yes | Place in line, from 1. |
| `message.from` | string | yes |  |
| `message.to` | string | yes |  |
| `message.messages_counted` | integer | yes | Message parts (segments) this send counts as. |
| `message.estimated_wait_minutes` | integer | yes | A rough wait at the key's pace. |
| `message.info` | string | yes |  |

**Example**

```json
{
  "status": "queued",
  "message": {
    "queue_id": 88412,
    "position": 41,
    "from": "7185550100",
    "to": "7185550142",
    "messages_counted": 1,
    "estimated_wait_minutes": 2,
    "info": "Accepted and queued. It goes out automatically, in order, as sending capacity frees up. Track it with GET /v1/messages/queue or cancel it with DELETE /v1/messages/queue/88412."
  }
}
```

#### 400

A missing or invalid field, a message over 1,600 characters, a bad `from` or `to`, a media URL we refuse, or a Webex-mode number with no members allowed.

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Invalid to number: \"12345\" is not a valid US/Canada phone number (10 digits, area code 2-9xx)."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Missing or invalid fields. Platform must be \"text\" or \"whatsapp\", and from, to, message are required."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Media URLs must use https. Got: http"
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Attachment type not supported: .heic"
  }
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused: outside the key's scopes, an address not allowed, `company_id` not yours, `from` is not one of your texting numbers, the number's mode cannot send, a Webex member not allowed on the number, or the messaging service refused (the recipient opted out, the number is on your blocked list, the text is too long once the signature is added, an attachment was refused).

**Not your number**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Invalid from number"
  }
}
```

**Recipient opted out**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Recipient has opted out of messages from this number"
  }
}
```

**On your blocked list**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "This number is on your company's blocked list."
  }
}
```

#### 404

Passed on from the messaging service: the number is not set up for texting there.

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "Number not found for this company"
  }
}
```

#### 422

WhatsApp is not available through the API.

**Example**

```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."
  }
}
```

#### 429

A limit was reached: the `messages` counter on a main key (20 a minute, 200 a day), a limit Vocatech set on this key, a metered key's day limit (which counts what is already queued), or, for a main key, the account's included texting limit on the messaging service (50 texts an hour and 150 a day as standard).

**Example**

```json
{
  "error": "Rate limit exceeded",
  "period": "day",
  "limit": 200,
  "counter": "messages",
  "retry_after": 30211
}
```

#### 500

Webex mode: the Webex space could not be reached or made.

**Example**

```json
{
  "error": {
    "code": 500,
    "type": "SERVER_ERROR",
    "message": "Webex token could not be loaded or refreshed"
  }
}
```

**Example**

```json
{
  "error": {
    "code": 500,
    "type": "SERVER_ERROR",
    "message": "Failed to create Webex Space"
  }
}
```

#### 502

The messaging service answered and did not send. This is a fault on our side: retry later, or contact support if it lasts.

**Example**

```json
{
  "error": {
    "code": 502,
    "type": "ERROR",
    "message": "Failed to send email message"
  }
}
```

#### 503

The messaging service could not be reached and nothing was sent (safe to retry), or the key could not be checked.

**Example**

```json
{
  "error": {
    "code": 503,
    "type": "ERROR",
    "message": "The messaging service could not be reached. The message was not sent, and it is safe to retry."
  }
}
```

#### 504

The send was not confirmed. It may have gone out: check `GET /v1/messages` before you retry.

**Example**

```json
{
  "error": {
    "code": 504,
    "type": "ERROR",
    "message": "The messaging service did not confirm the send. The message may have gone out: check GET /v1/messages before retrying."
  }
}
```

## List messages

`GET /v1/messages`

Your texts and WhatsApp messages in a date window, newest first, in one list. `channel` tells them apart.

**Dates** work as in `GET /v1/calls`: `start_date` and `end_date` together as local days in `timezone`, or neither for today, at most 366 days. The response echoes the local days you asked for.

**Paging.** `limit` is 500 by default and at most 500. Messages that tie on time keep a stable order.

**Fields to know.**

- `message_id`: for a text, the carrier's id (the one a send in `api` or `email` mode returns); for WhatsApp, the WhatsApp message id.
- `status`: a text always reads `delivered` here, because this list does not carry carrier receipts. The `message.status_updated` webhook does. A WhatsApp message carries its own status, for example `read`.
- `type`: `media` when the message has an attachment. Otherwise a text reads `webex`, and a WhatsApp message carries its conversation type.
- `remote_name` is empty today.
- `attachments` list each file's `att_` id. Fetch the file with `GET /v1/media/{id}`.

Each request is written to your account's HIPAA access log.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/messages \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "start_date=2026-09-21" \
  --data-urlencode "end_date=2026-09-21" \
  --data-urlencode "channel=any"
```

**Node**

```javascript
const params = new URLSearchParams({
  start_date: "2026-09-21",
  end_date: "2026-09-21",
  channel: "any",
});

const res = await fetch(`https://api.vocatech.com/v1/messages?${params}`, {
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.get(
    "https://api.vocatech.com/v1/messages",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "start_date": "2026-09-21",
        "end_date": "2026-09-21",
        "channel": "any",
    },
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->get('/v1/messages', [
    'query' => [
        'start_date' => '2026-09-21',
        'end_date' => '2026-09-21',
        'channel' => 'any',
    ],
]);

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `start_date` | query | string (date) |  | First local day of the window, `YYYY-MM-DD`, in `timezone`. Send it with `end_date`. Send neither for today. A single date is ignored and you get today. For example `2026-09-01`. |
| `end_date` | query | string (date) |  | Last local day of the window, `YYYY-MM-DD`, included in full. At most 366 days after `start_date`. For example `2026-09-07`. |
| `timezone` | query | string |  | IANA time zone for the dates, for example `America/Chicago`. An unknown zone falls back to `America/New_York`. The response echoes the zone used. Default `America/New_York`. |
| `direction` | query | string |  | `incoming` or `outgoing`. One of `any`, `incoming` or `outgoing`. Default `any`. |
| `status` | query | string |  | Filters WhatsApp messages by their status, for example `read`. Texts always read `delivered` here, so `delivered` keeps every text and any other value leaves texts out. Default `any`. |
| `channel` | query | string |  | `text` for texts, `whatsapp` for WhatsApp, `any` for both. One of `any`, `text` or `whatsapp`. Default `any`. |
| `search` | query | string |  | A number or words to find in your number, the other party's number, or the message text. |
| `sort` | query | string |  | Order by time. Rows that tie keep a stable order, so paging is safe. One of `day_desc` or `day_asc`. Default `day_desc`. |
| `page` | query | integer |  | Page number, from 1. Default `1`. At least 1. |
| `limit` | query | integer |  | Rows per page, 1 to 500. A value outside that range is brought inside it. Default `500`. From 1 to 500. |
| `company_id` | query | integer |  | Optional. If you send it, it must be your own company id, or the request is refused with `403`. The key already decides the company. |

### Answers

#### 200

The messages in the window.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | object | yes | The filters that were applied, echoed back. |
| `query.company_id` | integer |  |  |
| `query.start_date` | string (date) |  |  |
| `query.end_date` | string (date) |  |  |
| `query.direction` | string |  |  |
| `query.status` | string |  |  |
| `query.channel` | string |  |  |
| `query.search` | string |  |  |
| `query.timezone` | string |  |  |
| `messages` | array of objects | yes |  |
| `messages[].message_id` | string or null | yes | For a text, the carrier's message id: the one a send in `api` or `email` mode returns, and the one on message webhooks. For WhatsApp, the WhatsApp message id. |
| `messages[].direction` | string | yes | `incoming` or `outgoing`. |
| `messages[].status` | string | yes | A text always reads `delivered` here: this list does not carry carrier receipts (the `message.status_updated` webhook does). A WhatsApp message carries its own status, for example `sent`, `delivered`, `read` or `failed`. |
| `messages[].remote_name` | string | yes | Empty today. |
| `messages[].remote_number` | string or null | yes | The other party's number, digits without the leading 1. |
| `messages[].group_number` | string or null | yes | Your number, digits without the leading 1. |
| `messages[].sent_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `messages[].channel` | string | yes | One of `text` or `whatsapp`. |
| `messages[].type` | string | yes | `media` when the message has an attachment. Otherwise a text reads `webex`, and a WhatsApp message carries its conversation type. |
| `messages[].body` | string | yes | The message text, trimmed. |
| `messages[].attachments` | array of objects | yes |  |
| `messages[].attachments[].id` | string | yes | The attachment's media id. Fetch it with `GET /v1/media/{id}`. Matches `^att_[0-9]+$`. |
| `messages[].attachments[].content_type` | string or null | yes | The file's type, for example `image/jpeg`. |
| `messages[].attachments[].filename` | string or null | yes |  |
| `messages[].attachments[].size` | integer or null | yes | Bytes, when known. |
| `meta` | object | yes | Paging for list routes. Each list adds its own total. |
| `meta.page` | integer | yes | This page, from 1. |
| `meta.limit` | integer | yes | Rows per page, after limits were applied. |
| `meta.total_pages` | integer | yes | How many pages there are. |
| `meta.total_messages` | integer | yes | Messages that match, across all pages. |

**Example**

```json
{
  "query": {
    "company_id": 1234567890,
    "start_date": "2026-09-21",
    "end_date": "2026-09-21",
    "direction": "any",
    "status": "any",
    "channel": "any",
    "search": "",
    "timezone": "America/New_York"
  },
  "messages": [
    {
      "message_id": "msg_4c1d7e",
      "direction": "incoming",
      "status": "delivered",
      "remote_name": "",
      "remote_number": "7185550142",
      "group_number": "7185550100",
      "sent_time": "2026-09-21T16:40:11.000Z",
      "channel": "whatsapp",
      "type": "media",
      "body": "Here is the photo of the leak.",
      "attachments": [
        {
          "id": "att_90211",
          "content_type": "image/jpeg",
          "filename": "photo-0921.jpg",
          "size": 184233
        }
      ]
    },
    {
      "message_id": "msg_7f3a9c",
      "direction": "incoming",
      "status": "delivered",
      "remote_name": "",
      "remote_number": "7185550142",
      "group_number": "7185550100",
      "sent_time": "2026-09-21T15:22:04.000Z",
      "channel": "text",
      "type": "webex",
      "body": "Running 10 minutes late, see you soon.",
      "attachments": []
    }
  ],
  "meta": {
    "page": 1,
    "limit": 500,
    "total_pages": 1,
    "total_messages": 2
  }
}
```

#### 400

A date that is not `YYYY-MM-DD`, an end before the start, or a window over 366 days.

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Invalid date format or timezone."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "end_date is before start_date."
  }
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 500

An unexpected fault on our side, or a search that ran longer than 30 seconds and was stopped.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## See the send queue

`GET /v1/messages/queue`

For metered API Messaging keys. Shows what this key has waiting to send, what finished recently, and how much of today's allowance is left.

Messages wait here when the key's per-minute pace is used up. They go out automatically, oldest first, about once a minute. A message is tried up to 3 times before it is marked `failed`.

`items` lists up to 200 rows: waiting ones first, in the order they will send, then the most recent finished ones. Finished rows are removed after 7 days.

`used_today` and `remaining_today` cover the last 24 hours. `tmobile_day_allowance` is how many queued messages to T-Mobile numbers your company may send in a UTC day. Once it is used, T-Mobile messages wait for the next day while the rest keep going.

A main key has no queue: its limits read `null`.

### Request

**curl**

```bash
curl https://api.vocatech.com/v1/messages/queue \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/messages/queue", {
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.get(
    "https://api.vocatech.com/v1/messages/queue",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->get('/v1/messages/queue');

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Answers

#### 200

The queue and the limits.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `queue` | object | yes |  |
| `queue.pending` | integer | yes | Messages waiting or going out now. |
| `queue.items` | array of objects | yes | Up to 200 rows: waiting ones first, oldest first (the order they will send), then the most recent finished ones. |
| `queue.items[].queue_id` | integer | yes |  |
| `queue.items[].to` | string | yes |  |
| `queue.items[].from` | string | yes |  |
| `queue.items[].status` | string | yes | `queued` waits, `sending` is going out now, `sent` left, `failed` gave up after 3 tries, `canceled` was canceled. One of `queued`, `sending`, `sent`, `failed` or `canceled`. |
| `queue.items[].messages_counted` | integer | yes |  |
| `queue.items[].carrier` | string or null | yes | The recipient's carrier once we know it: `tmobile`, `att`, `verizon`, `voip`, `landline`, `other` or `unknown`. |
| `queue.items[].attempts` | integer | yes | Send attempts so far, up to 3. |
| `queue.items[].error` | string or null | yes | The last error, if any. |
| `queue.items[].message_id` | string or null | yes | The carrier's message id once sent. |
| `queue.items[].created_at` | string | yes | When it was queued. UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `queue.items[].sent_at` | string or null | yes | When it was sent. UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `limits` | object | yes |  |
| `limits.per_minute` | integer or null | yes | Message parts a minute for this key. Null when the key has none. |
| `limits.per_day` | integer or null | yes | Messages a day for this key. Null when the key has none. |
| `limits.used_today` | integer | yes | Messages this key sent in the last 24 hours. |
| `limits.remaining_today` | integer or null | yes | What is left of `per_day`, after what is already queued. Null when the key has no day limit. |
| `limits.tmobile_day_allowance` | integer | yes | Queued messages to T-Mobile numbers the company may send per UTC day. Once used, T-Mobile messages wait for the next day while the rest keep going. |

**Example**

```json
{
  "queue": {
    "pending": 2,
    "items": [
      {
        "queue_id": 88412,
        "to": "7185550142",
        "from": "7185550100",
        "status": "queued",
        "messages_counted": 1,
        "carrier": "verizon",
        "attempts": 0,
        "error": null,
        "message_id": null,
        "created_at": "2026-09-24 14:05:09",
        "sent_at": null
      },
      {
        "queue_id": 88413,
        "to": "7185550143",
        "from": "7185550100",
        "status": "queued",
        "messages_counted": 2,
        "carrier": null,
        "attempts": 0,
        "error": null,
        "message_id": null,
        "created_at": "2026-09-24 14:05:09",
        "sent_at": null
      },
      {
        "queue_id": 88390,
        "to": "9175550123",
        "from": "7185550100",
        "status": "sent",
        "messages_counted": 1,
        "carrier": "tmobile",
        "attempts": 1,
        "error": null,
        "message_id": "msg_2a6c0e",
        "created_at": "2026-09-24 14:01:12",
        "sent_at": "2026-09-24 14:02:03"
      }
    ]
  },
  "limits": {
    "per_minute": 60,
    "per_day": 5000,
    "used_today": 1210,
    "remaining_today": 3788,
    "tmobile_day_allowance": 960
  }
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## Cancel every queued message

`DELETE /v1/messages/queue`

Cancels every message this key still has waiting. A message already going out or finished is not touched. Use it when a wrong batch went in.

### Request

**curl**

```bash
curl -X DELETE https://api.vocatech.com/v1/messages/queue \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/messages/queue", {
  method: "DELETE",
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.delete(
    "https://api.vocatech.com/v1/messages/queue",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->delete('/v1/messages/queue');

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Answers

#### 200

Canceled.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | Always `canceled`. |
| `canceled` | integer | yes | How many waiting messages were canceled. |

**Example**

```json
{
  "status": "canceled",
  "canceled": 37
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.

## Cancel one queued message

`DELETE /v1/messages/queue/{id}`

Cancels one waiting message. Only a message still waiting can be canceled.

### Request

**curl**

```bash
curl -X DELETE https://api.vocatech.com/v1/messages/queue/88412 \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/messages/queue/88412", {
  method: "DELETE",
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

console.log(res.status, await res.json());
```

**Python**

```python
import requests

r = requests.delete(
    "https://api.vocatech.com/v1/messages/queue/88412",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code, r.json())
```

**PHP**

```php
<?php
require 'vendor/autoload.php';

$client = new GuzzleHttp\Client([
    'base_uri' => 'https://api.vocatech.com',
    'headers' => ['Authorization' => 'Bearer YOUR_API_KEY'],
    'http_errors' => false,
]);

$res = $client->delete('/v1/messages/queue/88412');

echo $res->getStatusCode(), PHP_EOL, $res->getBody(), PHP_EOL;
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The `queue_id` from the `202` answer or the queue list. For example `88412`. |

### Answers

#### 200

Canceled.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | Always `canceled`. |
| `queue_id` | integer | yes |  |

**Example**

```json
{
  "status": "canceled",
  "queue_id": 88412
}
```

#### 401

No key, or a key that is wrong or turned off.

#### 403

Refused. The key is scoped away from this route, the request came from an address not on the key's allow-list, or the route refused the request (the message says why).

#### 404

No queued message with that id on this key.

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "No queued message with that id on this key."
  }
}
```

#### 409

The message is already going out or finished.

**Example**

```json
{
  "error": {
    "code": 409,
    "type": "CONFLICT",
    "message": "This message is already sending or finished, so it cannot be canceled."
  }
}
```

#### 429

A rate limit was reached. Wait `Retry-After` seconds. The body names the counter, the window and the limit.

#### 503

Try again shortly. The key could not be checked just now (`Retry-After: 5`), or a service this route needs could not be reached.
