# Vocatech API reference

Every route of the Vocatech REST API at https://api.vocatech.com/v1, page by page as https://vocatech.com/developers/reference shows it, drawn from the API's own OpenAPI file (https://vocatech.com/developers/openapi.yml). How to use each one is in the developer guide (https://vocatech.com/developers/guide.md).

---

On the site at https://vocatech.com/developers/reference

# API reference

Every route of the Vocatech API with its parameters, its fields, its answers and its errors, read from the API’s own file every hour. To learn a job step by step, start with the developer guide (https://vocatech.com/developers/guide).

The Vocatech API connects your own systems to your phone system. Read your calls, texts and faxes. Send texts and faxes. Look up who is calling. Keep your contacts in sync. Have your AI agent place a call. Open a support ticket. Get every event pushed to your server by webhook.

**Base URL:** `https://api.vocatech.com`. Paths start with `/v1`. Requests and responses are JSON.

**Server to server only.** The API sends no CORS headers. Keep your key on your server. Never put it in a web page or a mobile app.

**Keys.** Send your key on every request as `Authorization: Bearer YOUR_API_KEY`. A key is an opaque 32-character secret. It is not a JWT. Make the main key in the Vocatech portal on your company's Integrations page (Generate or Regenerate). Make metered texting keys under Textdock > API Messaging, one per integrator, up to 10. The API cannot create or change keys.

## Every route

### Getting started

- `GET /v1/whoami` Check your key (https://vocatech.com/developers/reference/getting-started#get-whoami)

### Calls

- `GET /v1/calls` List calls (https://vocatech.com/developers/reference/calls#list-calls)

### Messages

- `POST /v1/messages` Send a text (https://vocatech.com/developers/reference/messages#send-message)
- `GET /v1/messages` List messages (https://vocatech.com/developers/reference/messages#list-messages)
- `GET /v1/messages/queue` See the send queue (https://vocatech.com/developers/reference/messages#list-message-queue)
- `DELETE /v1/messages/queue` Cancel every queued message (https://vocatech.com/developers/reference/messages#cancel-all-queued-messages)
- `DELETE /v1/messages/queue/{id}` Cancel one queued message (https://vocatech.com/developers/reference/messages#cancel-queued-message)

### Faxes

- `POST /v1/faxes` Send a fax (https://vocatech.com/developers/reference/faxes#send-fax)
- `GET /v1/faxes` List faxes (https://vocatech.com/developers/reference/faxes#list-faxes)

### Media

- `GET /v1/media/{id}` Get a media link (https://vocatech.com/developers/reference/media#get-media)

### Callers

- `GET /v1/callers/{number}` Look up a caller (https://vocatech.com/developers/reference/callers#get-caller)

### Contacts

- `GET /v1/contacts/fields` List contact fields (https://vocatech.com/developers/reference/contacts#list-contact-fields)
- `GET /v1/contacts` List contacts (https://vocatech.com/developers/reference/contacts#list-contacts)
- `POST /v1/contacts` Create or update contacts (https://vocatech.com/developers/reference/contacts#upsert-contacts)
- `DELETE /v1/contacts` Delete contacts (https://vocatech.com/developers/reference/contacts#delete-contacts)
- `GET /v1/contacts/lookup` Find contacts by phone number (https://vocatech.com/developers/reference/contacts#lookup-contacts)

### AI agents

- `GET /v1/agents` List AI agents (https://vocatech.com/developers/reference/ai-agents#list-agents)
- `POST /v1/agents/{id}/calls` Place an outgoing AI agent call (https://vocatech.com/developers/reference/ai-agents#place-agent-call)

### Support

- `POST /v1/support/tickets` Open a support ticket (https://vocatech.com/developers/reference/support#create-support-ticket)

### Knowledge

- `GET /v1/knowledge/answer` Ask the knowledge base (https://vocatech.com/developers/reference/knowledge#ask-knowledge)
- `POST /v1/knowledge/answer` Ask the knowledge base (POST) (https://vocatech.com/developers/reference/knowledge#ask-knowledge-post)

### Old paths

- `GET /v1/reports/calls` List calls (old path) (https://vocatech.com/developers/reference/old-paths#list-calls-legacy)
- `GET /v1/reports/messages` List messages (old path) (https://vocatech.com/developers/reference/old-paths#list-messages-legacy)

### Webhook endpoints

- `GET /v1/webhooks` List webhook endpoints (https://vocatech.com/developers/reference/webhooks#list-webhooks)
- `POST /v1/webhooks` Create a webhook endpoint (https://vocatech.com/developers/reference/webhooks#create-webhook)
- `GET /v1/webhooks/failures` List failed deliveries (https://vocatech.com/developers/reference/webhooks#list-webhook-failures)
- `GET /v1/webhooks/{id}` Get a webhook endpoint (https://vocatech.com/developers/reference/webhooks#get-webhook)
- `PATCH /v1/webhooks/{id}` Update a webhook endpoint (https://vocatech.com/developers/reference/webhooks#update-webhook)
- `DELETE /v1/webhooks/{id}` Delete a webhook endpoint (https://vocatech.com/developers/reference/webhooks#delete-webhook)
- `POST /v1/webhooks/{id}/test` Send a test delivery (https://vocatech.com/developers/reference/webhooks#test-webhook)

## Every webhook event

- `call.started` An extension started ringing or dialing (https://vocatech.com/developers/reference/events#call-started)
- `call.answered` An extension answered (https://vocatech.com/developers/reference/events#call-answered)
- `call.ended` A call ended (https://vocatech.com/developers/reference/events#call-ended)
- `call.transcription` A recorded call has its transcript (https://vocatech.com/developers/reference/events#call-transcription)
- `message.sent` A text went out through the API or an automation (https://vocatech.com/developers/reference/events#message-sent)
- `message.received` A text or WhatsApp message arrived (https://vocatech.com/developers/reference/events#message-received)
- `message.status_updated` The carrier reported on a text (https://vocatech.com/developers/reference/events#message-statusupdated)
- `fax.sent` An outgoing fax is in progress (https://vocatech.com/developers/reference/events#fax-sent)
- `fax.received` An incoming fax is arriving (https://vocatech.com/developers/reference/events#fax-received)
- `fax.delivered` A fax completed (https://vocatech.com/developers/reference/events#fax-delivered)
- `fax.failed` A fax failed (https://vocatech.com/developers/reference/events#fax-failed)

## Scopes

The main key reaches every route. A key can be narrowed to one or more scopes. A scoped key that calls a route outside its scopes gets `403` with `scopes` and `path` in the body. Every key may call `GET /v1/whoami`.

| Scope | Reaches |
|---|---|
| `messaging` | `/v1/messages` (send, history, queue), `/v1/reports/messages`, `/v1/media` (message attachments only), `/v1/webhooks` (message events only) |
| `agents` | `/v1/agents`, `/v1/callers`, `/v1/contacts/lookup` |
| `support` | `/v1/support`, `/v1/knowledge` |
| `knowledge` | `/v1/knowledge` |

A scoped key sees and changes only the webhooks it created, and only their failures.

## IP allow-list

A key can be locked to exact IPv4 or IPv6 addresses in the portal. Ranges are not supported. An empty list allows any address. A request from any other address gets `403` with `client_ip` in the body. Call `GET /v1/whoami` first and enter the address exactly as it shows `client_ip`.

## Rate limits

Every key has a separate counter for each kind of request. Windows are fixed and run on UTC: a minute, an hour and a day each reset on the clock. Refused requests count too.

| Counter | Requests | Per minute | Per hour | Per day |
|---|---|---|---|---|
| `default` | everything not listed below | 600 | 3,600 | 100,000 |
| `reports` | `GET /v1/calls`, `GET /v1/messages`, `/v1/reports/*` | 300 | 3,000 | 100,000 |
| `messages` | `POST /v1/messages` on a main key | 20 | | 200 |
| `faxes` | `POST /v1/faxes`, per account | 3 | | 50 faxes sent |
| `dial` | `POST /v1/agents/{id}/calls` | 10 | | 500 |
| `tickets` | `POST /v1/support/tickets` | 10 | | 200 |
| `knowledge` | `GET` or `POST /v1/knowledge/answer` | 30 | | 2,000 |

- A metered API Messaging key (scope `messaging`) is not held to 20 a minute and 200 a day. Its sends are accepted and paced by its own queue: message parts per minute (60 by default) and messages per day (5,000 by default), set per key by Vocatech. The backstop of 100,000 requests a day still applies. A company-wide cap of 960 T-Mobile-bound messages a day, shared with the company's broadcasts, also paces the queue (`limits.tmobile_day_allowance` in `GET /v1/messages/queue`); T-Mobile's day resets at midnight Pacific, and every number is looked up for its carrier before the send.
- A metered API Messaging key's sends are paid per message and are outside the account's included texting limit (50 texts an hour and 150 a day as standard): that limit never holds them back and they never use it up. Texts sent with a main key count toward it, together with the texts the account's people send, and past it the send is answered `429`.
- A request without a live key (missing, wrong or turned off) is counted by source address: 60 a minute and 600 an hour.
- Faxes are counted per account, not per key: 3 requests a minute, and 50 faxes actually sent a day, counted by the Eastern calendar day (a dry run or a refused request does not count toward the 50). A fax is a PDF of at most 25 pages. The fax API is for sending your own documents; it is not for marketing or bulk faxing.
- Each account also has a monthly allowance of outbound fax pages, 1,000 a month for each fax line on the account (unless Vocatech set another figure), pooled across the lines, so an account with three lines may send 3,000 pages a month from any one of them. It is the same count the portal and email to fax keep. Past it, `POST /v1/faxes` answers `429`.
- Every `429` from a counter carries a `Retry-After` header: the seconds until that window resets.

```json
{"error":"Rate limit exceeded","period":"minute","limit":20,"counter":"messages","retry_after":37}
```

## Errors

| Status | Meaning |
|---|---|
| `400` | Bad input: a date, a number, a window over 366 days, an end before a start. |
| `401` | No key, or a key that is wrong or turned off. |
| `403` | Outside the key's scopes, an address not on the allow-list, or the request was refused (for example, `from` is not your number, or the recipient opted out). |
| `404` | Not found, or not yours. |
| `409` | A conflict: a queued message already sending or finished, or a support ticket being worked (retry after `Retry-After`). |
| `410` | The media file is no longer available. |
| `413` | A fax document over 20 MB. |
| `422` | A field failed validation. The message says which. |
| `429` | A rate limit. See above. |
| `502` | A service we rely on answered and refused. |
| `503` | Retry. The key could not be checked just now (`Retry-After: 5`), or a service could not be reached. |
| `504` | A text send was not confirmed. It may have gone out. Check `GET /v1/messages` before you retry. |

Errors come in a few shapes:

- **Key errors** (`401`, the key-check `503`): `{"error":"Unauthorized","message":"Missing or invalid Authorization header."}`
- **Access errors** (`403` scope or address): `{"error":"Forbidden","message":"...","scopes":"messaging","path":"/v1/calls"}` or `{"error":"Forbidden","message":"...","client_ip":"198.51.100.7"}`
- **Rate limits** (`429`): see above.
- **Action errors**, from a route that ran: `{"error":{"code":404,"type":"NOT_FOUND","message":"Attachment not found."}}`. `type` is `BAD_REQUEST`, `UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, `CONFLICT`, `UNPROCESSABLE_ENTITY`, `RATE_LIMIT_EXCEEDED`, `SERVER_ERROR`, or `ERROR` for any other status.
- **Route errors**, for an unknown path, a wrong method, a webhook that is not found, or an unexpected fault: `{"statusCode":404,"error":{"type":"RESOURCE_NOT_FOUND","description":"Webhook not found."}}`

## Conventions

- Times in responses are UTC, ISO 8601, like `2026-09-24T14:05:09.000Z`, unless a field says otherwise.
- Report windows (`start_date`, `end_date`) are local days in `timezone`, which defaults to `America/New_York`.
- Phone numbers in reports are digits without the leading 1, like `7185550142`. Message and fax webhooks use E.164, like `+17185550142`.
- Lists page with `page` (from 1) and `limit`. `meta` carries the totals.
- Durations are in seconds.
- `info.version` is the date this reference was last checked against the code. New fields can appear in responses. Build clients that ignore fields they do not know.


## The file behind this reference

The whole reference is one OpenAPI 3.1 file, the API’s own, kept with its code, at https://vocatech.com/developers/openapi.yml. The whole reference as Markdown is https://vocatech.com/developers/reference.md.

---

On the site at https://vocatech.com/developers/reference/getting-started

# Getting started

Check your key and the address we see you coming from. Start here, and come back here when a request answers `401` or `403`.

## Check your key

`GET /v1/whoami`

Shows the address we see your request coming from, your company id, and whether this key is locked to addresses.

Call it first when you set up a key, and before you add an IP allow-list, so you enter the right address. It is also the first thing to check when requests start answering `403`.

Every key may call this route, whatever its scopes. `client_ip` is the address the allow-list is checked against. `forwarded_for` is the whole `X-Forwarded-For` chain we received, for troubleshooting.

### Request

**curl**

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

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/whoami", {
  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/whoami",
    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/whoami');

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

### Answers

#### 200

Your key works. This is what we see.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_ip` | string | yes | The address we see your request coming from. The allow-list is checked against this address. |
| `forwarded_for` | array of strings | yes | The `X-Forwarded-For` chain we received, left to right, for troubleshooting. |
| `company_id` | integer | yes | Your company id. |
| `ip_restricted` | boolean | yes | True when this key is locked to addresses. |
| `allowed_ips` | array of strings | yes | The addresses this key is locked to. Empty means any address. |

**Example**

```json
{
  "client_ip": "203.0.113.24",
  "forwarded_for": [
    "203.0.113.24"
  ],
  "company_id": 1234567890,
  "ip_restricted": true,
  "allowed_ips": [
    "203.0.113.24",
    "2001:db8::10"
  ]
}
```

#### 401

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

#### 403

The request came from an address not on the key's allow-list.

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

---

On the site at https://vocatech.com/developers/reference/calls

# Calls

Your call history. Each call carries its journey through menus, groups and people, with AI summaries, transcripts and recording links.

## List calls

`GET /v1/calls`

Your calls in a date window, newest first, one entry per call. Each call carries its `journey`: every leg it took through menus, groups, queues and people, with the AI summary, the transcript and a recording link when the leg was recorded.

**Dates.** Send `start_date` and `end_date` together, as local days in `timezone`. Send neither for today. A single date is ignored and you get today. At most 366 days per request; for more history, walk it a year at a time.

**Paging.** `limit` is 500 by default and at most 500. Calls that tie on the sort key keep a stable order, so paging is safe.

**Filters.** `status`, `extension` and `type` match single legs. A call is listed when one of its legs matches, and its `journey` then shows only the legs that match. `search` looks in the call id, the other party's name and number, your number and the extensions.

**Fields to know.** `status` is the status of the first leg in `journey`, not the call's final outcome: a call a menu answered and a person then missed reads `answered`. `extension` and `extension_name` name the last person who held the call. `recording_url` points at `GET /v1/media/rec_<n>`, which returns a signed link.

**Recordings in parts.** A call that is parked and picked up again is recorded in parts: part 1 up to the park, part 2 after the pick-up, rarely a part 3. Each leg lists its recordings in `recordings`, oldest first, each with its own `url` (`GET /v1/media/rec_<n>?part=N`) and its own start time and length. `recording_url`, `summary` and `transcription` are the latest part's. Nearly every leg has one part. The transcript of each part also comes by webhook (`call.transcription`, one event per part).

A search that runs longer than 30 seconds is stopped with `500`. Narrow the window or add filters.

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

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/calls \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "start_date=2026-09-21" \
  --data-urlencode "end_date=2026-09-22" \
  --data-urlencode "timezone=America/New_York"
```

**Node**

```javascript
const params = new URLSearchParams({
  start_date: "2026-09-21",
  end_date: "2026-09-22",
  timezone: "America/New_York",
});

const res = await fetch(`https://api.vocatech.com/v1/calls?${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/calls",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "start_date": "2026-09-21",
        "end_date": "2026-09-22",
        "timezone": "America/New_York",
    },
    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/calls', [
    'query' => [
        'start_date' => '2026-09-21',
        'end_date' => '2026-09-22',
        'timezone' => 'America/New_York',
    ],
]);

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`, as each call's `direction` reads. `voicemail` matches the rare calls stored that way. One of `any`, `incoming`, `outgoing` or `voicemail`. Default `any`. |
| `status` | query | string |  | Only calls with a leg in this status, for example `answered` or `missed`. `missed` never returns an outgoing call. Default `any`. For example `missed`. |
| `extension` | query | string |  | Only calls with a leg on this extension (exact match). For example `104`. |
| `type` | query | string |  | Only calls with a leg of this type, for example `user`, `auto_attendant`, `hunt_group` or `call_center`. Default `any`. |
| `search` | query | string |  | Text to find in the call id, the other party's name or number, your number, or an extension. |
| `sort` | query | string |  | Order by start time or by duration. Calls that tie keep a stable order, so paging is safe. One of `day_desc`, `day_asc`, `duration_desc` or `duration_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 calls in the window.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | object | yes | The filters that were applied, echoed back. `extension` and `type` are applied but not echoed. |
| `query.company_id` | integer |  |  |
| `query.start_date` | string (date) |  | The first local day used. |
| `query.end_date` | string (date) |  | The last local day used. |
| `query.direction` | string |  |  |
| `query.status` | string |  |  |
| `query.search` | string |  |  |
| `query.timezone` | string |  | The time zone used. |
| `calls` | array of objects | yes |  |
| `calls[].call_id` | string | yes | The call's id. |
| `calls[].direction` | string | yes | `incoming` or `outgoing`. A call your staff placed through the dial-out portal reads `outgoing`. A few calls read `voicemail`. |
| `calls[].status` | string | yes | The status of the first leg in `journey`, for example `answered` or `missed`. It is not the call's final outcome: a call a menu answered and a person then missed reads `answered`. Read the legs to see who answered. |
| `calls[].extension` | string or null | yes | The last person who held the call. When no person did, the first extension on it. |
| `calls[].extension_name` | string or null | yes | That extension's name, written First Last. |
| `calls[].remote_name` | string | yes | The other party's name from caller ID, written First Last. Can be empty. |
| `calls[].remote_number` | string | yes | The other party's number, digits without the leading 1. Can be empty. |
| `calls[].group_number` | string | yes | Your number on the call, digits without the leading 1. Can be empty. |
| `calls[].start_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].end_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].duration` | integer | yes | The call's length in seconds. |
| `calls[].journey` | array of objects | yes | Every leg, in order. When you filter by `status`, `extension` or `type`, only the matching legs are listed. |
| `calls[].journey[].order` | integer | yes | Position in the journey, from 1. |
| `calls[].journey[].type` | string | yes | What handled this leg, as the phone system wrote it. For example `user` (a person), `auto_attendant`, `hunt_group` or `call_center`. |
| `calls[].journey[].extension_name` | string or null | yes | The name of the person or feature on this leg, as set in the portal, written First Last. |
| `calls[].journey[].extension` | string or null | yes | The extension on this leg. |
| `calls[].journey[].start_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].journey[].end_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].journey[].duration` | integer | yes | Seconds on this leg. |
| `calls[].journey[].summary` | string or null | yes | The AI summary of this leg's recording (its latest part). Null when there is none, and when the leg's only recording belongs to another call leg. |
| `calls[].journey[].transcription` | string or null | yes | The transcript of this leg's recording (its latest part), with speaker labels. Null when there is none, and when the leg's only recording belongs to another call leg. |
| `calls[].journey[].recording_url` | string (uri) or null | yes | Where to fetch this leg's recording (its latest part). Call it with your key (`GET /v1/media/rec_<n>`) to get a signed link. Null when the leg was not recorded. |
| `calls[].journey[].recordings` | array of objects | yes | Every recording of this leg, oldest first. A call parked and picked up again has two parts (rarely three). Nearly every recorded leg has one. Empty when the leg was not recorded. |
| `calls[].journey[].recordings[].part` | integer | yes | This part's place, from 1, oldest first. |
| `calls[].journey[].recordings[].parts` | integer | yes | How many parts the leg's recording has. |
| `calls[].journey[].recordings[].recording_id` | integer | yes | The recording's id. The `call.transcription` event of this part carries the same id. |
| `calls[].journey[].recordings[].start_time` | string (date-time) or null | yes | When this part started. UTC, ISO 8601 with milliseconds. |
| `calls[].journey[].recordings[].duration` | integer or null | yes | Seconds. |
| `calls[].journey[].recordings[].url` | string (uri) or null | yes | Where to fetch this part (`GET /v1/media/rec_<n>?part=N`) with your key. Null while the part's file is not stored yet. |
| `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_calls` | integer | yes | Calls that match, across all pages. |

**Example**

```json
{
  "query": {
    "company_id": 1234567890,
    "start_date": "2026-09-21",
    "end_date": "2026-09-22",
    "direction": "any",
    "status": "any",
    "search": "",
    "timezone": "America/New_York"
  },
  "calls": [
    {
      "call_id": "3c9d7e1f-2a4b-4c6d-8e0f-1a2b3c4d5e6f",
      "direction": "outgoing",
      "status": "answered",
      "extension": "104",
      "extension_name": "Maya Chen",
      "remote_name": "",
      "remote_number": "7185550142",
      "group_number": "7185550100",
      "start_time": "2026-09-22T15:40:02.000Z",
      "end_time": "2026-09-22T15:41:10.000Z",
      "duration": 68,
      "journey": [
        {
          "order": 1,
          "type": "user",
          "extension_name": "Maya Chen",
          "extension": "104",
          "start_time": "2026-09-22T15:40:02.000Z",
          "end_time": "2026-09-22T15:41:10.000Z",
          "duration": 68,
          "summary": null,
          "transcription": null,
          "recording_url": null,
          "recordings": []
        }
      ]
    },
    {
      "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
      "direction": "incoming",
      "status": "answered",
      "extension": "104",
      "extension_name": "Maya Chen",
      "remote_name": "Jordan Reyes",
      "remote_number": "7185550142",
      "group_number": "7185550100",
      "start_time": "2026-09-21T14:03:12.000Z",
      "end_time": "2026-09-21T14:09:40.000Z",
      "duration": 388,
      "journey": [
        {
          "order": 1,
          "type": "auto_attendant",
          "extension_name": "Main Menu",
          "extension": "100",
          "start_time": "2026-09-21T14:03:12.000Z",
          "end_time": "2026-09-21T14:03:31.000Z",
          "duration": 19,
          "summary": null,
          "transcription": null,
          "recording_url": null,
          "recordings": []
        },
        {
          "order": 2,
          "type": "user",
          "extension_name": "Maya Chen",
          "extension": "104",
          "start_time": "2026-09-21T14:03:31.000Z",
          "end_time": "2026-09-21T14:09:40.000Z",
          "duration": 369,
          "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow.",
          "transcription": "Speaker 1: Good morning, thanks for calling. This is Maya.\nSpeaker 2: Hi Maya, this is Jordan Reyes. I need to move my Thursday appointment.\nSpeaker 1: No problem. Would Friday at 10 AM work?\nSpeaker 2: Friday at 10 is perfect. Thank you.",
          "recording_url": "https://api.vocatech.com/v1/media/rec_48213907",
          "recordings": [
            {
              "part": 1,
              "parts": 2,
              "recording_id": 10577640,
              "start_time": "2026-09-21T14:03:32.000Z",
              "duration": 41,
              "url": "https://api.vocatech.com/v1/media/rec_48213907?part=1"
            },
            {
              "part": 2,
              "parts": 2,
              "recording_id": 10577702,
              "start_time": "2026-09-21T14:04:31.000Z",
              "duration": 309,
              "url": "https://api.vocatech.com/v1/media/rec_48213907?part=2"
            }
          ]
        }
      ]
    }
  ],
  "meta": {
    "page": 1,
    "limit": 500,
    "total_pages": 1,
    "total_calls": 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. Use YYYY-MM-DD and a valid IANA timezone."
  }
}
```

**Example**

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

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Ask for at most 366 days at a time (this request covers 400). For more history, walk it a year at a time."
  }
}
```

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

---

On the site at https://vocatech.com/developers/reference/messages

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

---

On the site at https://vocatech.com/developers/reference/faxes

# Faxes

Send faxes from your fax lines and read your fax history.

## Send a fax

`POST /v1/faxes`

Sends one fax from one of your fax lines. Send the document as a base64-encoded PDF of at most 25 pages, up to 20 MB before encoding. Another file type, a longer PDF, or a PDF whose pages cannot be read is refused with `400`.

**Limits.** Per account: 3 requests a minute and 50 faxes sent a day (Eastern calendar day). The fax API is for sending your own documents, not for marketing or bulk faxing.

`from` must be an active fax line on your account. `to` must be a valid 10-digit US or Canadian number. Premium numbers (900, 976), service codes and area codes that are other countries are refused.

The answer is `201` with a `fax_id` once the fax carrier has the job. Sending takes minutes. Read the outcome in `GET /v1/faxes` (`status`), and fetch the document with `GET /v1/media/{fax_id}`.

**Dry run.** `test: true` runs every check (the numbers, the file and its pages, your line, the monthly limit) and sends nothing. It counts toward the 3 a minute, never toward the 50 a day.

**Monthly limit.** Each account may send 1,000 fax pages a month for each fax line on it (unless Vocatech set another figure), pooled across its lines, counted by the Eastern calendar month across the API, the portal and email to fax. Past it, the answer is `429`. Another fax line brings another 1,000 pages a month; contact Vocatech support for a line or a different figure.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/faxes \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "7185550100",
    "to": "7185550142",
    "filename": "intake-form.pdf",
    "subject": "Intake form",
    "file": "'"$(base64 < intake-form.pdf | tr -d '\n')"'"
  }'
```

**Node**

```javascript
import { readFile } from "node:fs/promises";

const res = await fetch("https://api.vocatech.com/v1/faxes", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    from: "7185550100",
    to: "7185550142",
    filename: "intake-form.pdf",
    subject: "Intake form",
    file: (await readFile("intake-form.pdf")).toString("base64"),
  }),
});

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

**Python**

```python
import base64
import requests

with open("intake-form.pdf", "rb") as f:
    document = base64.b64encode(f.read()).decode()

r = requests.post(
    "https://api.vocatech.com/v1/faxes",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "from": "7185550100",
        "to": "7185550142",
        "filename": "intake-form.pdf",
        "subject": "Intake form",
        "file": document,
    },
    timeout=60,
)
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/faxes', [
    'json' => [
        'from' => '7185550100',
        'to' => '7185550142',
        'filename' => 'intake-form.pdf',
        'subject' => 'Intake form',
        'file' => base64_encode(file_get_contents('intake-form.pdf')),
    ],
]);

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

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `from` | string | yes | One of your active fax lines, 10 digits. Punctuation and a leading 1 are ignored. For example `7185550100`. |
| `to` | string | yes | The recipient's fax number: a valid 10-digit US or Canadian number. Premium numbers (900, 976) and area codes that are other countries are refused. For example `7185550142`. |
| `file` | string (base64) | yes | The document as base64. Send a PDF. Up to 20 MB before encoding. |
| `filename` | string |  | The document's file name. Default `document.pdf`. |
| `subject` | string |  | A subject for your records. It shows in `GET /v1/faxes`. |
| `recipient_name` | string |  | The recipient's name, passed to the fax carrier. |
| `test` | boolean |  | A dry run: check everything and send nothing. Default `false`. |

**Example**

```json
{
  "from": "7185550100",
  "to": "7185550142",
  "filename": "intake-form.pdf",
  "subject": "Intake form",
  "recipient_name": "Jordan Reyes",
  "file": "JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2c+PgplbmRvYmoK"
}
```

### Answers

#### 200

A dry run passed every check. Nothing was sent.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | Always `test`. |
| `fax` | object | yes |  |
| `fax.from` | string | yes |  |
| `fax.to` | string | yes |  |
| `fax.filename` | string | yes |  |
| `fax.fax_line` | string | yes | The fax line's name in the portal. |
| `fax.valid` | boolean | yes | Always `true`. |

**Example**

```json
{
  "status": "test",
  "fax": {
    "from": "7185550100",
    "to": "7185550142",
    "filename": "intake-form.pdf",
    "fax_line": "Front Desk Fax",
    "valid": true
  }
}
```

#### 201

The fax carrier has the job.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `status` | string | yes | Always `queued`. |
| `fax` | object | yes |  |
| `fax.fax_id` | string | yes | The fax's id. Use it with `GET /v1/media/{id}`. Matches `^fax_[0-9]+$`. |
| `fax.faxage_job_id` | string or null | yes | The fax carrier's job id. |
| `fax.from` | string | yes |  |
| `fax.to` | string | yes |  |
| `fax.filename` | string | yes |  |
| `fax.subject` | string or null | yes |  |
| `fax.created_at` | string | yes | When we took it, ISO 8601 with the Eastern offset. |

**Example**

```json
{
  "status": "queued",
  "fax": {
    "fax_id": "fax_77120",
    "faxage_job_id": "93125501",
    "from": "7185550100",
    "to": "7185550142",
    "filename": "intake-form.pdf",
    "subject": "Intake form",
    "created_at": "2026-09-24T10:05:09-04:00"
  }
}
```

#### 400

A missing field, a number that is not 10 digits, a destination we refuse, or a file that is not base64.

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Missing required fields: from, to, and file are required."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Invalid to number format. Expected 10 digits."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "to may not be a premium-rate number (900)."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "to is outside the US and Canada (area code 876). International numbers are not dialed through the API."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "File must be base64-encoded."
  }
}
```

#### 401

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

#### 403

Refused: faxing is not on for your account, `from` is not an active fax line of yours, the key's scopes, or the address.

**Faxing is off**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Faxing is not enabled for this company."
  }
}
```

**Not your fax line**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Invalid from number. Must be an active fax line assigned to your company."
  }
}
```

#### 413

The document is over 20 MB.

**Example**

```json
{
  "error": {
    "code": 413,
    "type": "ERROR",
    "message": "The document is too large. The limit is 20 MB."
  }
}
```

#### 429

The `faxes` counter is full (3 a minute per account), the account has sent its 50 faxes for the day, or the account's monthly page limit is used. The day and monthly limits answer with an action error and no `Retry-After`.

**Example**

```json
{
  "error": {
    "code": 429,
    "type": "RATE_LIMIT_EXCEEDED",
    "message": "This account has used its 3,000 outbound fax pages for the month, 1,000 pages for each of its 3 fax lines. Contact Vocatech support to raise it."
  }
}
```

#### 500

The fax carrier refused the job or could not be reached. The message is the carrier's error, or the connection error.

**Example**

```json
{
  "error": {
    "code": 500,
    "type": "SERVER_ERROR",
    "message": "cURL error 28: Operation timed out after 60000 milliseconds with 0 bytes received"
  }
}
```

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

## List faxes

`GET /v1/faxes`

Your sent and received faxes in a date window, newest first.

**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. Faxes that tie on time keep a stable order.

`has_document` says whether `GET /v1/media/{fax_id}` can return the PDF. Answers `403` when faxing is not on for your account.

**Known issue.** Fax times are stored in Eastern time. `sent_at` shows that Eastern clock time with a `Z`, so it reads 4 or 5 hours early, and the date window is compared the same way: a fax from midnight to about 4 AM Eastern lands in the previous day's window.

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

### Request

**curl**

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

**Node**

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

const res = await fetch(`https://api.vocatech.com/v1/faxes?${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/faxes",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "start_date": "2026-09-24",
        "end_date": "2026-09-24",
    },
    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/faxes', [
    'query' => [
        'start_date' => '2026-09-24',
        'end_date' => '2026-09-24',
    ],
]);

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` (received) or `outgoing` (sent). One of `any`, `incoming` or `outgoing`. Default `any`. |
| `status` | query | string |  | Only faxes in this status. One of `any`, `in_progress`, `completed`, `failed` or `restricted`. Default `any`. |
| `search` | query | string |  | Text to find in the other party's number or the subject. |
| `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. |

### Answers

#### 200

The faxes 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.search` | string |  |  |
| `query.timezone` | string |  |  |
| `faxes` | array of objects | yes |  |
| `faxes[].fax_id` | string | yes | Use it with `GET /v1/media/{id}` to get the document. Matches `^fax_[0-9]+$`. |
| `faxes[].direction` | string | yes | One of `incoming` or `outgoing`. |
| `faxes[].status` | string | yes | One of `in_progress`, `completed`, `failed`, `restricted` or `unknown`. |
| `faxes[].from` | string or null | yes | Digits without the leading 1. |
| `faxes[].to` | string or null | yes | Digits without the leading 1. |
| `faxes[].pages` | integer or null | yes |  |
| `faxes[].subject` | string or null | yes |  |
| `faxes[].faxage_job_id` | string or null | yes | The fax carrier's job id. |
| `faxes[].has_document` | boolean | yes | True when `GET /v1/media/{fax_id}` can return the PDF. |
| `faxes[].sent_at` | string (date-time) or null | yes | When the fax was logged. Known issue: this is Eastern time written with a `Z`, so it reads 4 or 5 hours early. |
| `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_faxes` | integer | yes | Faxes that match, across all pages. |

**Example**

```json
{
  "query": {
    "company_id": 1234567890,
    "start_date": "2026-09-24",
    "end_date": "2026-09-24",
    "direction": "any",
    "status": "any",
    "search": "",
    "timezone": "America/New_York"
  },
  "faxes": [
    {
      "fax_id": "fax_77135",
      "direction": "incoming",
      "status": "completed",
      "from": "2125550187",
      "to": "7185550100",
      "pages": 2,
      "subject": null,
      "faxage_job_id": "93125877",
      "has_document": true,
      "sent_at": "2026-09-24T11:40:00.000Z"
    },
    {
      "fax_id": "fax_77120",
      "direction": "outgoing",
      "status": "completed",
      "from": "7185550100",
      "to": "7185550142",
      "pages": 3,
      "subject": "Intake form",
      "faxage_job_id": "93125501",
      "has_document": true,
      "sent_at": "2026-09-24T10:05:09.000Z"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 500,
    "total_pages": 1,
    "total_faxes": 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."
  }
}
```

#### 401

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

#### 403

Faxing is not on for your account, or the key's scopes or address.

**Example**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Faxing is not enabled for this company."
  }
}
```

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

---

On the site at https://vocatech.com/developers/reference/media

# Media

Short-lived links to message attachments, call recordings and fax documents.

## Get a media link

`GET /v1/media/{id}`

Returns a short-lived link to one file:

- `att_<n>`: a message attachment, from `GET /v1/messages` or a message webhook. A bare number is read as an attachment too.
- `rec_<n>`: the recording of one call leg, from `recording_url` or `recordings` in `GET /v1/calls`.
- `fax_<n>`: a fax document, from `GET /v1/faxes`.

`url` is a signed link, good for 30 minutes (`expires_at`). Download the file with a plain `GET`: the signed link needs no key. Ask again for a fresh link.

A call parked and picked up again is recorded in parts. `rec_<n>` answers the latest part; `rec_<n>?part=N` answers part N (from 1). The answer names the part it holds (`part` of `parts`) with its `recording_id` and `start_time`. A part that does not exist answers `404`, and the message says how many parts there are.

Two exceptions return a plain link with no `expires_at`: an attachment on a message you sent (you get back the URL you sent it with), and a picture received in the last few minutes, before its stored copy is ready.

Anything that is not yours answers `404`. A key with the `messaging` scope may fetch attachments only.

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

### Request

**curl**

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

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/media/rec_48213907", {
  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/media/rec_48213907",
    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/media/rec_48213907');

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | string | yes | `att_<n>`, `rec_<n>` or `fax_<n>`. Matches `^(att_\|rec_\|fax_)?[0-9]+$`. |
| `part` | query | integer |  | For a recording (`rec_<n>`) only. Which part of a recording saved in parts, from 1, oldest first. Leave it out for the latest part. `recordings` in `GET /v1/calls` gives each part's link. At least 1. For example `1`. |

### Answers

#### 200

A link to the file.

**When `type` is `attachment`**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Matches `^att_[0-9]+$`. |
| `type` | string | yes | Always `attachment`. |
| `source` | string | yes | Where the message came from. One of `sms` or `whatsapp`. |
| `content_type` | string or null | yes |  |
| `filename` | string or null | yes |  |
| `size` | integer or null | yes | Bytes, when known. |
| `url` | string (uri) | yes | A signed link, good for 30 minutes. For an attachment on a message you sent, the URL you sent it with. |
| `expires_at` | string (date-time) |  | When the signed link stops working. Left out when `url` is not a signed link. |

**When `type` is `recording`**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Matches `^rec_[0-9]+$`. |
| `type` | string | yes | Always `recording`. |
| `content_type` | string | yes | `audio/` and the file's extension, for example `audio/mp3` or `audio/wav`. |
| `format` | string | yes | The file's extension, for example `mp3`. |
| `duration` | integer or null | yes | Seconds. |
| `url` | string (uri) | yes | A signed link, good for 30 minutes. |
| `expires_at` | string (date-time) | yes |  |
| `part` | integer | yes | Which part this is, from 1. Without `?part=`, the latest part. |
| `parts` | integer | yes | How many parts the recording has. Nearly always 1. |
| `recording_id` | integer | yes | The recording's id, as in `recordings` and the `call.transcription` event. |
| `start_time` | string (date-time) or null | yes | When this part started. UTC, ISO 8601 with milliseconds. |

**When `type` is `fax`**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | Matches `^fax_[0-9]+$`. |
| `type` | string | yes | Always `fax`. |
| `content_type` | string | yes | Always `application/pdf`. |
| `format` | string | yes | Always `pdf`. |
| `pages` | integer or null | yes |  |
| `url` | string (uri) | yes | A signed link, good for 30 minutes. |
| `expires_at` | string (date-time) | yes |  |

**Message attachment**

```json
{
  "id": "att_90211",
  "type": "attachment",
  "source": "sms",
  "content_type": "image/jpeg",
  "filename": "photo-0921.jpg",
  "size": 184233,
  "url": "https://files.example.com/signed/att_90211.jpg?sig=7c2e9a41",
  "expires_at": "2026-09-24T14:35:09.000Z"
}
```

**Call recording (the latest part)**

```json
{
  "id": "rec_48213907",
  "type": "recording",
  "content_type": "audio/mp3",
  "format": "mp3",
  "duration": 309,
  "url": "https://files.example.com/signed/rec_48213907.mp3?sig=b71f0c9e",
  "expires_at": "2026-09-24T14:35:09.000Z",
  "part": 2,
  "parts": 2,
  "recording_id": 10577702,
  "start_time": "2026-09-21T14:04:31.000Z"
}
```

**Fax document**

```json
{
  "id": "fax_77120",
  "type": "fax",
  "content_type": "application/pdf",
  "format": "pdf",
  "pages": 3,
  "url": "https://files.example.com/signed/fax_77120.pdf?sig=3d5a8c20",
  "expires_at": "2026-09-24T14:35:09.000Z"
}
```

#### 400

The id is not in a form we know.

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Invalid recording segment ID."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Invalid attachment ID."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Invalid fax ID."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Invalid recording part. Use a whole number from 1."
  }
}
```

#### 401

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

#### 403

A `messaging` key asked for a recording or a fax, the account has no call recording set up, or the key's scopes or address.

**Messaging key, recording id**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "This API key is limited to messaging and cannot fetch recording media."
  }
}
```

**No call recording**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Company not configured for call recordings."
  }
}
```

#### 404

Not found, not yours, or not ready yet.

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "Attachment not found."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "Recording not found."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "Recording file not yet available."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "Recording part not found. This recording has 2 parts."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "Fax document not found."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "Fax document not yet available."
  }
}
```

#### 410

The attachment's file is no longer available.

**Example**

```json
{
  "error": {
    "code": 410,
    "type": "ERROR",
    "message": "Media file no longer available."
  }
}
```

#### 429

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

#### 500

Fax documents are not set up on our side.

**Example**

```json
{
  "error": {
    "code": 500,
    "type": "SERVER_ERROR",
    "message": "Fax service not configured."
  }
}
```

#### 503

Our storage refused us. The file exists. Contact support if it lasts. Or the key could not be checked.

**Example**

```json
{
  "error": {
    "code": 503,
    "type": "ERROR",
    "message": "Recording storage access failed on our side. The recording exists. Contact support."
  }
}
```

---

On the site at https://vocatech.com/developers/reference/callers

# Callers

Everything your account knows about one phone number, in one answer. Built for an AI agent or a screen pop that needs context before it answers.

## Look up a caller

`GET /v1/callers/{number}`

Everything your account knows about one phone number: the contacts it belongs to (up to 5), recent calls with who handled them and the AI summary, recent texts and WhatsApp messages, and the last call each way. Built for an AI agent or a screen pop that needs context before it answers.

`number` can be written any way: punctuation and a leading 1 are ignored. It needs at least 7 digits.

Calls and messages look back `days` days and return up to `limit` of each, newest first. Transcripts are left out unless `include` asks for them; each is cut at 6,000 characters. Message text is cut at 400 characters. A cut ends in ` [cut]`.

Keys with the `agents` scope can call this route. Each request is written to your account's HIPAA access log.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/callers/7185550142 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "days=90" \
  --data-urlencode "limit=10"
```

**Node**

```javascript
const params = new URLSearchParams({
  days: "90",
  limit: "10",
});

const res = await fetch(`https://api.vocatech.com/v1/callers/7185550142?${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/callers/7185550142",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "days": 90,
        "limit": 10,
    },
    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/callers/7185550142', [
    'query' => [
        'days' => 90,
        'limit' => 10,
    ],
]);

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `number` | path | string | yes | The phone number, any format, at least 7 digits. For example `7185550142`. |
| `days` | query | integer |  | How far back to look, 1 to 365. A value outside that range is brought inside it. Default `90`. From 1 to 365. |
| `limit` | query | integer |  | Most calls, and most messages, to return: 1 to 50. A value outside that range is brought inside it. Default `10`. From 1 to 50. |
| `include` | query | string |  | What to include, separated by commas: `contacts`, `calls`, `messages`, `transcripts`. `transcripts` brings the calls with it. Unknown words are ignored. Default `contacts,calls,messages`. For example `contacts,calls,messages,transcripts`. |

### Answers

#### 200

What we know about the number.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `number` | string | yes | The number you asked about, digits only, without a leading 1. |
| `days` | integer | yes | How many days back the calls and messages go. |
| `contacts` | array of objects | yes | Contacts with this number, up to 5. Up to 5 items. |
| `contacts[].id` | integer | yes | The contact's id. |
| `contacts[].fields` | object | yes | Field name to value. The names are your contact fields (`GET /v1/contacts/fields`). Phone fields hold digits; several numbers are separated by `;`. |
| `contacts[].fields.{field}` | string or null |  | One value for each field, by its name. |
| `calls` | array of objects | yes | Newest first. |
| `calls[].call_id` | string | yes |  |
| `calls[].direction` | string | yes | `incoming` or `outgoing`. |
| `calls[].status` | string | yes | The first leg's status, as in `GET /v1/calls`. |
| `calls[].start_time` | string (date-time) or null | yes |  |
| `calls[].end_time` | string (date-time) or null | yes |  |
| `calls[].duration` | integer | yes | Seconds. |
| `calls[].remote_name` | string | yes |  |
| `calls[].remote_number` | string | yes |  |
| `calls[].our_number` | string | yes | Your number on the call. |
| `calls[].handled_by` | object or null | yes | The person on your side: the last extension that held an incoming call, or the one that placed an outgoing call. |
| `calls[].handled_by.extension` | string | yes |  |
| `calls[].handled_by.name` | string | yes |  |
| `calls[].summary` | string or null | yes | The first AI summary on the call. |
| `calls[].journey` | array of objects | yes |  |
| `calls[].journey[].order` | integer | yes |  |
| `calls[].journey[].type` | string | yes |  |
| `calls[].journey[].extension` | string or null | yes |  |
| `calls[].journey[].extension_name` | string or null | yes |  |
| `calls[].journey[].start_time` | string (date-time) or null | yes |  |
| `calls[].journey[].duration` | integer | yes | Seconds. |
| `calls[].journey[].summary` | string or null | yes | The AI summary of this leg, when recorded. |
| `calls[].journey[].transcription` | string or null |  | Only with `include=transcripts`. Cut at 6,000 characters, ending in ` [cut]`. |
| `messages` | array of objects | yes | Newest first. |
| `messages[].channel` | string | yes | One of `text` or `whatsapp`. |
| `messages[].direction` | string | yes | `incoming` or `outgoing`. |
| `messages[].body` | string | yes | Cut at 400 characters, ending in ` [cut]`. |
| `messages[].sent_time` | string | yes | UTC, ISO 8601 without milliseconds. |
| `messages[].our_number` | string | yes |  |
| `messages[].remote_number` | string | yes |  |
| `last_call` | object or null | yes | The newest call either way. |
| `last_call.call_id` | string | yes |  |
| `last_call.direction` | string | yes | `incoming` or `outgoing`. |
| `last_call.status` | string | yes | The first leg's status, as in `GET /v1/calls`. |
| `last_call.start_time` | string (date-time) or null | yes |  |
| `last_call.end_time` | string (date-time) or null | yes |  |
| `last_call.duration` | integer | yes | Seconds. |
| `last_call.remote_name` | string | yes |  |
| `last_call.remote_number` | string | yes |  |
| `last_call.our_number` | string | yes | Your number on the call. |
| `last_call.handled_by` | object or null | yes | The person on your side: the last extension that held an incoming call, or the one that placed an outgoing call. |
| `last_call.handled_by.extension` | string | yes |  |
| `last_call.handled_by.name` | string | yes |  |
| `last_call.summary` | string or null | yes | The first AI summary on the call. |
| `last_call.journey` | array of objects | yes |  |
| `last_call.journey[].order` | integer | yes |  |
| `last_call.journey[].type` | string | yes |  |
| `last_call.journey[].extension` | string or null | yes |  |
| `last_call.journey[].extension_name` | string or null | yes |  |
| `last_call.journey[].start_time` | string (date-time) or null | yes |  |
| `last_call.journey[].duration` | integer | yes | Seconds. |
| `last_call.journey[].summary` | string or null | yes | The AI summary of this leg, when recorded. |
| `last_call.journey[].transcription` | string or null |  | Only with `include=transcripts`. Cut at 6,000 characters, ending in ` [cut]`. |
| `last_incoming` | object or null | yes | The newest incoming call. |
| `last_incoming.call_id` | string | yes |  |
| `last_incoming.direction` | string | yes | `incoming` or `outgoing`. |
| `last_incoming.status` | string | yes | The first leg's status, as in `GET /v1/calls`. |
| `last_incoming.start_time` | string (date-time) or null | yes |  |
| `last_incoming.end_time` | string (date-time) or null | yes |  |
| `last_incoming.duration` | integer | yes | Seconds. |
| `last_incoming.remote_name` | string | yes |  |
| `last_incoming.remote_number` | string | yes |  |
| `last_incoming.our_number` | string | yes | Your number on the call. |
| `last_incoming.handled_by` | object or null | yes | The person on your side: the last extension that held an incoming call, or the one that placed an outgoing call. |
| `last_incoming.handled_by.extension` | string | yes |  |
| `last_incoming.handled_by.name` | string | yes |  |
| `last_incoming.summary` | string or null | yes | The first AI summary on the call. |
| `last_incoming.journey` | array of objects | yes |  |
| `last_incoming.journey[].order` | integer | yes |  |
| `last_incoming.journey[].type` | string | yes |  |
| `last_incoming.journey[].extension` | string or null | yes |  |
| `last_incoming.journey[].extension_name` | string or null | yes |  |
| `last_incoming.journey[].start_time` | string (date-time) or null | yes |  |
| `last_incoming.journey[].duration` | integer | yes | Seconds. |
| `last_incoming.journey[].summary` | string or null | yes | The AI summary of this leg, when recorded. |
| `last_incoming.journey[].transcription` | string or null |  | Only with `include=transcripts`. Cut at 6,000 characters, ending in ` [cut]`. |
| `last_outgoing` | object or null | yes | The newest outgoing call. |
| `last_outgoing.call_id` | string | yes |  |
| `last_outgoing.direction` | string | yes | `incoming` or `outgoing`. |
| `last_outgoing.status` | string | yes | The first leg's status, as in `GET /v1/calls`. |
| `last_outgoing.start_time` | string (date-time) or null | yes |  |
| `last_outgoing.end_time` | string (date-time) or null | yes |  |
| `last_outgoing.duration` | integer | yes | Seconds. |
| `last_outgoing.remote_name` | string | yes |  |
| `last_outgoing.remote_number` | string | yes |  |
| `last_outgoing.our_number` | string | yes | Your number on the call. |
| `last_outgoing.handled_by` | object or null | yes | The person on your side: the last extension that held an incoming call, or the one that placed an outgoing call. |
| `last_outgoing.handled_by.extension` | string | yes |  |
| `last_outgoing.handled_by.name` | string | yes |  |
| `last_outgoing.summary` | string or null | yes | The first AI summary on the call. |
| `last_outgoing.journey` | array of objects | yes |  |
| `last_outgoing.journey[].order` | integer | yes |  |
| `last_outgoing.journey[].type` | string | yes |  |
| `last_outgoing.journey[].extension` | string or null | yes |  |
| `last_outgoing.journey[].extension_name` | string or null | yes |  |
| `last_outgoing.journey[].start_time` | string (date-time) or null | yes |  |
| `last_outgoing.journey[].duration` | integer | yes | Seconds. |
| `last_outgoing.journey[].summary` | string or null | yes | The AI summary of this leg, when recorded. |
| `last_outgoing.journey[].transcription` | string or null |  | Only with `include=transcripts`. Cut at 6,000 characters, ending in ` [cut]`. |
| `counts` | object | yes |  |
| `counts.contacts` | integer | yes |  |
| `counts.calls` | integer | yes |  |
| `counts.messages` | integer | yes |  |

**Example**

```json
{
  "number": "7185550142",
  "days": 90,
  "contacts": [
    {
      "id": 5012,
      "fields": {
        "First Name": "Jordan",
        "Last Name": "Reyes",
        "Phone": "7185550142",
        "Email": "jordan.reyes@example.com",
        "Account Number": "A-2291"
      }
    }
  ],
  "calls": [
    {
      "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
      "direction": "incoming",
      "status": "answered",
      "start_time": "2026-09-21T14:03:12.000Z",
      "end_time": "2026-09-21T14:09:40.000Z",
      "duration": 388,
      "remote_name": "Jordan Reyes",
      "remote_number": "7185550142",
      "our_number": "7185550100",
      "handled_by": {
        "extension": "104",
        "name": "Maya Chen"
      },
      "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow.",
      "journey": [
        {
          "order": 1,
          "type": "auto_attendant",
          "extension": "100",
          "extension_name": "Main Menu",
          "start_time": "2026-09-21T14:03:12.000Z",
          "duration": 19,
          "summary": null
        },
        {
          "order": 2,
          "type": "user",
          "extension": "104",
          "extension_name": "Maya Chen",
          "start_time": "2026-09-21T14:03:31.000Z",
          "duration": 369,
          "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow."
        }
      ]
    }
  ],
  "messages": [
    {
      "channel": "text",
      "direction": "incoming",
      "body": "Running 10 minutes late, see you soon.",
      "sent_time": "2026-09-21T15:22:04Z",
      "our_number": "7185550100",
      "remote_number": "7185550142"
    }
  ],
  "last_call": {
    "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
    "direction": "incoming",
    "status": "answered",
    "start_time": "2026-09-21T14:03:12.000Z",
    "end_time": "2026-09-21T14:09:40.000Z",
    "duration": 388,
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "our_number": "7185550100",
    "handled_by": {
      "extension": "104",
      "name": "Maya Chen"
    },
    "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow.",
    "journey": [
      {
        "order": 1,
        "type": "auto_attendant",
        "extension": "100",
        "extension_name": "Main Menu",
        "start_time": "2026-09-21T14:03:12.000Z",
        "duration": 19,
        "summary": null
      },
      {
        "order": 2,
        "type": "user",
        "extension": "104",
        "extension_name": "Maya Chen",
        "start_time": "2026-09-21T14:03:31.000Z",
        "duration": 369,
        "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow."
      }
    ]
  },
  "last_incoming": {
    "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
    "direction": "incoming",
    "status": "answered",
    "start_time": "2026-09-21T14:03:12.000Z",
    "end_time": "2026-09-21T14:09:40.000Z",
    "duration": 388,
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "our_number": "7185550100",
    "handled_by": {
      "extension": "104",
      "name": "Maya Chen"
    },
    "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow.",
    "journey": [
      {
        "order": 1,
        "type": "auto_attendant",
        "extension": "100",
        "extension_name": "Main Menu",
        "start_time": "2026-09-21T14:03:12.000Z",
        "duration": 19,
        "summary": null
      },
      {
        "order": 2,
        "type": "user",
        "extension": "104",
        "extension_name": "Maya Chen",
        "start_time": "2026-09-21T14:03:31.000Z",
        "duration": 369,
        "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow."
      }
    ]
  },
  "last_outgoing": null,
  "counts": {
    "contacts": 1,
    "calls": 1,
    "messages": 1
  }
}
```

#### 400

Fewer than 7 digits.

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "number must contain at least 7 digits."
  }
}
```

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

---

On the site at https://vocatech.com/developers/reference/contacts

# Contacts

Your contacts: the fields they carry, the list, create or update in batches, delete, and find by phone number.

## List contact fields

`GET /v1/contacts/fields`

The fields your contacts carry, in display order. A field's `name` is the key in every contact's `fields`, and the name to use when you create or update contacts.

`is_match` fields find an existing contact on create. `is_phone` fields hold phone numbers: we keep digits only, and several numbers are separated by `;`. A field tied to one of your integrations carries `integration`.

### Request

**curl**

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

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/contacts/fields", {
  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/contacts/fields",
    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/contacts/fields');

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

### Answers

#### 200

Your contact fields.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `fields` | array of objects | yes |  |
| `fields[].id` | integer | yes |  |
| `fields[].name` | string | yes | The field's name: the key in every contact's `fields`. |
| `fields[].order` | integer | yes | Display order. |
| `fields[].is_phone` | boolean | yes | Holds phone numbers. We keep digits only; several numbers are separated by `;`. |
| `fields[].is_match` | boolean | yes | Used to find an existing contact on create. A contact whose match fields all equal yours is updated instead. |
| `fields[].reports_enabled` | boolean | yes | A display setting from the portal. |
| `fields[].hide_no_data` | boolean | yes | A display setting from the portal. |
| `fields[].show_in_fields` | boolean | yes | A display setting from the portal. |
| `fields[].is_integration` | boolean | yes | True when the field is tied to one of your integrations. |
| `fields[].integration` | object |  | Present only on integration fields: the integration's settings, as set in the portal. |
| `fields[].integration.id` | integer |  |  |
| `fields[].integration.initials` | string |  |  |
| `fields[].integration.image_url` | string |  |  |
| `fields[].integration.fields` | array of objects |  |  |
| `fields[].integration.fields[].key` | string |  |  |
| `fields[].integration.fields[].display_name` | string |  |  |
| `fields[].integration.fields[].value` | string |  |  |
| `fields[].integration.fields[].fills` | array, each any |  | Integration settings, as set in the portal. |

**Example**

```json
{
  "fields": [
    {
      "id": 31,
      "name": "First Name",
      "order": 1,
      "is_phone": false,
      "is_match": false,
      "reports_enabled": true,
      "hide_no_data": false,
      "show_in_fields": true,
      "is_integration": false
    },
    {
      "id": 32,
      "name": "Last Name",
      "order": 2,
      "is_phone": false,
      "is_match": false,
      "reports_enabled": true,
      "hide_no_data": false,
      "show_in_fields": true,
      "is_integration": false
    },
    {
      "id": 33,
      "name": "Phone",
      "order": 3,
      "is_phone": true,
      "is_match": true,
      "reports_enabled": true,
      "hide_no_data": false,
      "show_in_fields": true,
      "is_integration": false
    },
    {
      "id": 34,
      "name": "Email",
      "order": 4,
      "is_phone": false,
      "is_match": false,
      "reports_enabled": false,
      "hide_no_data": true,
      "show_in_fields": true,
      "is_integration": false
    },
    {
      "id": 35,
      "name": "Account Number",
      "order": 5,
      "is_phone": false,
      "is_match": true,
      "reports_enabled": false,
      "hide_no_data": true,
      "show_in_fields": true,
      "is_integration": false
    }
  ]
}
```

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

## List contacts

`GET /v1/contacts`

Your contacts, oldest first. `search` finds contacts where any field contains the text.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "page=1" \
  --data-urlencode "limit=100"
```

**Node**

```javascript
const params = new URLSearchParams({
  page: "1",
  limit: "100",
});

const res = await fetch(`https://api.vocatech.com/v1/contacts?${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/contacts",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "page": 1,
        "limit": 100,
    },
    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/contacts', [
    'query' => [
        'page' => 1,
        'limit' => 100,
    ],
]);

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer |  | Page number, from 1. Default `1`. At least 1. |
| `limit` | query | integer |  | Contacts per page, 1 to 500. A value outside that range is brought inside it. Default `100`. From 1 to 500. |
| `search` | query | string |  | Text to find in any field. For example `Reyes`. |

### Answers

#### 200

A page of contacts.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | object | yes |  |
| `query.page` | integer | yes |  |
| `query.limit` | integer | yes |  |
| `query.search` | string or null | yes |  |
| `contacts` | array of objects | yes |  |
| `contacts[].id` | integer | yes | The contact's id. |
| `contacts[].fields` | object | yes | Field name to value. The names are your contact fields (`GET /v1/contacts/fields`). Phone fields hold digits; several numbers are separated by `;`. |
| `contacts[].fields.{field}` | string or null |  | One value for each field, by its name. |
| `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_contacts` | integer | yes | Contacts that match. `total_pages` is 0 when there are none. |

**Example**

```json
{
  "query": {
    "page": 1,
    "limit": 100,
    "search": null
  },
  "contacts": [
    {
      "id": 5012,
      "fields": {
        "First Name": "Jordan",
        "Last Name": "Reyes",
        "Phone": "7185550142",
        "Email": "jordan.reyes@example.com",
        "Account Number": "A-2291"
      }
    },
    {
      "id": 5013,
      "fields": {
        "First Name": "Sam",
        "Last Name": "Patel",
        "Phone": "7185550143;9175550123",
        "Email": "sam.patel@example.com",
        "Account Number": "A-2310"
      }
    }
  ],
  "meta": {
    "page": 1,
    "limit": 100,
    "total_pages": 1,
    "total_contacts": 2
  }
}
```

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

## Create or update contacts

`POST /v1/contacts`

Creates a contact, or updates the one that matches. Send one contact as `{"fields": {...}}`, or up to 500 as `{"contacts": [{"fields": {...}}]}`.

`fields` maps field names (from `GET /v1/contacts/fields`) to values. Names we do not know are ignored. Values over 255 characters are cut. Phone fields keep digits only; separate several numbers with `;`.

**Matching.** When your account has match fields (`is_match: true`), a contact whose match fields all equal yours is updated instead of created. With no match fields, every contact is created new.

One contact answers `201` when created and `200` when updated. A batch always answers `200`. One bad entry does not stop the rest: `errors` lists each one by its `index` in your array.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "fields": {
      "First Name": "Jordan",
      "Last Name": "Reyes",
      "Phone": "(718) 555-0142",
      "Email": "jordan.reyes@example.com",
      "Account Number": "A-2291"
    }
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/contacts", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    fields: {
      "First Name": "Jordan",
      "Last Name": "Reyes",
      Phone: "(718) 555-0142",
      Email: "jordan.reyes@example.com",
      "Account Number": "A-2291",
    },
  }),
});

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

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/contacts",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "fields": {
            "First Name": "Jordan",
            "Last Name": "Reyes",
            "Phone": "(718) 555-0142",
            "Email": "jordan.reyes@example.com",
            "Account Number": "A-2291",
        },
    },
    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/contacts', [
    'json' => [
        'fields' => [
            'First Name' => 'Jordan',
            'Last Name' => 'Reyes',
            'Phone' => '(718) 555-0142',
            'Email' => 'jordan.reyes@example.com',
            'Account Number' => 'A-2291',
        ],
    ],
]);

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

### Body

**One contact**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `fields` | object | yes | Field name to value. Names come from `GET /v1/contacts/fields`; unknown names are ignored. Values over 255 characters are cut. At least 1 field. |
| `fields.{field}` | string or null |  | One value for each field, by its name. |

**A batch**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `contacts` | array of objects | yes | Up to 500 items. |
| `contacts[].fields` | object | yes | Field name to value. Names come from `GET /v1/contacts/fields`; unknown names are ignored. Values over 255 characters are cut. At least 1 field. |
| `contacts[].fields.{field}` | string or null |  | One value for each field, by its name. |

**One contact**

```json
{
  "fields": {
    "First Name": "Jordan",
    "Last Name": "Reyes",
    "Phone": "(718) 555-0142",
    "Email": "jordan.reyes@example.com",
    "Account Number": "A-2291"
  }
}
```

**A batch**

```json
{
  "contacts": [
    {
      "fields": {
        "First Name": "Jordan",
        "Last Name": "Reyes",
        "Phone": "7185550142",
        "Account Number": "A-2291"
      }
    },
    {
      "fields": {
        "First Name": "Sam",
        "Last Name": "Patel",
        "Phone": "7185550143;9175550123",
        "Account Number": "A-2310"
      }
    }
  ]
}
```

### Answers

#### 200

One contact was updated, or a batch was processed.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `summary` | object | yes |  |
| `summary.total` | integer | yes |  |
| `summary.created` | integer | yes |  |
| `summary.updated` | integer | yes |  |
| `summary.errors` | integer | yes |  |
| `contacts` | array of objects | yes |  |
| `contacts[].contact` | object | yes |  |
| `contacts[].contact.id` | integer | yes | The contact's id. |
| `contacts[].contact.fields` | object | yes | Field name to value. The names are your contact fields (`GET /v1/contacts/fields`). Phone fields hold digits; several numbers are separated by `;`. |
| `contacts[].contact.fields.{field}` | string or null |  | One value for each field, by its name. |
| `contacts[].action` | string | yes | One of `created` or `updated`. |
| `errors` | array of objects | yes |  |
| `errors[].index` | integer | yes | The entry's position in your `contacts` array, from 0. |
| `errors[].message` | string | yes |  |

**Example**

```json
{
  "summary": {
    "total": 2,
    "created": 1,
    "updated": 1,
    "errors": 0
  },
  "contacts": [
    {
      "contact": {
        "id": 5012,
        "fields": {
          "First Name": "Jordan",
          "Last Name": "Reyes",
          "Phone": "7185550142",
          "Email": "jordan.reyes@example.com",
          "Account Number": "A-2291"
        }
      },
      "action": "updated"
    },
    {
      "contact": {
        "id": 5013,
        "fields": {
          "First Name": "Sam",
          "Last Name": "Patel",
          "Phone": "7185550143;9175550123",
          "Email": "sam.patel@example.com",
          "Account Number": "A-2310"
        }
      },
      "action": "created"
    }
  ],
  "errors": []
}
```

#### 201

One contact was created.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `summary` | object | yes |  |
| `summary.total` | integer | yes |  |
| `summary.created` | integer | yes |  |
| `summary.updated` | integer | yes |  |
| `summary.errors` | integer | yes |  |
| `contacts` | array of objects | yes |  |
| `contacts[].contact` | object | yes |  |
| `contacts[].contact.id` | integer | yes | The contact's id. |
| `contacts[].contact.fields` | object | yes | Field name to value. The names are your contact fields (`GET /v1/contacts/fields`). Phone fields hold digits; several numbers are separated by `;`. |
| `contacts[].contact.fields.{field}` | string or null |  | One value for each field, by its name. |
| `contacts[].action` | string | yes | One of `created` or `updated`. |
| `errors` | array of objects | yes |  |
| `errors[].index` | integer | yes | The entry's position in your `contacts` array, from 0. |
| `errors[].message` | string | yes |  |

**Example**

```json
{
  "summary": {
    "total": 1,
    "created": 1,
    "updated": 0,
    "errors": 0
  },
  "contacts": [
    {
      "contact": {
        "id": 5012,
        "fields": {
          "First Name": "Jordan",
          "Last Name": "Reyes",
          "Phone": "7185550142",
          "Email": "jordan.reyes@example.com",
          "Account Number": "A-2291"
        }
      },
      "action": "created"
    }
  ],
  "errors": []
}
```

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

#### 422

No `fields` object, or a batch over 500.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Missing or invalid \"fields\" object."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Batch size cannot exceed 500 contacts."
  }
}
```

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

## Delete contacts

`DELETE /v1/contacts`

Deletes one contact (`{"id": 5012}`) or up to 500 (`{"ids": [5012, 5013]}`). The ids go in a JSON body.

One contact that is not yours or does not exist answers `404`. In a batch it is listed in `errors`, and the rest are deleted.

### Request

**curl**

```bash
curl -X DELETE https://api.vocatech.com/v1/contacts \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "id": 5012
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/contacts", {
  method: "DELETE",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    id: 5012,
  }),
});

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

**Python**

```python
import requests

r = requests.delete(
    "https://api.vocatech.com/v1/contacts",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "id": 5012,
    },
    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/contacts', [
    'json' => [
        'id' => 5012,
    ],
]);

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

### Body

**One contact**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes |  |

**A batch**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ids` | array of integers | yes | Up to 500 items. |

**One contact**

```json
{
  "id": 5012
}
```

**A batch**

```json
{
  "ids": [
    5012,
    5013,
    5099
  ]
}
```

### Answers

#### 200

Deleted.

**One contact**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `deleted` | integer | yes | The id that was deleted. |

**A batch**

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `summary` | object | yes |  |
| `summary.total` | integer | yes |  |
| `summary.deleted` | integer | yes |  |
| `summary.errors` | integer | yes |  |
| `deleted` | array of integers | yes | The ids that were deleted. |
| `errors` | array of objects | yes | Ids not found on your account. |
| `errors[].id` | integer | yes |  |
| `errors[].message` | string | yes |  |

**One contact**

```json
{
  "deleted": 5012
}
```

**A batch**

```json
{
  "summary": {
    "total": 3,
    "deleted": 2,
    "errors": 1
  },
  "deleted": [
    5012,
    5013
  ],
  "errors": [
    {
      "id": 5099,
      "message": "The contact you requested does not exist."
    }
  ]
}
```

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

One contact: not found on your account.

**Example**

```json
{
  "error": {
    "code": 404,
    "type": "NOT_FOUND",
    "message": "The contact you requested does not exist."
  }
}
```

#### 422

No `id`, or a batch over 500.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Missing \"id\" field."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Batch size cannot exceed 500 IDs."
  }
}
```

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

## Find contacts by phone number

`GET /v1/contacts/lookup`

The contacts a phone number belongs to, up to 10, through the same phone index Callpop uses. Any format works on either side: `(718) 555-0142`, `+17185550142` and `7185550142` find the same contact.

Keys with the `agents` scope can call this route.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/contacts/lookup \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "phone=7185550142"
```

**Node**

```javascript
const params = new URLSearchParams({
  phone: "7185550142",
});

const res = await fetch(`https://api.vocatech.com/v1/contacts/lookup?${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/contacts/lookup",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "phone": "7185550142",
    },
    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/contacts/lookup', [
    'query' => [
        'phone' => '7185550142',
    ],
]);

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `phone` | query | string | yes | The phone number, any format, at least 7 digits. For example `7185550142`. |

### Answers

#### 200

The contacts with that number. `found` is false when there are none.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `phone` | string | yes | The number you asked about, digits only, without a leading 1. |
| `found` | boolean | yes |  |
| `contacts` | array of objects | yes | Up to 10. Up to 10 items. |
| `contacts[].id` | integer | yes | The contact's id. |
| `contacts[].fields` | object | yes | Field name to value. The names are your contact fields (`GET /v1/contacts/fields`). Phone fields hold digits; several numbers are separated by `;`. |
| `contacts[].fields.{field}` | string or null |  | One value for each field, by its name. |

**Example**

```json
{
  "phone": "7185550142",
  "found": true,
  "contacts": [
    {
      "id": 5012,
      "fields": {
        "First Name": "Jordan",
        "Last Name": "Reyes",
        "Phone": "7185550142",
        "Email": "jordan.reyes@example.com",
        "Account Number": "A-2291"
      }
    }
  ]
}
```

#### 400

No phone, or fewer than 7 digits.

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "phone is required and must contain at least 7 digits."
  }
}
```

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

---

On the site at https://vocatech.com/developers/reference/ai-agents

# AI agents

Your AI agents, and one outgoing call through one of them.

## List AI agents

`GET /v1/agents`

Your AI agents, with what you need to pick one for `POST /v1/agents/{id}/calls`. Agents are set up in the portal. An agent can place calls only when `active` and `outgoing_calls` are both true.

Keys with the `agents` scope can call this route.

### Request

**curl**

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

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/agents", {
  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/agents",
    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/agents');

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

### Answers

#### 200

Your agents.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `agents` | array of objects | yes |  |
| `agents[].id` | integer | yes | Use it with `POST /v1/agents/{id}/calls`. |
| `agents[].name` | string | yes |  |
| `agents[].extension` | string | yes | The agent's extension. |
| `agents[].active` | boolean | yes | The agent is switched on. |
| `agents[].outgoing_calls` | boolean | yes | The agent's Outgoing calls switch is on. It must be on to place calls. |
| `agents[].provisioned` | boolean | yes | The agent is set up on the phone system. |
| `meta` | object | yes |  |
| `meta.total` | integer | yes |  |

**Example**

```json
{
  "agents": [
    {
      "id": 12,
      "name": "Front Desk",
      "extension": "150",
      "active": true,
      "outgoing_calls": true,
      "provisioned": true
    }
  ],
  "meta": {
    "total": 1
  }
}
```

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

## Place an outgoing AI agent call

`POST /v1/agents/{id}/calls`

Your AI agent places one outgoing call: a reminder, a confirmation, a callback, a notice. The agent opens with its outgoing greeting, says the purpose, and handles the answer with the jobs it has. A job that changes an appointment files the change.

The outcome reaches you the way every message from the agent does: through the agent's message delivery and message action, opening with a tag that names your `reference`.

**Rules.**

- One call per request. This is not a bulk dialer.
- `to` is a 10-digit US or Canadian number, or an extension of 2 to 6 digits. Never an emergency or service number (911 and the other N11 codes, 933, 988), a premium number (900, 976), or an area code that is another country.
- The agent must be on, and its Outgoing calls switch must be on (in the portal, under Persona, Outgoing calls).
- A call outside the agent's own calling hours (8 AM to 9 PM in the agent's time zone unless its Persona tab says otherwise) is refused unless `allow_quiet_hours` is true.
- Send `purpose` or `instructions`, or both.
- An agent places at most 50 calls a day; past that the request is refused (`422`) until the next day.

The answer is `202` once the call is placed. `state` is the first state the phone system reported.

**A retry is safe with an `Idempotency-Key`.** Send the header, your own id for the request (up to 80 characters): a repeat within a day answers the same call (`202`, the same `call_sid`) instead of placing a second one. Without it, the same agent, `to` and `reference` within two minutes is treated as the same request. A `502` means the platform did not answer and the call may have been placed: check for the placed-call tag before you retry, or retry with the same key.

Keys with the `agents` scope can call this route.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/agents/12/calls \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "7185550142",
    "purpose": "Confirm the Friday 10 AM appointment",
    "person_name": "Jordan Reyes",
    "reference": "appt-2291",
    "kind": "notice"
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/agents/12/calls", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    to: "7185550142",
    purpose: "Confirm the Friday 10 AM appointment",
    person_name: "Jordan Reyes",
    reference: "appt-2291",
    kind: "notice",
  }),
});

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

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/agents/12/calls",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "to": "7185550142",
        "purpose": "Confirm the Friday 10 AM appointment",
        "person_name": "Jordan Reyes",
        "reference": "appt-2291",
        "kind": "notice",
    },
    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/agents/12/calls', [
    'json' => [
        'to' => '7185550142',
        'purpose' => 'Confirm the Friday 10 AM appointment',
        'person_name' => 'Jordan Reyes',
        'reference' => 'appt-2291',
        'kind' => 'notice',
    ],
]);

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The agent's id, from `GET /v1/agents`. For example `12`. |
| `Idempotency-Key` | header | string |  | Your own id for this request, up to 80 characters. A repeat within a day answers the same call instead of placing a second one. Up to 80 characters. For example `visit-20260925-0900`. |

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `to` | string | yes | Who to call: a 10-digit US or Canadian number, or an extension of 2 to 6 digits. Punctuation and a leading 1 are ignored. For example `7185550142`. |
| `purpose` | string |  | Why the agent is calling, in one line. It says this after its greeting. Longer text is cut to 200 characters. Up to 200 characters. |
| `subject` | string |  | Another name for `purpose`. |
| `instructions` | string |  | More for the agent to know or do on this call. Longer text is cut to 2,000 characters. Up to 2,000 characters. |
| `person_name` | string |  | The name of the person being called. Cut to 80 characters. Up to 80 characters. |
| `reference` | string |  | Your own id for this call, echoed in the agent's message about it. Letters, digits, dots, dashes, colons and underscores. Matches `^[A-Za-z0-9._:-]{1,80}$`. |
| `job` | string |  | The name of the agent job the call starts with. Cut to 80 characters. Up to 80 characters. |
| `kind` | string |  | `notice` tells the person something. `callback` answers something they asked for. Any other value reads as `notice`. One of `notice` or `callback`. Default `notice`. |
| `allow_quiet_hours` | boolean |  | Allow a call outside the agent's own calling hours (8 AM to 9 PM in its time zone unless its Persona tab says otherwise). Default `false`. |

**Example**

```json
{
  "to": "7185550142",
  "purpose": "Confirm the Friday 10 AM appointment",
  "person_name": "Jordan Reyes",
  "reference": "appt-2291",
  "kind": "notice"
}
```

### Answers

#### 202

The call is placed.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `call` | object | yes |  |
| `call.call_sid` | string | yes | The id of the placed call. |
| `call.agent_id` | integer | yes |  |
| `call.from` | string | yes | The number the call comes from. |
| `call.to` | string | yes |  |
| `call.state` | string | yes | The first state the phone system reported, for example `trying`. |
| `call.kind` | string | yes | One of `notice` or `callback`. |
| `call.purpose` | string | yes |  |
| `call.reference` | string | yes |  |

**Example**

```json
{
  "call": {
    "call_sid": "c7d2e9a4-1f3b-4c8e-9a6d-0b5e2f7c1a93",
    "agent_id": 12,
    "from": "7185550100",
    "to": "7185550142",
    "state": "trying",
    "kind": "notice",
    "purpose": "Confirm the Friday 10 AM appointment",
    "reference": "appt-2291"
  }
}
```

#### 400

The agent id is not a number.

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "The agent id must be a number."
  }
}
```

#### 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 agent with that id on your account.

**Example**

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

#### 422

A field failed a check, the agent or its outgoing calls are off, it is quiet hours, or the platform refused the call.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Outgoing calls are switched off on this agent. Turn them on under Persona, Outgoing calls."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "to is required: the number to call, or an extension."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "to may not be an emergency or service number."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "purpose or instructions is required: the agent has to know why it is calling."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "This agent is switched off."
  }
}
```

#### 429

The `dial` counter is full (10 a minute, 500 a day per key), or another request limit was reached. Wait `Retry-After` seconds.

**Example**

```json
{
  "error": "Rate limit exceeded",
  "period": "minute",
  "limit": 10,
  "counter": "dial",
  "retry_after": 42
}
```

#### 502

The agent platform did not answer, or answered that it failed. The call may have been placed; check for the placed-call tag before you retry, or retry with the same `Idempotency-Key`.

**Example**

```json
{
  "error": {
    "code": 502,
    "type": "ERROR",
    "message": "The agent platform did not answer. The call may have been placed; check for the placed-call tag before you retry."
  }
}
```

#### 503

Outgoing calls are not available right now, or the key could not be checked.

**Example**

```json
{
  "error": {
    "code": 503,
    "type": "ERROR",
    "message": "Outgoing calls are not available right now."
  }
}
```

---

On the site at https://vocatech.com/developers/reference/support

# Support

Open a ticket with Vocatech support from your own system.

## Open a support ticket

`POST /v1/support/tickets`

Opens a ticket with Vocatech support from your own system: a help desk sync, an automation, or your AI agent's "after a message" action. The ticket belongs to the account your key belongs to and is worked like any email or text to support.

**One matter, one ticket.** Send the same `reference` again within 7 days and your message is added to the ticket it opened (`200`, `appended: true`) instead of opening a new one. A `reference` that is a ticket key you were given reports back on that ticket.

**Busy.** `409` means that ticket is being worked right now. Retry after `Retry-After` seconds (60).

Send contact details flat (`contact_name`, `contact_phone`, `contact_email`, `contact_company`, `contact_company_phone`, `reply_by`) or in a `contact` object (`name`, `phone`, `email`, `company`, `company_phone`, `reply_by`). The flat field wins when both are sent.

**Whose account.** Support finds the customer's account from the business phone number first (`contact_company_phone`), then `contact_phone`, then `caller_id`. Send the business name and number when you have them.

Keys with the `support` scope can call this route.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/support/tickets \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "Our fax line has been down since this morning. Please call me back.",
    "contact_name": "Jordan Reyes",
    "contact_phone": "7185550142",
    "reply_by": "call",
    "reference": "ticket-sync-2291",
    "source": "Help desk sync"
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/support/tickets", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    message: "Our fax line has been down since this morning. Please call me back.",
    contact_name: "Jordan Reyes",
    contact_phone: "7185550142",
    reply_by: "call",
    reference: "ticket-sync-2291",
    source: "Help desk sync",
  }),
});

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

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/support/tickets",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "message": "Our fax line has been down since this morning. Please call me back.",
        "contact_name": "Jordan Reyes",
        "contact_phone": "7185550142",
        "reply_by": "call",
        "reference": "ticket-sync-2291",
        "source": "Help desk sync",
    },
    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/support/tickets', [
    'json' => [
        'message' => 'Our fax line has been down since this morning. Please call me back.',
        'contact_name' => 'Jordan Reyes',
        'contact_phone' => '7185550142',
        'reply_by' => 'call',
        'reference' => 'ticket-sync-2291',
        'source' => 'Help desk sync',
    ],
]);

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

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `message` | string | yes | What the person needs, in their own words. Up to 4,000 characters. Up to 4,000 characters. |
| `subject` | string |  | One line. Cut to 120 characters. |
| `contact_name` | string |  | Cut to 120 characters. |
| `contact_phone` | string |  | A 10-digit US number. Punctuation and a leading 1 are allowed. |
| `contact_email` | string (email) |  |  |
| `contact_company` | string |  | The business the person is calling about, as they gave it. Cut to 120 characters. |
| `contact_company_phone` | string |  | The business's main phone number, as they gave it. Support matches the account on it first. A 10-digit US number, punctuation and a leading 1 allowed; anything else is left out and the ticket still opens. |
| `reply_by` | string |  | How the person wants to hear back. One of `call`, `text` or `email`. |
| `caller_id` | string |  | The number the person called from, when it differs from `contact_phone`. A 10-digit US number is kept; anything else (an extension, a withheld or an international number) is left out and the ticket still opens. |
| `reference` | string |  | Your own id for this matter. The same `reference` within 7 days adds to the same ticket. A ticket key reports back on that ticket. Matches `^[A-Za-z0-9._:-]{1,80}$`. |
| `source` | string |  | The system that opened it, for example `AI front desk` or `Help desk sync`. Cut to 80 characters. |
| `contact` | object |  | Another way to send the contact fields. A flat field wins when both are sent. |
| `contact.name` | string |  |  |
| `contact.phone` | string |  |  |
| `contact.email` | string (email) |  |  |
| `contact.company` | string |  |  |
| `contact.company_phone` | string |  |  |
| `contact.reply_by` | string |  | One of `call`, `text` or `email`. |

**Example**

```json
{
  "message": "Our fax line has been down since this morning. Please call me back.",
  "contact_name": "Jordan Reyes",
  "contact_phone": "7185550142",
  "contact_company": "Reyes Family Dental",
  "contact_company_phone": "7185550100",
  "reply_by": "call",
  "reference": "ticket-sync-2291",
  "source": "Help desk sync"
}
```

### Answers

#### 200

Added to the ticket the same `reference` opened.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ticket` | object | yes |  |
| `ticket.key` | string | yes | The ticket's key. Send it as `reference` later to report back on this ticket. |
| `ticket.subject` | string | yes |  |
| `ticket.status` | string | yes | For example `open`. |
| `ticket.created_at` | string | yes | When the ticket was opened, ISO 8601. |
| `ticket.appended` | boolean | yes | True when your message was added to a ticket the same `reference` opened. |

**Example**

```json
{
  "ticket": {
    "key": "case-20260924-141502-3f9a1c7e5b2d4a6c8e0f1a3b5c7d9e2f",
    "subject": "Message from Jordan Reyes",
    "status": "open",
    "created_at": "2026-09-24T14:15:02+00:00",
    "appended": true
  }
}
```

#### 201

A new ticket was opened.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `ticket` | object | yes |  |
| `ticket.key` | string | yes | The ticket's key. Send it as `reference` later to report back on this ticket. |
| `ticket.subject` | string | yes |  |
| `ticket.status` | string | yes | For example `open`. |
| `ticket.created_at` | string | yes | When the ticket was opened, ISO 8601. |
| `ticket.appended` | boolean | yes | True when your message was added to a ticket the same `reference` opened. |

**Example**

```json
{
  "ticket": {
    "key": "case-20260924-141502-3f9a1c7e5b2d4a6c8e0f1a3b5c7d9e2f",
    "subject": "Message from Jordan Reyes",
    "status": "open",
    "created_at": "2026-09-24T14:15:02+00:00",
    "appended": false
  }
}
```

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

#### 409

That ticket is being worked right now. Retry after `Retry-After` seconds.

**Example**

```json
{
  "error": {
    "code": 409,
    "type": "CONFLICT",
    "message": "busy"
  }
}
```

#### 422

A field is missing or malformed. The message says which.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "message is required: what the person needs, in their own words."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "reply_by must be call, text or email."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "contact_phone must be a 10 digit US number."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "reference may use letters, digits, dot, underscore, colon and dash, up to 80 characters."
  }
}
```

#### 429

The `tickets` counter is full (10 a minute, 200 a day per key), or another request limit was reached. Wait `Retry-After` seconds.

**Example**

```json
{
  "error": "Rate limit exceeded",
  "period": "minute",
  "limit": 10,
  "counter": "tickets",
  "retry_after": 42
}
```

#### 502

The support desk could not take the ticket right now. Try again in a minute.

**Example**

```json
{
  "error": {
    "code": 502,
    "type": "ERROR",
    "message": "The support desk could not take the ticket right now. Try again in a minute."
  }
}
```

#### 503

The support desk is not available right now, or the key could not be checked.

**Example**

```json
{
  "error": {
    "code": 503,
    "type": "ERROR",
    "message": "The support desk is not available right now."
  }
}
```

---

On the site at https://vocatech.com/developers/reference/knowledge

# Knowledge

Ask Vocatech's knowledge base one question about the service and get the answer a person at our front desk would give.

## Ask the knowledge base

`GET /v1/knowledge/answer`

Answers one question about Vocatech's service from Vocatech's own knowledge base, in one to three sentences, the way a person at our front desk would say it. When the knowledge base does not cover it, `found` is false and `answer` is empty.

The answer uses Vocatech's published facts only. Nothing about your account is in it.

Built for an AI agent's answer-questions job, and for help desks and chat bots. `note` tells an agent how to use the answer.

Keys with the `support` or `knowledge` scope can call this route.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/knowledge/answer \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "q=What are your support hours?"
```

**Node**

```javascript
const params = new URLSearchParams({
  q: "What are your support hours?",
});

const res = await fetch(`https://api.vocatech.com/v1/knowledge/answer?${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/knowledge/answer",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "q": "What are your support hours?",
    },
    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/knowledge/answer', [
    'query' => [
        'q' => 'What are your support hours?',
    ],
]);

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `q` | query | string | yes | The question, in the caller's own words. 3 to 500 characters. From 3 to 500 characters. For example `What are your support hours?`. |
| `source` | query | string |  | Who is asking, for our logs. Cut to 80 characters. Up to 80 characters. For example `Front desk bot`. |

### Answers

#### 200

The answer, or `found: false`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `question` | string | yes | Your question, with spaces tidied. |
| `found` | boolean | yes | False when the knowledge base does not cover it. |
| `answer` | string | yes | One to three sentences, ready to say aloud. Empty when `found` is false. |
| `section` | string | yes | The part of the knowledge base it came from. Empty when `found` is false. |
| `note` | string | yes | How an agent should use the answer. |

**Covered**

```json
{
  "question": "What are your support hours?",
  "found": true,
  "answer": "Our team answers Monday to Thursday from 9 AM to 5 PM Eastern, and Friday from 9 AM to noon.",
  "section": "Support",
  "note": "Answer the caller from this in your own words; state only what it says."
}
```

**Not covered**

```json
{
  "question": "Can you repair my fax machine?",
  "found": false,
  "answer": "",
  "section": "",
  "note": "The knowledge base does not cover this. Say you do not have that information and offer support."
}
```

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

#### 422

No question, or one too short or too long.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "q is required: the question, in the caller's own words."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "q may be at most 500 characters."
  }
}
```

#### 429

The `knowledge` counter is full (30 a minute, 2,000 a day per key), or another request limit was reached. Wait `Retry-After` seconds.

**Example**

```json
{
  "error": "Rate limit exceeded",
  "period": "minute",
  "limit": 30,
  "counter": "knowledge",
  "retry_after": 42
}
```

#### 502

The knowledge base could not answer right now. Try again in a minute.

**Example**

```json
{
  "error": {
    "code": 502,
    "type": "ERROR",
    "message": "The knowledge base could not answer right now. Try again in a minute."
  }
}
```

#### 503

The knowledge base is not available right now, or the key could not be checked.

**Example**

```json
{
  "error": {
    "code": 503,
    "type": "ERROR",
    "message": "The knowledge base is not available right now."
  }
}
```

## Ask the knowledge base (POST)

`POST /v1/knowledge/answer`

Answers one question about Vocatech's service from Vocatech's own knowledge base, in one to three sentences, the way a person at our front desk would say it. When the knowledge base does not cover it, `found` is false and `answer` is empty.

The answer uses Vocatech's published facts only. Nothing about your account is in it.

Built for an AI agent's answer-questions job, and for help desks and chat bots. `note` tells an agent how to use the answer.

Keys with the `support` or `knowledge` scope can call this route.

The same as the `GET` form, with the question in a JSON body as `q` or `question`. A `q` in the query string wins over the body.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/knowledge/answer \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "q": "What are your support hours?",
    "source": "Front desk bot"
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/knowledge/answer", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    q: "What are your support hours?",
    source: "Front desk bot",
  }),
});

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

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/knowledge/answer",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "q": "What are your support hours?",
        "source": "Front desk bot",
    },
    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/knowledge/answer', [
    'json' => [
        'q' => 'What are your support hours?',
        'source' => 'Front desk bot',
    ],
]);

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

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `q` | string |  | The question, in the caller's own words. 3 to 500 characters. From 3 to 500 characters. |
| `question` | string |  | Another name for `q`. |
| `source` | string |  | Who is asking, for our logs. Cut to 80 characters. |

**Example**

```json
{
  "q": "What are your support hours?",
  "source": "Front desk bot"
}
```

### Answers

#### 200

The answer, or `found: false`.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `question` | string | yes | Your question, with spaces tidied. |
| `found` | boolean | yes | False when the knowledge base does not cover it. |
| `answer` | string | yes | One to three sentences, ready to say aloud. Empty when `found` is false. |
| `section` | string | yes | The part of the knowledge base it came from. Empty when `found` is false. |
| `note` | string | yes | How an agent should use the answer. |

**Covered**

```json
{
  "question": "What are your support hours?",
  "found": true,
  "answer": "Our team answers Monday to Thursday from 9 AM to 5 PM Eastern, and Friday from 9 AM to noon.",
  "section": "Support",
  "note": "Answer the caller from this in your own words; state only what it says."
}
```

**Not covered**

```json
{
  "question": "Can you repair my fax machine?",
  "found": false,
  "answer": "",
  "section": "",
  "note": "The knowledge base does not cover this. Say you do not have that information and offer support."
}
```

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

#### 422

No question, or one too short or too long.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "q is required: the question, in the caller's own words."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "q may be at most 500 characters."
  }
}
```

#### 429

The `knowledge` counter is full (30 a minute, 2,000 a day per key), or another request limit was reached. Wait `Retry-After` seconds.

**Example**

```json
{
  "error": "Rate limit exceeded",
  "period": "minute",
  "limit": 30,
  "counter": "knowledge",
  "retry_after": 42
}
```

#### 502

The knowledge base could not answer right now. Try again in a minute.

**Example**

```json
{
  "error": {
    "code": 502,
    "type": "ERROR",
    "message": "The knowledge base could not answer right now. Try again in a minute."
  }
}
```

#### 503

The knowledge base is not available right now, or the key could not be checked.

**Example**

```json
{
  "error": {
    "code": 503,
    "type": "ERROR",
    "message": "The knowledge base is not available right now."
  }
}
```

---

On the site at https://vocatech.com/developers/reference/old-paths

# Old paths

Older paths that still answer. They behave exactly like the routes they point to. Use the new paths in new code.

## List calls (old path)

`GET /v1/reports/calls` (old path)

The same as `GET /v1/calls`: the same parameters, the same answer, the same `reports` counter. Kept for older integrations. Use `GET /v1/calls`.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/reports/calls \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "start_date=2026-09-21" \
  --data-urlencode "end_date=2026-09-22" \
  --data-urlencode "timezone=America/New_York"
```

**Node**

```javascript
const params = new URLSearchParams({
  start_date: "2026-09-21",
  end_date: "2026-09-22",
  timezone: "America/New_York",
});

const res = await fetch(`https://api.vocatech.com/v1/reports/calls?${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/reports/calls",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "start_date": "2026-09-21",
        "end_date": "2026-09-22",
        "timezone": "America/New_York",
    },
    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/reports/calls', [
    'query' => [
        'start_date' => '2026-09-21',
        'end_date' => '2026-09-22',
        'timezone' => 'America/New_York',
    ],
]);

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`, as each call's `direction` reads. `voicemail` matches the rare calls stored that way. One of `any`, `incoming`, `outgoing` or `voicemail`. Default `any`. |
| `status` | query | string |  | Only calls with a leg in this status, for example `answered` or `missed`. `missed` never returns an outgoing call. Default `any`. For example `missed`. |
| `extension` | query | string |  | Only calls with a leg on this extension (exact match). For example `104`. |
| `type` | query | string |  | Only calls with a leg of this type, for example `user`, `auto_attendant`, `hunt_group` or `call_center`. Default `any`. |
| `search` | query | string |  | Text to find in the call id, the other party's name or number, your number, or an extension. |
| `sort` | query | string |  | Order by start time or by duration. Calls that tie keep a stable order, so paging is safe. One of `day_desc`, `day_asc`, `duration_desc` or `duration_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 calls in the window.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `query` | object | yes | The filters that were applied, echoed back. `extension` and `type` are applied but not echoed. |
| `query.company_id` | integer |  |  |
| `query.start_date` | string (date) |  | The first local day used. |
| `query.end_date` | string (date) |  | The last local day used. |
| `query.direction` | string |  |  |
| `query.status` | string |  |  |
| `query.search` | string |  |  |
| `query.timezone` | string |  | The time zone used. |
| `calls` | array of objects | yes |  |
| `calls[].call_id` | string | yes | The call's id. |
| `calls[].direction` | string | yes | `incoming` or `outgoing`. A call your staff placed through the dial-out portal reads `outgoing`. A few calls read `voicemail`. |
| `calls[].status` | string | yes | The status of the first leg in `journey`, for example `answered` or `missed`. It is not the call's final outcome: a call a menu answered and a person then missed reads `answered`. Read the legs to see who answered. |
| `calls[].extension` | string or null | yes | The last person who held the call. When no person did, the first extension on it. |
| `calls[].extension_name` | string or null | yes | That extension's name, written First Last. |
| `calls[].remote_name` | string | yes | The other party's name from caller ID, written First Last. Can be empty. |
| `calls[].remote_number` | string | yes | The other party's number, digits without the leading 1. Can be empty. |
| `calls[].group_number` | string | yes | Your number on the call, digits without the leading 1. Can be empty. |
| `calls[].start_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].end_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].duration` | integer | yes | The call's length in seconds. |
| `calls[].journey` | array of objects | yes | Every leg, in order. When you filter by `status`, `extension` or `type`, only the matching legs are listed. |
| `calls[].journey[].order` | integer | yes | Position in the journey, from 1. |
| `calls[].journey[].type` | string | yes | What handled this leg, as the phone system wrote it. For example `user` (a person), `auto_attendant`, `hunt_group` or `call_center`. |
| `calls[].journey[].extension_name` | string or null | yes | The name of the person or feature on this leg, as set in the portal, written First Last. |
| `calls[].journey[].extension` | string or null | yes | The extension on this leg. |
| `calls[].journey[].start_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].journey[].end_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `calls[].journey[].duration` | integer | yes | Seconds on this leg. |
| `calls[].journey[].summary` | string or null | yes | The AI summary of this leg's recording (its latest part). Null when there is none, and when the leg's only recording belongs to another call leg. |
| `calls[].journey[].transcription` | string or null | yes | The transcript of this leg's recording (its latest part), with speaker labels. Null when there is none, and when the leg's only recording belongs to another call leg. |
| `calls[].journey[].recording_url` | string (uri) or null | yes | Where to fetch this leg's recording (its latest part). Call it with your key (`GET /v1/media/rec_<n>`) to get a signed link. Null when the leg was not recorded. |
| `calls[].journey[].recordings` | array of objects | yes | Every recording of this leg, oldest first. A call parked and picked up again has two parts (rarely three). Nearly every recorded leg has one. Empty when the leg was not recorded. |
| `calls[].journey[].recordings[].part` | integer | yes | This part's place, from 1, oldest first. |
| `calls[].journey[].recordings[].parts` | integer | yes | How many parts the leg's recording has. |
| `calls[].journey[].recordings[].recording_id` | integer | yes | The recording's id. The `call.transcription` event of this part carries the same id. |
| `calls[].journey[].recordings[].start_time` | string (date-time) or null | yes | When this part started. UTC, ISO 8601 with milliseconds. |
| `calls[].journey[].recordings[].duration` | integer or null | yes | Seconds. |
| `calls[].journey[].recordings[].url` | string (uri) or null | yes | Where to fetch this part (`GET /v1/media/rec_<n>?part=N`) with your key. Null while the part's file is not stored yet. |
| `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_calls` | integer | yes | Calls that match, across all pages. |

**Example**

```json
{
  "query": {
    "company_id": 1234567890,
    "start_date": "2026-09-21",
    "end_date": "2026-09-22",
    "direction": "any",
    "status": "any",
    "search": "",
    "timezone": "America/New_York"
  },
  "calls": [
    {
      "call_id": "3c9d7e1f-2a4b-4c6d-8e0f-1a2b3c4d5e6f",
      "direction": "outgoing",
      "status": "answered",
      "extension": "104",
      "extension_name": "Maya Chen",
      "remote_name": "",
      "remote_number": "7185550142",
      "group_number": "7185550100",
      "start_time": "2026-09-22T15:40:02.000Z",
      "end_time": "2026-09-22T15:41:10.000Z",
      "duration": 68,
      "journey": [
        {
          "order": 1,
          "type": "user",
          "extension_name": "Maya Chen",
          "extension": "104",
          "start_time": "2026-09-22T15:40:02.000Z",
          "end_time": "2026-09-22T15:41:10.000Z",
          "duration": 68,
          "summary": null,
          "transcription": null,
          "recording_url": null,
          "recordings": []
        }
      ]
    },
    {
      "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
      "direction": "incoming",
      "status": "answered",
      "extension": "104",
      "extension_name": "Maya Chen",
      "remote_name": "Jordan Reyes",
      "remote_number": "7185550142",
      "group_number": "7185550100",
      "start_time": "2026-09-21T14:03:12.000Z",
      "end_time": "2026-09-21T14:09:40.000Z",
      "duration": 388,
      "journey": [
        {
          "order": 1,
          "type": "auto_attendant",
          "extension_name": "Main Menu",
          "extension": "100",
          "start_time": "2026-09-21T14:03:12.000Z",
          "end_time": "2026-09-21T14:03:31.000Z",
          "duration": 19,
          "summary": null,
          "transcription": null,
          "recording_url": null,
          "recordings": []
        },
        {
          "order": 2,
          "type": "user",
          "extension_name": "Maya Chen",
          "extension": "104",
          "start_time": "2026-09-21T14:03:31.000Z",
          "end_time": "2026-09-21T14:09:40.000Z",
          "duration": 369,
          "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow.",
          "transcription": "Speaker 1: Good morning, thanks for calling. This is Maya.\nSpeaker 2: Hi Maya, this is Jordan Reyes. I need to move my Thursday appointment.\nSpeaker 1: No problem. Would Friday at 10 AM work?\nSpeaker 2: Friday at 10 is perfect. Thank you.",
          "recording_url": "https://api.vocatech.com/v1/media/rec_48213907",
          "recordings": [
            {
              "part": 1,
              "parts": 2,
              "recording_id": 10577640,
              "start_time": "2026-09-21T14:03:32.000Z",
              "duration": 41,
              "url": "https://api.vocatech.com/v1/media/rec_48213907?part=1"
            },
            {
              "part": 2,
              "parts": 2,
              "recording_id": 10577702,
              "start_time": "2026-09-21T14:04:31.000Z",
              "duration": 309,
              "url": "https://api.vocatech.com/v1/media/rec_48213907?part=2"
            }
          ]
        }
      ]
    }
  ],
  "meta": {
    "page": 1,
    "limit": 500,
    "total_pages": 1,
    "total_calls": 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. Use YYYY-MM-DD and a valid IANA timezone."
  }
}
```

**Example**

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

**Example**

```json
{
  "error": {
    "code": 400,
    "type": "BAD_REQUEST",
    "message": "Ask for at most 366 days at a time (this request covers 400). For more history, walk it a year at a time."
  }
}
```

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

## List messages (old path)

`GET /v1/reports/messages` (old path)

The same as `GET /v1/messages`: the same parameters, the same answer, the same `reports` counter. Keys with the `messaging` scope can call it. Use `GET /v1/messages`.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/reports/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/reports/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/reports/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/reports/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.

---

On the site at https://vocatech.com/developers/reference/webhooks

# Webhook endpoints

Webhooks push events to your server as they happen. Register an endpoint with `POST /v1/webhooks` (or in the portal: company settings, Webhooks), then answer each delivery with a `2xx`. The payload of each event is under **Webhooks** (the events) in the menu.

**Delivery.** Each event is an HTTPS `POST` with a JSON body:

```json
{
  "id": "evt_6703a1f25c9e8.21736450",
  "event_type": "call.ended",
  "timestamp": "2026-09-24T14:09:41+00:00",
  "company_id": 1234567890,
  "data": {}
}
```

with these headers:

- `X-Vocatech-Signature: t=<unix seconds>,v1=<hex>`
- `X-Vocatech-Event`: the event type, the same as `event_type`
- `User-Agent: Vocatech-Webhook/1.0`
- `Content-Type: application/json`

**Answer fast.** Answer `2xx` within 5 seconds, then do the work. Scheduled deliveries and retries time out at 5 seconds. Any other status, no answer in time, or a redirect is a failed delivery. We never follow redirects: a `3xx` counts as a failure.

**Retries.** Failed deliveries are retried by a job that runs every 5 minutes. The first retry comes at its next run. Each later retry waits at least 5, then 30, then 120 minutes after the one before. That makes four retries and 5 attempts in all, the last one about 2 hours 40 minutes after the first failure. A retry carries the same body (the same `id`) with a new signature. It goes to the URL that failed, even if you change or pause the endpoint later. Deleting the endpoint stops its retries. A failed delivery is listed at `GET /v1/webhooks/failures` from its first failure; once it has used every attempt it stays listed for 7 days. A delivery that succeeds on a retry leaves the list. There is no replay route: to catch up, read the data with `GET /v1/calls`, `GET /v1/messages` or `GET /v1/faxes`.

**Duplicates and order.** A delivery can arrive more than once, and events can arrive out of order. Keep the `id` of each event you handle and skip one you have already seen.

**Verify every delivery.** `v1` is the HMAC-SHA256, in lowercase hex, of `t=<t>.<raw body>`, keyed with your endpoint's `secret_key` exactly as it was given to you (64 characters, used as text). Compute it over the raw bytes you received, before any JSON parsing. Reject the delivery when `t` is more than 300 seconds from your clock, or when the signature does not match. Compare in constant time.

Node:

```js
import crypto from "node:crypto";

// rawBody: the exact bytes received, as a Buffer
// (for example with express.raw({ type: "application/json" })).
export function verifyVocatech(rawBody, header, secret) {
  const parts = Object.fromEntries(
    String(header).split(",").map((kv) => kv.trim().split("=")),
  );
  const t = Number(parts.t);
  if (!Number.isInteger(t) || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`t=${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(String(parts.v1 || ""));
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Python:

```python
import hashlib
import hmac
import time

def verify_vocatech(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.strip().split("=", 1) for kv in header.split(",") if "=" in kv)
    t = parts.get("t", "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    signed = b"t=" + t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))
```

PHP:

```php
// $rawBody = file_get_contents('php://input');
function verifyVocatech(string $rawBody, string $header, string $secret): bool
{
    $parts = [];
    foreach (explode(',', $header) as $kv) {
        [$k, $v] = array_pad(explode('=', trim($kv), 2), 2, '');
        $parts[$k] = $v;
    }
    $t = $parts['t'] ?? '';
    if (!ctype_digit($t) || abs(time() - (int) $t) > 300) {
        return false;
    }
    $expected = hash_hmac('sha256', "t={$t}.{$rawBody}", $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
```

**Filters.** `event_filters` picks the events. `extension_filters` narrows call events (`call.*` and `call.transcription`) to those extensions; message and fax events are not narrowed by extension. `number_filters` narrows every event to the numbers you list, matched against the event's phone numbers. An event with no phone number is never sent to an endpoint that has number filters. Known issue: on `call.started`, `call.answered` and `call.ended`, `group_number` is one fixed number of your account, not always the number that was dialed, so a filter on your own numbers can miss those events. A filter on the other party's number works.

**Test.** `POST /v1/webhooks/{id}/test` sends a signed `webhook.test` event right away. The hosted tool at https://api.vocatech.com/test/ gives you a temporary receiver URL and a live log of events for 30 minutes.

**Scoped keys.** A key with the `messaging` scope may subscribe to message events only, and sees only the endpoints it created.


## List webhook endpoints

`GET /v1/webhooks`

Your webhook endpoints, newest first. A scoped key lists only the endpoints it created. The signing secret is never shown here.

### Request

**curl**

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

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks", {
  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/webhooks",
    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/webhooks');

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `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

Your endpoints.

**Example**

```json
[
  {
    "id": 318,
    "company_id": 1234567890,
    "name": "CRM sync",
    "description": "Calls and texts into the CRM",
    "url": "https://hooks.example.com/vocatech",
    "event_filters": [
      "call.ended",
      "message.received"
    ],
    "extension_filters": [],
    "number_filters": [
      "+17185550100"
    ],
    "enabled": true,
    "created_at": "2026-09-24 14:05:09",
    "updated_at": "2026-09-24 14:05:09"
  }
]
```

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

## Create a webhook endpoint

`POST /v1/webhooks`

Registers an endpoint for one or more events. The answer carries `secret_key` once. Store it to verify signatures: it is never shown again.

**URL.** `https` on a public internet address: every address the name resolves to must be public. Private, loopback and link-local addresses, internal names, and a username or password in the URL are refused with `422`. We never follow redirects.

**Filters.**

- `event_filters` (required): the events to send. `events` is accepted as another name.
- `extension_filters`: only call events (`call.*` and `call.transcription`) for these extensions. Message and fax events are not narrowed by extension.
- `number_filters`: only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored as E.164, up to 200. Leave it out for every number. An event with no phone number is never sent to a filtered endpoint.

A key with the `messaging` scope may subscribe to message events only. You can also create endpoints in the portal (company settings, Webhooks).

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/webhooks \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://hooks.example.com/vocatech",
    "event_filters": [
      "call.ended",
      "message.received"
    ],
    "number_filters": [
      "7185550100"
    ],
    "name": "CRM sync",
    "description": "Calls and texts into the CRM"
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks", {
  method: "POST",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    url: "https://hooks.example.com/vocatech",
    event_filters: ["call.ended", "message.received"],
    number_filters: ["7185550100"],
    name: "CRM sync",
    description: "Calls and texts into the CRM",
  }),
});

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

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/webhooks",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "url": "https://hooks.example.com/vocatech",
        "event_filters": ["call.ended", "message.received"],
        "number_filters": ["7185550100"],
        "name": "CRM sync",
        "description": "Calls and texts into the CRM",
    },
    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/webhooks', [
    'json' => [
        'url' => 'https://hooks.example.com/vocatech',
        'event_filters' => ['call.ended', 'message.received'],
        'number_filters' => ['7185550100'],
        'name' => 'CRM sync',
        'description' => 'Calls and texts into the CRM',
    ],
]);

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

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string (uri) | yes | An https URL on a public internet address. Every address the name resolves to must be public. No username or password in the URL. Up to 2,048 characters. |
| `event_filters` | array of strings | yes | The events to send. At least one. At least 1 item. |
| `events` | array of strings |  | Another name for `event_filters`, accepted on create only. |
| `extension_filters` | array of strings |  | Only call events (`call.*` and `call.transcription`) for these extensions. |
| `number_filters` | array of strings |  | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `name` | string |  | A name you will recognize. Defaults to the URL's host. |
| `description` | string |  |  |
| `company_id` | integer |  | Optional. If you send it, it must be your own company id. |

**Example**

```json
{
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "number_filters": [
    "7185550100"
  ],
  "name": "CRM sync",
  "description": "Calls and texts into the CRM"
}
```

### Answers

#### 201

Created. Store `secret_key` now.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes |  |
| `company_id` | integer | yes |  |
| `name` | string | yes | Defaults to the URL's host. |
| `description` | string or null | yes |  |
| `url` | string (uri) | yes |  |
| `event_filters` | array of strings | yes |  |
| `extension_filters` | array of strings | yes | Only call events for these extensions. Empty means every extension. |
| `number_filters` | array of strings | yes | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `enabled` | boolean | yes | False pauses deliveries. |
| `created_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `updated_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `secret_key` | string | yes | The signing secret, 64 characters. Shown only in this answer. Store it now. |

**Example**

```json
{
  "id": 318,
  "company_id": 1234567890,
  "name": "CRM sync",
  "description": "Calls and texts into the CRM",
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "extension_filters": [],
  "number_filters": [
    "+17185550100"
  ],
  "enabled": true,
  "created_at": "2026-09-24 14:05:09",
  "updated_at": "2026-09-24 14:05:09",
  "secret_key": "9f4c2e7a1b8d3f6c0e5a9d2b7f4c1e8a3d6b0f9c2e5a8d1b4f7c0e3a6d9b2f5c"
}
```

#### 401

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

#### 403

The key's scope does not allow an event you asked for, `company_id` is not yours, or the key's scopes or address.

**Example**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "This API key cannot subscribe to call.ended. It may subscribe to: message.sent, message.received, message.status_updated."
  }
}
```

#### 422

A URL we refuse, no events or an unknown event, or a bad number filter.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must point at a public internet address. hooks.internal does not resolve to one."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must use HTTPS."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Invalid event(s): call.missed. Allowed: call.started, call.started, call.ended, call.answered, call.transcription, message.sent, message.received, message.status_updated, fax.sent, fax.received, fax.delivered, fax.failed"
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "Not a valid phone number in number_filters: 555-0142"
  }
}
```

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

## List failed deliveries

`GET /v1/webhooks/failures`

Deliveries that failed, newest first, with the event exactly as we sent it and the error we got.

A failure is listed from its first failed attempt, while we retry. One that used all 5 attempts stays listed for 7 days. One that succeeds on a retry leaves the list, and deleting an endpoint drops its failures. A scoped key sees only failures of endpoints it created.

There is no replay route. To catch up, read the data with `GET /v1/calls`, `GET /v1/messages` or `GET /v1/faxes`.

### Request

**curl**

```bash
curl -G https://api.vocatech.com/v1/webhooks/failures \
  -H "Authorization: Bearer YOUR_API_KEY" \
  --data-urlencode "page=1" \
  --data-urlencode "limit=20"
```

**Node**

```javascript
const params = new URLSearchParams({
  page: "1",
  limit: "20",
});

const res = await fetch(`https://api.vocatech.com/v1/webhooks/failures?${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/webhooks/failures",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    params={
        "page": 1,
        "limit": 20,
    },
    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/webhooks/failures', [
    'query' => [
        'page' => 1,
        'limit' => 20,
    ],
]);

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `page` | query | integer |  | Page number, from 1. Default `1`. At least 1. |
| `limit` | query | integer |  | Failures per page, 1 to 100. A value outside that range is brought inside it. Default `20`. From 1 to 100. |
| `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

A page of failures.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `failures` | array of objects | yes |  |
| `failures[].id` | integer | yes |  |
| `failures[].endpoint_id` | integer or null | yes |  |
| `failures[].endpoint_url` | string | yes | The URL the delivery went to. |
| `failures[].event_type` | string | yes | A webhook event. One of `call.started`, `call.answered`, `call.ended`, `call.transcription`, `message.sent`, `message.received`, `message.status_updated`, `fax.sent`, `fax.received`, `fax.delivered` or `fax.failed`. |
| `failures[].payload` | object or null | yes | The event exactly as we sent it. |
| `failures[].error_message` | string or null | yes | `HTTP` and the status your server answered, or the connection error. |
| `failures[].attempts` | integer | yes | Attempts so far, 1 to 5. From 1 to 5. |
| `failures[].retried_at` | string or null | yes | The last retry. UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `failures[].created_at` | string | yes | The first failure. UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `meta` | object | yes |  |
| `meta.page` | integer | yes |  |
| `meta.limit` | integer | yes |  |
| `meta.total` | integer | yes |  |
| `meta.total_pages` | integer | yes |  |

**Example**

```json
{
  "failures": [
    {
      "id": 20417,
      "endpoint_id": 318,
      "endpoint_url": "https://hooks.example.com/vocatech",
      "event_type": "call.ended",
      "payload": {
        "id": "evt_6703a1f8e0c46.73920518",
        "event_type": "call.ended",
        "timestamp": "2026-09-24T14:09:43+00:00",
        "company_id": 1234567890,
        "data": {
          "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
          "direction": "incoming",
          "extension": "104",
          "extension_name": "Maya Chen",
          "remote_name": "Jordan Reyes",
          "remote_number": "7185550142",
          "group_number": "7185550100",
          "start_time": "2026-09-24T14:03:12.000Z",
          "end_time": "2026-09-24T14:09:40.000Z",
          "duration": 369
        }
      },
      "error_message": "HTTP 503",
      "attempts": 2,
      "retried_at": "2026-09-24 14:15:02",
      "created_at": "2026-09-24 14:09:43"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1,
    "total_pages": 1
  }
}
```

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

## Get a webhook endpoint

`GET /v1/webhooks/{id}`

One endpoint. The signing secret is not shown. A scoped key can read only the endpoints it created.

### Request

**curl**

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

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks/318", {
  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/webhooks/318",
    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/webhooks/318');

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |
| `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 endpoint.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes |  |
| `company_id` | integer | yes |  |
| `name` | string | yes | Defaults to the URL's host. |
| `description` | string or null | yes |  |
| `url` | string (uri) | yes |  |
| `event_filters` | array of strings | yes |  |
| `extension_filters` | array of strings | yes | Only call events for these extensions. Empty means every extension. |
| `number_filters` | array of strings | yes | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `enabled` | boolean | yes | False pauses deliveries. |
| `created_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `updated_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |

**Example**

```json
{
  "id": 318,
  "company_id": 1234567890,
  "name": "CRM sync",
  "description": "Calls and texts into the CRM",
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "extension_filters": [],
  "number_filters": [
    "+17185550100"
  ],
  "enabled": true,
  "created_at": "2026-09-24 14:05:09",
  "updated_at": "2026-09-24 14:05:09"
}
```

#### 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 endpoint with that id on your account, or not one this key created.

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

## Update a webhook endpoint

`PATCH /v1/webhooks/{id}`

Changes only the fields you send.

- `enabled: false` pauses deliveries without deleting the endpoint. Retries already scheduled still go out; delete the endpoint to stop them.
- `number_filters: []` widens the endpoint back to every number.
- A blank `name` keeps the current one.
- `event_filters` must keep at least one event. `events` is not accepted here.

The signing secret does not change. To get a new secret, create a new endpoint and delete the old one.

### Request

**curl**

```bash
curl -X PATCH https://api.vocatech.com/v1/webhooks/318 \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "enabled": false
  }'
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks/318", {
  method: "PATCH",
  headers: {
    Authorization: "Bearer YOUR_API_KEY",
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    enabled: false,
  }),
});

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

**Python**

```python
import requests

r = requests.patch(
    "https://api.vocatech.com/v1/webhooks/318",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    json={
        "enabled": False,
    },
    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->patch('/v1/webhooks/318', [
    'json' => [
        'enabled' => false,
    ],
]);

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |

### Body

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `url` | string (uri) |  | Checked the same way as on create. |
| `event_filters` | array of strings |  | Replaces the list. At least one. At least 1 item. |
| `extension_filters` | array of strings |  | Replaces the list. Send `[]` for every extension. |
| `number_filters` | array of strings |  | Replaces the list. Send `[]` for every number. Up to 200 items. |
| `enabled` | boolean |  | False pauses deliveries. |
| `name` | string |  | A blank name keeps the current one. |
| `description` | string or null |  |  |
| `company_id` | integer |  | Optional. If you send it, it must be your own company id. |

**Pause deliveries**

```json
{
  "enabled": false
}
```

**Change the events**

```json
{
  "event_filters": [
    "call.ended",
    "call.transcription"
  ]
}
```

### Answers

#### 200

The endpoint as saved.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | integer | yes |  |
| `company_id` | integer | yes |  |
| `name` | string | yes | Defaults to the URL's host. |
| `description` | string or null | yes |  |
| `url` | string (uri) | yes |  |
| `event_filters` | array of strings | yes |  |
| `extension_filters` | array of strings | yes | Only call events for these extensions. Empty means every extension. |
| `number_filters` | array of strings | yes | Only events that involve one of these numbers: 10-digit US or Canadian numbers in any format, stored and shown as E.164 (`+17185550142`). Up to 200. Empty means every number on the account. Up to 200 items. |
| `enabled` | boolean | yes | False pauses deliveries. |
| `created_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |
| `updated_at` | string | yes | UTC, written `YYYY-MM-DD HH:MM:SS`. |

**Example**

```json
{
  "id": 318,
  "company_id": 1234567890,
  "name": "CRM sync",
  "description": "Calls and texts into the CRM",
  "url": "https://hooks.example.com/vocatech",
  "event_filters": [
    "call.ended",
    "message.received"
  ],
  "extension_filters": [],
  "number_filters": [
    "+17185550100"
  ],
  "enabled": false,
  "created_at": "2026-09-24 14:05:09",
  "updated_at": "2026-09-24 15:02:44"
}
```

#### 401

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

#### 403

The key's scope does not allow an event you asked for, `company_id` is not yours, or the key's scopes or address.

**Example**

```json
{
  "error": {
    "code": 403,
    "type": "FORBIDDEN",
    "message": "Unauthorized: company_id mismatch."
  }
}
```

#### 404

No endpoint with that id on your account, or not one this key created.

#### 422

A URL we refuse, an empty or unknown event list, or a bad number filter.

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "event_filters must be a non-empty array."
  }
}
```

**Example**

```json
{
  "error": {
    "code": 422,
    "type": "UNPROCESSABLE_ENTITY",
    "message": "The URL must use HTTPS."
  }
}
```

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

## Delete a webhook endpoint

`DELETE /v1/webhooks/{id}`

Deletes the endpoint and stops its pending retries. The answer has no body.

### Request

**curl**

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

**Node**

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

console.log(res.status); // 204 when it worked
```

**Python**

```python
import requests

r = requests.delete(
    "https://api.vocatech.com/v1/webhooks/318",
    headers={"Authorization": "Bearer YOUR_API_KEY"},
    timeout=30,
)
print(r.status_code)  # 204 when it worked
```

**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/webhooks/318');

echo $res->getStatusCode(), PHP_EOL; // 204 when it worked
```

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |
| `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

#### 204

Deleted.

#### 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 endpoint with that id on your account, or not one this key created.

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

## Send a test delivery

`POST /v1/webhooks/{id}/test`

Sends one signed test event to the endpoint now and tells you what came back. The event is `webhook.test`:

```json
{
  "id": "test_6703a7f0c2b19.90417236",
  "event_type": "webhook.test",
  "timestamp": "2026-09-24T14:30:00+00:00",
  "company_id": 1234567890,
  "data": { "message": "This is a test webhook delivery from Vocatech." }
}
```

It carries the same headers and signature as every event. We wait up to 10 seconds. A failed test is not retried and is not listed in failures. The answer is `200` either way: read `success`.

### Request

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/webhooks/318/test \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**Node**

```javascript
const res = await fetch("https://api.vocatech.com/v1/webhooks/318/test", {
  method: "POST",
  headers: { Authorization: "Bearer YOUR_API_KEY" },
});

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

**Python**

```python
import requests

r = requests.post(
    "https://api.vocatech.com/v1/webhooks/318/test",
    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->post('/v1/webhooks/318/test');

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

### Parameters

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `id` | path | integer | yes | The webhook endpoint's id. For example `318`. |

### Answers

#### 200

What your endpoint answered.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `success` | boolean | yes | True when your endpoint answered `2xx` within 10 seconds. |
| `status_code` | integer or null | yes | The status your endpoint answered. Null when it could not be reached. |
| `error` | string or null | yes | Why it failed. |

**Delivered**

```json
{
  "success": true,
  "status_code": 200,
  "error": null
}
```

**Your server answered 500**

```json
{
  "success": false,
  "status_code": 500,
  "error": "HTTP 500"
}
```

**Not a public address any more**

```json
{
  "success": false,
  "status_code": null,
  "error": "Destination does not resolve to a public internet address"
}
```

#### 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 endpoint with that id on your account, or not one this key created.

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

---

On the site at https://vocatech.com/developers/reference/events

# Webhook events

Each event is an HTTPS POST to your endpoint, in the same envelope with its own data. How to answer a delivery, check its signature and read its retries is on the endpoints page (https://vocatech.com/developers/reference/webhooks).

## call.started

An extension started ringing or dialing.

An extension started ringing on an incoming call, or started dialing an outgoing one. `end_time` and `duration` are null.

We look for new call events every 5 seconds, so it usually arrives within seconds. Each event is one extension's view of the call: a call that rings several extensions can send one event per extension. Numbers are digits without the leading 1.

Known issue: `group_number` is one fixed number of your account, the same on every call event, not always the number that was dialed. A `number_filters` entry for your own number can miss these events.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `call.started`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.call_id` | string | yes | The phone system's id for the call. It ties a call's events together. It is not always the same as `call_id` in `GET /v1/calls`. |
| `data.direction` | string | yes | `incoming`, `outgoing`, or `unknown` when the phone system did not say. One of `incoming`, `outgoing` or `unknown`. |
| `data.extension` | string or null | yes | The extension this event is about. |
| `data.extension_name` | string or null | yes | Its name, written First Last. |
| `data.remote_name` | string or null | yes | The other party's name from caller ID. |
| `data.remote_number` | string or null | yes | The other party's number, digits without the leading 1. |
| `data.group_number` | string or null | yes | One number of your account, digits without the leading 1. Known issue: it is the same number on every call event, not always the number that was dialed. |
| `data.start_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `data.end_time` | string (date-time) or null | yes | Null until the call ends. |
| `data.duration` | integer or null | yes | Seconds from answer to hang-up. Null until the call ends, and on a call nobody answered. |

**Example delivery**

```json
{
  "id": "evt_6703a1f25c9e8.21736450",
  "event_type": "call.started",
  "timestamp": "2026-09-24T14:03:14+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T14:03:12.000Z",
    "end_time": null,
    "duration": null
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## call.answered

An extension answered.

An extension answered the call. `end_time` and `duration` are null.

We look for new call events every 5 seconds, so it usually arrives within seconds. Each event is one extension's view of the call: a call that rings several extensions can send one event per extension. Numbers are digits without the leading 1.

Known issue: `group_number` is one fixed number of your account, the same on every call event, not always the number that was dialed. A `number_filters` entry for your own number can miss these events.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `call.answered`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.call_id` | string | yes | The phone system's id for the call. It ties a call's events together. It is not always the same as `call_id` in `GET /v1/calls`. |
| `data.direction` | string | yes | `incoming`, `outgoing`, or `unknown` when the phone system did not say. One of `incoming`, `outgoing` or `unknown`. |
| `data.extension` | string or null | yes | The extension this event is about. |
| `data.extension_name` | string or null | yes | Its name, written First Last. |
| `data.remote_name` | string or null | yes | The other party's name from caller ID. |
| `data.remote_number` | string or null | yes | The other party's number, digits without the leading 1. |
| `data.group_number` | string or null | yes | One number of your account, digits without the leading 1. Known issue: it is the same number on every call event, not always the number that was dialed. |
| `data.start_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `data.end_time` | string (date-time) or null | yes | Null until the call ends. |
| `data.duration` | integer or null | yes | Seconds from answer to hang-up. Null until the call ends, and on a call nobody answered. |

**Example delivery**

```json
{
  "id": "evt_6703a1f3d7b21.58214907",
  "event_type": "call.answered",
  "timestamp": "2026-09-24T14:03:33+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T14:03:12.000Z",
    "end_time": null,
    "duration": null
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## call.ended

A call ended.

The call ended for an extension. `duration` is seconds from answer to hang-up, or null when nobody answered.

We look for new call events every 5 seconds, so it usually arrives within seconds. Each event is one extension's view of the call: a call that rings several extensions can send one event per extension. Numbers are digits without the leading 1.

Known issue: `group_number` is one fixed number of your account, the same on every call event, not always the number that was dialed. A `number_filters` entry for your own number can miss these events.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `call.ended`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.call_id` | string | yes | The phone system's id for the call. It ties a call's events together. It is not always the same as `call_id` in `GET /v1/calls`. |
| `data.direction` | string | yes | `incoming`, `outgoing`, or `unknown` when the phone system did not say. One of `incoming`, `outgoing` or `unknown`. |
| `data.extension` | string or null | yes | The extension this event is about. |
| `data.extension_name` | string or null | yes | Its name, written First Last. |
| `data.remote_name` | string or null | yes | The other party's name from caller ID. |
| `data.remote_number` | string or null | yes | The other party's number, digits without the leading 1. |
| `data.group_number` | string or null | yes | One number of your account, digits without the leading 1. Known issue: it is the same number on every call event, not always the number that was dialed. |
| `data.start_time` | string (date-time) or null | yes | UTC, ISO 8601 with milliseconds. |
| `data.end_time` | string (date-time) or null | yes | Null until the call ends. |
| `data.duration` | integer or null | yes | Seconds from answer to hang-up. Null until the call ends, and on a call nobody answered. |

**Example delivery**

```json
{
  "id": "evt_6703a1f8e0c46.73920518",
  "event_type": "call.ended",
  "timestamp": "2026-09-24T14:09:43+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T14:03:12.000Z",
    "end_time": "2026-09-24T14:09:40.000Z",
    "duration": 369
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## call.transcription

A recorded call has its transcript.

A recorded call leg has its transcript and AI summary. One event per recording: a call parked and picked up again is recorded in parts and sends one event per part.

It is sent once the finished transcript is ready, usually a few minutes after the call. We check every 2 minutes and wait up to 6 hours for the finished transcript. `extension` is the recorded leg's extension. The times and `duration` are the whole call's. `direction` reads as in `GET /v1/calls`.

`recording_id`, `part`, `parts`, `recording_start_time` and `recording_duration` say which recording the words are. Part N of a leg is `GET /v1/media/rec_<n>?part=N` and the same entry of `recordings` in `GET /v1/calls`. `parts` counts the parts saved when the event was sent. `part` is null for a recording of another call leg that is linked to the same leg of the report.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `call.transcription`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.call_id` | string | yes | The phone system's id for the call. |
| `data.direction` | string | yes | `incoming` or `outgoing`, as `GET /v1/calls` shows it. |
| `data.extension` | string or null | yes | The extension on the recorded leg. |
| `data.extension_name` | string or null | yes |  |
| `data.remote_name` | string or null | yes |  |
| `data.remote_number` | string or null | yes | Digits without the leading 1. |
| `data.group_number` | string or null | yes | Your number on the call, digits without the leading 1. |
| `data.start_time` | string (date-time) or null | yes | The call's start. UTC, ISO 8601 with milliseconds. |
| `data.end_time` | string (date-time) or null | yes | The call's end. UTC, ISO 8601 with milliseconds. |
| `data.duration` | integer | yes | The call's length in seconds. |
| `data.summary` | string or null | yes | The AI summary. Null when there is none. |
| `data.transcription` | string | yes | The transcript, with speaker labels. |
| `data.recording_id` | integer |  | The recording these words are, as in `recordings` in `GET /v1/calls`. |
| `data.part` | integer or null |  | This recording's part of the leg, from 1. Null for a recording of another call leg linked to the same leg of the report. |
| `data.parts` | integer or null |  | How many parts the leg's recording had when the event was sent. Nearly always 1. |
| `data.recording_start_time` | string (date-time) or null |  | When this recording started. UTC, ISO 8601 with milliseconds. |
| `data.recording_duration` | integer or null |  | This recording's length in seconds. |

**Example delivery**

```json
{
  "id": "evt_6703a2b41f9d2.30481726",
  "event_type": "call.transcription",
  "timestamp": "2026-09-24T14:13:05+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T14:03:12.000Z",
    "end_time": "2026-09-24T14:09:40.000Z",
    "duration": 388,
    "summary": "Jordan Reyes asked to move the Thursday appointment to Friday at 10 AM. Maya Chen confirmed the new time and said a reminder text will follow.",
    "transcription": "Speaker 1: Good morning, thanks for calling. This is Maya.\nSpeaker 2: Hi Maya, this is Jordan Reyes. I need to move my Thursday appointment.\nSpeaker 1: No problem. Would Friday at 10 AM work?\nSpeaker 2: Friday at 10 is perfect. Thank you.",
    "recording_id": 10577702,
    "part": 2,
    "parts": 2,
    "recording_start_time": "2026-09-24T14:04:31.000Z",
    "recording_duration": 309
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## message.sent

A text went out through the API or an automation.

A text left through the API or an automation: a send on a number in `api` or `email` mode, a picture message sent through the API, or a mass or group text. It is sent as soon as the carrier takes the message. `message_id` is the carrier's id, the same one the send returned. For a picture message sent through the API, `attachments` lists the URLs you sent.

Today a text sent from Webex does not fire `message.sent`. That includes `POST /v1/messages` on a Webex-mode number, replies your team types in Webex or sends by email, and WhatsApp replies. Those messages are in `GET /v1/messages`.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `message.sent`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.message_id` | string or null | yes | For a text, the carrier's message id: the same one a send in `api` or `email` mode returns and `GET /v1/messages` shows. For WhatsApp, the WhatsApp message id. |
| `data.direction` | string | yes | One of `incoming` or `outgoing`. |
| `data.channel` | string | yes | One of `text` or `whatsapp`. |
| `data.from` | string | yes | E.164, like `+17185550142`. |
| `data.to` | string | yes | E.164. On a group text, the recipients. |
| `data.body` | string | yes | The message text. Empty on most delivery receipts. |
| `data.status` | string | yes | `delivered` on received messages, `sent` on sent ones. On status updates: for a text `delivered` or `failed`; for WhatsApp its status, for example `delivered`, `read` or `failed`. |
| `data.error` | string or null | yes | Why a message failed, from the carrier. Null otherwise. |
| `data.sent_at` | string or null | yes | When the message was sent or received, ISO 8601 as the source reported it. The offset varies. |
| `data.attachments` | array, each object or string (uri) | yes | Files on the message. Received messages list objects. A picture message sent through the API lists the URLs you sent. |
| `data.attachments[].url` | string (uri) or null | yes | `https://api.vocatech.com/v1/media/att_<n>`: call it with your key for a signed link. |
| `data.attachments[].content_type` | string or null | yes |  |
| `data.attachments[].filename` | string or null | yes |  |
| `data.attachments[].size` | integer or null | yes |  |

**Example delivery**

```json
{
  "id": "evt_6703a3d19b6f2.17394056",
  "event_type": "message.sent",
  "timestamp": "2026-09-24T14:22:06+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "msg_9b2e5a",
    "direction": "outgoing",
    "channel": "text",
    "from": "+17185550100",
    "to": "+17185550142",
    "body": "Friday at 10 AM works. See you then.",
    "status": "sent",
    "error": null,
    "sent_at": "2026-09-24T10:22:05-04:00",
    "attachments": []
  }
}
```

**Example delivery**

```json
{
  "id": "evt_6703a3e2b0d58.47213960",
  "event_type": "message.sent",
  "timestamp": "2026-09-24T14:06:45+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "msg_5d8f1b",
    "direction": "outgoing",
    "channel": "text",
    "from": "+17185550100",
    "to": "+17185550142",
    "body": "Here is the estimate we discussed.",
    "status": "sent",
    "error": null,
    "sent_at": "2026-09-24T10:06:44-04:00",
    "attachments": [
      "https://files.example.com/estimates/estimate-2291.pdf"
    ]
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## message.received

A text or WhatsApp message arrived.

A text or WhatsApp message arrived on one of your numbers. It is sent as soon as the message arrives.

`attachments` lists each picture or file with its link to `GET /v1/media`. A file we could not store has `url` set to null.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `message.received`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.message_id` | string or null | yes | For a text, the carrier's message id: the same one a send in `api` or `email` mode returns and `GET /v1/messages` shows. For WhatsApp, the WhatsApp message id. |
| `data.direction` | string | yes | One of `incoming` or `outgoing`. |
| `data.channel` | string | yes | One of `text` or `whatsapp`. |
| `data.from` | string | yes | E.164, like `+17185550142`. |
| `data.to` | string | yes | E.164. On a group text, the recipients. |
| `data.body` | string | yes | The message text. Empty on most delivery receipts. |
| `data.status` | string | yes | `delivered` on received messages, `sent` on sent ones. On status updates: for a text `delivered` or `failed`; for WhatsApp its status, for example `delivered`, `read` or `failed`. |
| `data.error` | string or null | yes | Why a message failed, from the carrier. Null otherwise. |
| `data.sent_at` | string or null | yes | When the message was sent or received, ISO 8601 as the source reported it. The offset varies. |
| `data.attachments` | array, each object or string (uri) | yes | Files on the message. Received messages list objects. A picture message sent through the API lists the URLs you sent. |
| `data.attachments[].url` | string (uri) or null | yes | `https://api.vocatech.com/v1/media/att_<n>`: call it with your key for a signed link. |
| `data.attachments[].content_type` | string or null | yes |  |
| `data.attachments[].filename` | string or null | yes |  |
| `data.attachments[].size` | integer or null | yes |  |

**Example delivery**

```json
{
  "id": "evt_6703a3c07a5e1.64820193",
  "event_type": "message.received",
  "timestamp": "2026-09-24T14:20:32+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "msg_7f3a9c",
    "direction": "incoming",
    "channel": "text",
    "from": "+17185550142",
    "to": "+17185550100",
    "body": "Can we move my appointment to Friday?",
    "status": "delivered",
    "error": null,
    "sent_at": "2026-09-24T14:20:31.512Z",
    "attachments": [
      {
        "url": "https://api.vocatech.com/v1/media/att_90211",
        "content_type": "image/jpeg",
        "filename": "photo-0924.jpg",
        "size": 184233
      }
    ]
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## message.status_updated

The carrier reported on a text.

The carrier reported what happened to a text you sent, or WhatsApp reported a status change. It is sent as soon as the report arrives.

For a text, `status` is `delivered`, or `failed` with the carrier's reason in `error`. A text whose receipt never came back sends no event. For WhatsApp, `status` is the WhatsApp status, for example `delivered`, `read` or `failed`. `body` is empty except on a failed text. Match `message_id` to the send or to `GET /v1/messages`.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `message.status_updated`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.message_id` | string or null | yes | For a text, the carrier's message id: the same one a send in `api` or `email` mode returns and `GET /v1/messages` shows. For WhatsApp, the WhatsApp message id. |
| `data.direction` | string | yes | One of `incoming` or `outgoing`. |
| `data.channel` | string | yes | One of `text` or `whatsapp`. |
| `data.from` | string | yes | E.164, like `+17185550142`. |
| `data.to` | string | yes | E.164. On a group text, the recipients. |
| `data.body` | string | yes | The message text. Empty on most delivery receipts. |
| `data.status` | string | yes | `delivered` on received messages, `sent` on sent ones. On status updates: for a text `delivered` or `failed`; for WhatsApp its status, for example `delivered`, `read` or `failed`. |
| `data.error` | string or null | yes | Why a message failed, from the carrier. Null otherwise. |
| `data.sent_at` | string or null | yes | When the message was sent or received, ISO 8601 as the source reported it. The offset varies. |
| `data.attachments` | array, each object or string (uri) | yes | Files on the message. Received messages list objects. A picture message sent through the API lists the URLs you sent. |
| `data.attachments[].url` | string (uri) or null | yes | `https://api.vocatech.com/v1/media/att_<n>`: call it with your key for a signed link. |
| `data.attachments[].content_type` | string or null | yes |  |
| `data.attachments[].filename` | string or null | yes |  |
| `data.attachments[].size` | integer or null | yes |  |

**Example delivery**

```json
{
  "id": "evt_6703a3d4c1a07.92057341",
  "event_type": "message.status_updated",
  "timestamp": "2026-09-24T14:22:09+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "msg_9b2e5a",
    "direction": "outgoing",
    "channel": "text",
    "from": "+17185550100",
    "to": "+17185550142",
    "body": "",
    "status": "delivered",
    "error": null,
    "sent_at": "2026-09-24T14:22:07.880Z",
    "attachments": []
  }
}
```

**Example delivery**

```json
{
  "id": "evt_6703a3d6e8b14.30958216",
  "event_type": "message.status_updated",
  "timestamp": "2026-09-24T14:25:40+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "msg_1e7b4d",
    "direction": "outgoing",
    "channel": "text",
    "from": "+17185550100",
    "to": "+17185550143",
    "body": "Friday at 10 AM works. See you then.",
    "status": "failed",
    "error": "Carrier rejected the message",
    "sent_at": "2026-09-24T14:25:38.214Z",
    "attachments": []
  }
}
```

**Example delivery**

```json
{
  "id": "evt_6703a3d9a2c75.11846230",
  "event_type": "message.status_updated",
  "timestamp": "2026-09-24T14:31:12+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "msg_6f0a2c",
    "direction": "outgoing",
    "channel": "whatsapp",
    "from": "+17185550100",
    "to": "+17185550142",
    "body": "",
    "status": "read",
    "error": null,
    "sent_at": "2026-09-24 14:31:10",
    "attachments": []
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## fax.sent

An outgoing fax is in progress.

An outgoing fax was handed to the fax carrier and is in progress.

We look for new faxes on a schedule. Each fax is read once, when it first appears, and the event is chosen by its status at that moment. A later change sends no further event: a fax that finishes after we first saw it sends no `fax.delivered` or `fax.failed`. Read the final status with `GET /v1/faxes`.

Known issue: `sent_at` is Eastern time written with a `Z`, so it reads 4 or 5 hours early.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `fax.sent`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.fax_id` | string | yes | Use it with `GET /v1/media/{id}` once the document is ready. Matches `^fax_[0-9]+$`. |
| `data.direction` | string | yes | One of `incoming` or `outgoing`. |
| `data.from` | string | yes | E.164. |
| `data.to` | string | yes | E.164. |
| `data.pages` | integer or null | yes |  |
| `data.subject` | string or null | yes |  |
| `data.status` | string | yes | One of `in_progress`, `completed`, `failed`, `restricted` or `unknown`. |
| `data.faxage_job_id` | string or null | yes | The fax carrier's job id. |
| `data.sent_at` | string (date-time) or null | yes | When the fax was logged. Known issue: Eastern time written with a `Z`, so it reads 4 or 5 hours early. |

**Example delivery**

```json
{
  "id": "evt_6703a41e2d8b3.48102967",
  "event_type": "fax.sent",
  "timestamp": "2026-09-24T14:06:02+00:00",
  "company_id": 1234567890,
  "data": {
    "fax_id": "fax_77120",
    "direction": "outgoing",
    "from": "+17185550100",
    "to": "+17185550142",
    "pages": null,
    "subject": "Intake form",
    "status": "in_progress",
    "faxage_job_id": "93125501",
    "sent_at": "2026-09-24T10:05:09.000Z"
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## fax.received

An incoming fax is arriving.

An incoming fax was logged while it was still arriving. An incoming fax that was logged already complete comes as `fax.delivered` instead.

We look for new faxes on a schedule. Each fax is read once, when it first appears, and the event is chosen by its status at that moment. A later change sends no further event: a fax that finishes after we first saw it sends no `fax.delivered` or `fax.failed`. Read the final status with `GET /v1/faxes`.

Known issue: `sent_at` is Eastern time written with a `Z`, so it reads 4 or 5 hours early.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `fax.received`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.fax_id` | string | yes | Use it with `GET /v1/media/{id}` once the document is ready. Matches `^fax_[0-9]+$`. |
| `data.direction` | string | yes | One of `incoming` or `outgoing`. |
| `data.from` | string | yes | E.164. |
| `data.to` | string | yes | E.164. |
| `data.pages` | integer or null | yes |  |
| `data.subject` | string or null | yes |  |
| `data.status` | string | yes | One of `in_progress`, `completed`, `failed`, `restricted` or `unknown`. |
| `data.faxage_job_id` | string or null | yes | The fax carrier's job id. |
| `data.sent_at` | string (date-time) or null | yes | When the fax was logged. Known issue: Eastern time written with a `Z`, so it reads 4 or 5 hours early. |

**Example delivery**

```json
{
  "id": "evt_6703a5a07e4c9.26039174",
  "event_type": "fax.received",
  "timestamp": "2026-09-24T15:39:31+00:00",
  "company_id": 1234567890,
  "data": {
    "fax_id": "fax_77131",
    "direction": "incoming",
    "from": "+12125550187",
    "to": "+17185550100",
    "pages": null,
    "subject": null,
    "status": "in_progress",
    "faxage_job_id": "93125870",
    "sent_at": "2026-09-24T11:39:02.000Z"
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## fax.delivered

A fax completed.

A fax was logged as completed when we first saw it. Fetch the document with `GET /v1/media/{fax_id}`.

We look for new faxes on a schedule. Each fax is read once, when it first appears, and the event is chosen by its status at that moment. A later change sends no further event: a fax that finishes after we first saw it sends no `fax.delivered` or `fax.failed`. Read the final status with `GET /v1/faxes`.

Known issue: `sent_at` is Eastern time written with a `Z`, so it reads 4 or 5 hours early.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `fax.delivered`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.fax_id` | string | yes | Use it with `GET /v1/media/{id}` once the document is ready. Matches `^fax_[0-9]+$`. |
| `data.direction` | string | yes | One of `incoming` or `outgoing`. |
| `data.from` | string | yes | E.164. |
| `data.to` | string | yes | E.164. |
| `data.pages` | integer or null | yes |  |
| `data.subject` | string or null | yes |  |
| `data.status` | string | yes | One of `in_progress`, `completed`, `failed`, `restricted` or `unknown`. |
| `data.faxage_job_id` | string or null | yes | The fax carrier's job id. |
| `data.sent_at` | string (date-time) or null | yes | When the fax was logged. Known issue: Eastern time written with a `Z`, so it reads 4 or 5 hours early. |

**Example delivery**

```json
{
  "id": "evt_6703a5b3f1d06.85216403",
  "event_type": "fax.delivered",
  "timestamp": "2026-09-24T15:41:02+00:00",
  "company_id": 1234567890,
  "data": {
    "fax_id": "fax_77135",
    "direction": "incoming",
    "from": "+12125550187",
    "to": "+17185550100",
    "pages": 2,
    "subject": null,
    "status": "completed",
    "faxage_job_id": "93125877",
    "sent_at": "2026-09-24T11:40:00.000Z"
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.

## fax.failed

A fax failed.

A fax was logged as failed or restricted when we first saw it, for example an outgoing fax the carrier refused at once.

We look for new faxes on a schedule. Each fax is read once, when it first appears, and the event is chosen by its status at that moment. A later change sends no further event: a fax that finishes after we first saw it sends no `fax.delivered` or `fax.failed`. Read the final status with `GET /v1/faxes`.

Known issue: `sent_at` is Eastern time written with a `Z`, so it reads 4 or 5 hours early.

### Headers

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Vocatech-Signature` | header | string | yes | `t=<unix seconds>,v1=<hex HMAC-SHA256 of t=<t>.<raw body>, keyed with your secret_key>`. Reject the delivery if `t` is more than 300 seconds from your clock or `v1` does not match. For example `t=1790259302,v1=4e1c9a7b2f5d8e3a6c0b9f2e5d8a1c4b7e0f3a6d9c2b5e8f1a4d7c0b3e6f9a2d`. |
| `X-Vocatech-Event` | header | string | yes | The event type, the same as `event_type` in the body. |
| `User-Agent` | header | string | yes | Always `Vocatech-Webhook/1.0`. Always `Vocatech-Webhook/1.0`. |

### Payload

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `id` | string | yes | A unique id for this delivery's event. Retries repeat it. Use it to skip duplicates. For example `evt_6703a1f25c9e8.21736450`. |
| `event_type` | string | yes | Always `fax.failed`. |
| `timestamp` | string (date-time) | yes | When we built the event. ISO 8601 with `+00:00`. |
| `company_id` | integer | yes | Your company id. |
| `data` | object | yes |  |
| `data.fax_id` | string | yes | Use it with `GET /v1/media/{id}` once the document is ready. Matches `^fax_[0-9]+$`. |
| `data.direction` | string | yes | One of `incoming` or `outgoing`. |
| `data.from` | string | yes | E.164. |
| `data.to` | string | yes | E.164. |
| `data.pages` | integer or null | yes |  |
| `data.subject` | string or null | yes |  |
| `data.status` | string | yes | One of `in_progress`, `completed`, `failed`, `restricted` or `unknown`. |
| `data.faxage_job_id` | string or null | yes | The fax carrier's job id. |
| `data.sent_at` | string (date-time) or null | yes | When the fax was logged. Known issue: Eastern time written with a `Z`, so it reads 4 or 5 hours early. |

**Example delivery**

```json
{
  "id": "evt_6703a60c4a2e8.60371925",
  "event_type": "fax.failed",
  "timestamp": "2026-09-24T16:02:03+00:00",
  "company_id": 1234567890,
  "data": {
    "fax_id": "fax_77140",
    "direction": "outgoing",
    "from": "+17185550100",
    "to": "+17185550142",
    "pages": null,
    "subject": "Intake form",
    "status": "failed",
    "faxage_job_id": null,
    "sent_at": "2026-09-24T12:01:40.000Z"
  }
}
```

### Your answer

#### 2XX

Received. Answer within 5 seconds, then do the work.

#### Any other status

Any other status, no answer within 5 seconds, or a redirect is a failed delivery. We retry it.
