# Vocatech API developer guide

The guide to the Vocatech REST API at https://api.vocatech.com/v1, page by page as https://vocatech.com/developers/guide shows it. Every field of every route is in the API reference (https://vocatech.com/developers/reference.md) and the OpenAPI file (https://vocatech.com/developers/openapi.yml).

---

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

# Developer guide

Connect your own software to your Vocatech account. Read calls, texts and faxes with their AI summaries, transcripts and recordings. Send texts and faxes. Look up who is calling, have an AI agent place a call, open a support ticket, and get an event the moment something happens.

- **Base URL** `https://api.vocatech.com/v1`
- **Authentication** `Authorization: Bearer <key>`
- **Format** JSON in and out, UTF-8
- **Every field** The [API reference](https://vocatech.com/developers/reference) and the [OpenAPI file](https://vocatech.com/developers/openapi.yml)

It is a plain HTTPS and JSON API for your servers. These hold everywhere:

- **Server to server only.** The API sends no CORS headers, so a web page cannot call it. A key never belongs in a browser, a mobile app or a code repository.
- **JSON both ways.** Send `Content-Type: application/json` with every request that has a body. Every answer is JSON, except `204 No Content`.
- **Times are UTC**, in ISO 8601, like `2026-09-24T13:02:11.000Z`. The dates you ask for are read in your time zone. See [Pagination and dates](https://vocatech.com/developers/guide/dates).
- **Phone numbers are 10 digits** in requests and reports, like `7185550142`. Formatting and a leading 1 are stripped. Text and fax webhooks write numbers with +1, like `+17185550142`.
- **Your account only.** A key reads and acts on its own account. Every read of calls, texts, faxes, recordings and caller history is written to your account's HIPAA access log, with the key that made it.

## Support

Questions, a key that leaked, or an endpoint that does not behave: reach us, and a person picks up.

[Email**office@vocatech.com**](mailto:office@vocatech.com) [Call, text or WhatsApp**718.395.1550**](tel:+17183951550)

Tell us the time, the path and the status you got. Never send us your key.

---

On the site at https://vocatech.com/developers/guide/quickstart

# Quickstart

Three steps take you from nothing to today's calls.

## 1Get a key

In the Vocatech portal, open your company and go to the **Integrations** page. Under **Portal Public API**, press **Generate** and copy the key. This is your main key, and it reaches every route. For texting only, make an [API Messaging key](https://vocatech.com/developers/guide/keys#keys) instead.

Put the key in an environment variable. The Node, Python and PHP examples in this guide use the small `vt()` helper below: save it next to your code. curl needs nothing else.

**curl**

```bash
# Put the key in the environment once. Every curl example reads it from there.
export VOCATECH_API_KEY="paste-your-key-here"

# That is all curl needs.
```

**Node**

```javascript
// vocatech.mjs: a tiny client. Node 18 or newer, no packages.
const BASE = 'https://api.vocatech.com';
const KEY = process.env.VOCATECH_API_KEY;

export async function vt(method, path, body) {
  const res = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Bearer ${KEY}`,
      ...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
    },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const text = await res.text();
  let data = null;
  try {
    data = text ? JSON.parse(text) : null;
  } catch {
    data = { raw: text };
  }
  if (!res.ok) {
    const err = new Error(`${res.status} ${errorText(data)}`);
    err.status = res.status;
    err.retryAfter = Number(res.headers.get('retry-after')) || null;
    err.body = data;
    throw err;
  }
  return data;
}

// The API writes errors in a few shapes. This reads all of them.
export function errorText(body) {
  if (!body) return '';
  if (typeof body.error === 'string') return body.message || body.error;
  if (body.error) return body.error.message || body.error.description || '';
  return body.raw || '';
}
```

**Python**

```python
# vocatech.py: a tiny client. Python 3.8 or newer, and: pip install requests
import os

import requests

BASE = "https://api.vocatech.com"
KEY = os.environ["VOCATECH_API_KEY"]


def error_text(body):
    """The API writes errors in a few shapes. This reads all of them."""
    if not isinstance(body, dict):
        return ""
    err = body.get("error")
    if isinstance(err, str):
        return body.get("message") or err
    if isinstance(err, dict):
        return err.get("message") or err.get("description") or ""
    return ""


class VocatechError(Exception):
    def __init__(self, status, body, retry_after):
        super().__init__(f"{status} {error_text(body)}")
        self.status = status
        self.body = body
        self.retry_after = retry_after


def vt(method, path, body=None, params=None):
    r = requests.request(
        method,
        BASE + path,
        params=params,
        json=body,
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=60,
    )
    try:
        data = r.json() if r.content else None
    except ValueError:
        data = {"raw": r.text}
    if not r.ok:
        retry = r.headers.get("Retry-After")
        raise VocatechError(r.status_code, data, int(retry) if retry else None)
    return data
```

**PHP**

```php
<?php
// vocatech.php: a tiny client. PHP 8.1 or newer, with the curl extension.
const VT_BASE = 'https://api.vocatech.com';

/** The API writes errors in a few shapes. This reads all of them. */
function vt_error_text(?array $body): string
{
    $error = $body['error'] ?? null;
    if (is_string($error)) {
        return (string) ($body['message'] ?? $error);
    }
    if (is_array($error)) {
        return (string) ($error['message'] ?? $error['description'] ?? '');
    }
    return '';
}

final class VocatechError extends RuntimeException
{
    public function __construct(
        public readonly int $status,
        public readonly ?array $body,
        public readonly ?int $retryAfter
    ) {
        parent::__construct($status . ' ' . vt_error_text($body), $status);
    }
}

function vt(string $method, string $path, ?array $body = null): ?array
{
    $retryAfter = null;
    $headers = ['Authorization: Bearer ' . getenv('VOCATECH_API_KEY')];
    $ch = curl_init(VT_BASE . $path);
    if ($body !== null) {
        $headers[] = 'Content-Type: application/json';
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
    }
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_HTTPHEADER     => $headers,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 60,
        CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$retryAfter): int {
            if (stripos($line, 'Retry-After:') === 0) {
                $retryAfter = (int) trim(substr($line, 12));
            }
            return strlen($line);
        },
    ]);
    $raw = curl_exec($ch);
    if ($raw === false) {
        throw new RuntimeException('Network error: ' . curl_error($ch));
    }
    $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    $data = $raw === '' ? null : json_decode($raw, true);
    if ($status >= 400) {
        throw new VocatechError($status, is_array($data) ? $data : null, $retryAfter);
    }
    return is_array($data) ? $data : null;
}
```

The Node examples are ES modules: save them as `.mjs` files and run them with Node 18 or newer.

## 2Check the connection

`GET /v1/whoami` answers with your company id and the address the API sees you calling from. Every key may call it.

**curl**

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

**Node**

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

const me = await vt('GET', '/v1/whoami');
console.log(me.company_id, me.client_ip, me.ip_restricted);
```

**Python**

```python
from vocatech import vt

me = vt("GET", "/v1/whoami")
print(me["company_id"], me["client_ip"], me["ip_restricted"])
```

**PHP**

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

$me = vt('GET', '/v1/whoami');
echo $me['company_id'], ' ', $me['client_ip'], PHP_EOL;
```

**Response200 OKCopy**

```json
{
  "client_ip": "203.0.113.24",
  "forwarded_for": ["203.0.113.24"],
  "company_id": 1234567890,
  "ip_restricted": false,
  "allowed_ips": []
}
```

If you plan to lock the key to your server's address, `client_ip` is the address to allow. See [IP allow-list](https://vocatech.com/developers/guide/keys#ip-allow-list).

## 3Pull today's calls

`GET /v1/calls` with no dates returns today, in New York time. Each call carries its journey: every menu, group and person it reached, with the AI summary, the transcript and a recording link when there is one.

**curl**

```bash
# No dates: today, in New York time
curl https://api.vocatech.com/v1/calls \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# One day, in your own time zone
curl "https://api.vocatech.com/v1/calls?start_date=2026-09-23&end_date=2026-09-23&timezone=America/Chicago" \
  -H "Authorization: Bearer $VOCATECH_API_KEY"
```

**Node**

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

const today = await vt('GET', '/v1/calls');
console.log(`${today.meta.total_calls} calls today`);

for (const call of today.calls) {
  console.log(call.start_time, call.direction, call.remote_number, call.extension_name);
}
```

**Python**

```python
from vocatech import vt

today = vt("GET", "/v1/calls")
print(today["meta"]["total_calls"], "calls today")

for call in today["calls"]:
    print(call["start_time"], call["direction"], call["remote_number"], call["extension_name"])
```

**PHP**

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

$today = vt('GET', '/v1/calls');
echo $today['meta']['total_calls'], " calls today\n";

foreach ($today['calls'] as $call) {
    echo "{$call['start_time']} {$call['direction']} {$call['remote_number']} {$call['extension_name']}\n";
}
```

**Response200 OKCopy**

```json
{
  "query": {
    "company_id": 1234567890,
    "start_date": "2026-09-24",
    "end_date": "2026-09-24",
    "direction": "any",
    "status": "any",
    "search": "",
    "timezone": "America/New_York"
  },
  "calls": [
    {
      "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-24T13:02:11.000Z",
      "end_time": "2026-09-24T13:06:49.000Z",
      "duration": 278,
      "journey": [
        {
          "order": 1,
          "type": "auto_attendant",
          "extension_name": "Main Menu",
          "extension": "900",
          "start_time": "2026-09-24T13:02:11.000Z",
          "end_time": "2026-09-24T13:02:24.000Z",
          "duration": 13,
          "summary": null,
          "transcription": null,
          "recording_url": null,
          "recordings": []
        },
        {
          "order": 2,
          "type": "user",
          "extension_name": "Maya Chen",
          "extension": "104",
          "start_time": "2026-09-24T13:02:24.000Z",
          "end_time": "2026-09-24T13:06:49.000Z",
          "duration": 265,
          "summary": "Jordan asked to move the Thursday visit to Friday morning. Maya booked Friday at 9 AM.",
          "transcription": "Maya: Good morning, how can I help?\nJordan: Hi, I need to move my Thursday visit.\nMaya: Sure. Friday at 9 AM works.",
          "recording_url": "https://api.vocatech.com/v1/media/rec_58213407",
          "recordings": [
            {
              "part": 1,
              "parts": 1,
              "recording_id": 10592210,
              "start_time": "2026-09-24T13:02:25.000Z",
              "duration": 264,
              "url": "https://api.vocatech.com/v1/media/rec_58213407?part=1"
            }
          ]
        }
      ]
    }
  ],
  "meta": {
    "page": 1,
    "limit": 500,
    "total_pages": 1,
    "total_calls": 1
  }
}
```

Next, walk every page and download the recordings in [Pull today's calls](https://vocatech.com/developers/guide/pull-calls), or have events pushed to you in [Receive webhooks](https://vocatech.com/developers/guide/receive-webhooks).

---

On the site at https://vocatech.com/developers/guide/keys

# Keys and access

Every request carries a key in the `Authorization` header:

**HeaderCopy**

```http
Authorization: Bearer YOUR_API_KEY
```

A key is an opaque 32-character secret. It is not a JWT, and there is nothing inside it to read. Keep it on your server, in an environment variable or a secret store, and out of logs, URLs and code.

No key, a wrong key, or a key that was turned off or regenerated gets `401`. If the API cannot check a key for a moment, it answers `503` with `Retry-After: 5`. That is not a revoked key: wait a few seconds and try again.

**Response401 UnauthorizedCopy**

```json
{
  "error": "Unauthorized",
  "message": "Invalid token or inactive user."
}
```

## Main and API Messaging keys

The portal makes two kinds of key. The API itself cannot create or change keys.

| Property | Main key | API Messaging keys |
| --- | --- | --- |
| Made in | Integrations page, Portal Public API, **Generate** | Textdock, **API Messaging** |
| How many | One per account | Up to 10 per account: one for each integrator, each with its own label |
| Reaches | Every route | Texting only (the `messaging` scope) |
| Sending texts | Up to 20 a minute and 200 a day through `POST /v1/messages`, inside the account's included texting (50 texts an hour and 150 a day as standard, shared with the texts the account's people send) | A whole batch is accepted and paced out by a queue, 60 message parts a minute and 5,000 messages a day to start, set per key. Paid per message, so the account's included texting limit never holds these back and they never use it up |
| IP allow-list | Optional | Required |

API Messaging keys are metered. A key can also be narrowed to `agents`, `support` or `knowledge`, for an AI agent, a help desk or a chat bot that should reach nothing else. [Ask Vocatech support](https://vocatech.com/developers/guide#support) for one.

## Scopes

A key with no scope reaches every route. A narrowed key reaches only the routes of its scopes. Any key may call `GET /v1/whoami`.

| Scope | Reaches | Notes |
| --- | --- | --- |
| `messaging` | `/v1/messages` (send, history, queue), `/v1/reports/messages`, `/v1/media`, `/v1/webhooks` | Media: message attachments only, never recordings or faxes. Webhooks: the three message events, on endpoints this key created. |
| `agents` | `/v1/agents`, `/v1/callers`, `/v1/contacts/lookup` | What an AI agent needs: who is calling, and your agents. |
| `support` | `/v1/support`, `/v1/knowledge` | Open tickets and ask the knowledge base. |
| `knowledge` | `/v1/knowledge` | Ask the knowledge base, nothing else. |

Outside its scope, a key gets `403`:

**Response403 ForbiddenCopy**

```json
{
  "error": "Forbidden",
  "message": "This API key is limited to messaging and cannot be used for /v1/calls.",
  "scopes": "messaging",
  "path": "/v1/calls"
}
```

## IP allow-list

Lock a key to the addresses your servers call from, and a copied key is useless anywhere else.

- Enter exact IPv4 or IPv6 addresses. Ranges are not accepted. An empty list means any address.
- Main key: on the Integrations page, fill **Allowed IPs** and press **Save allowed IPs**. It is optional.
- API Messaging keys: the portal requires the list when it makes the key.

Before you lock a key, call `GET /v1/whoami` from the server that will use it and allow the `client_ip` it reports. The **Connections** panel next to the key lists every address the key was called from. Matching is exact: an IPv4 address and its IPv6 form (`::ffff:203.0.113.24`) count as two different addresses.

A request from any other address gets `403`. The answer names the address the API saw, so when calls that worked start failing, you can see at once that your server's address changed:

**Response403 ForbiddenCopy**

```json
{
  "error": "Forbidden",
  "message": "This API key is restricted to specific IP addresses and 203.0.113.24 is not one of them.",
  "client_ip": "203.0.113.24"
}
```

## Rotating a key

Press **Regenerate** next to the key and confirm. The new key replaces the old one at once: from that moment the old key gets `401`. So rotate in this order:

1. Make sure your system can take a new key quickly, from a secret store or an environment variable.
2. Regenerate in the portal and copy the new key.
3. Put it in place and restart whatever reads it.

Regenerating the main key does not touch your API Messaging keys, and each API Messaging key regenerates on its own. If a key leaks, regenerate it right away and [tell us](https://vocatech.com/developers/guide#support).

---

On the site at https://vocatech.com/developers/guide/pull-calls

# Pull today’s calls

1. Ask `GET /v1/calls` with `sort=day_asc` and `limit=500`, and raise `page` until it reaches `meta.total_pages`.
2. Read each call's `journey`: one entry per leg, for every menu, group and person. A transcribed leg has `summary` and `transcription`. A recorded leg has `recording_url`.
3. `recording_url` is an API address. Call it with your key: the answer holds a signed `url` that works for 30 minutes with no key. Download from there.
4. A call parked and picked up again is recorded in parts. Each leg lists its parts in `recordings`, oldest first, each with its own `url`. `recording_url`, `summary` and `transcription` are the latest part's. To keep every part, fetch each entry of `recordings` the same way.

**curl**

```bash
# 1. Today's calls, New York time, 500 a page, oldest first
curl "https://api.vocatech.com/v1/calls?sort=day_asc&limit=500&page=1" \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# 2. A recording: ask for a signed link, then download it with no key (uses jq)
url=$(curl -s https://api.vocatech.com/v1/media/rec_58213407 \
  -H "Authorization: Bearer $VOCATECH_API_KEY" | jq -r .url)
curl -o rec_58213407.mp3 "$url"
```

**Node**

```javascript
import { writeFile } from 'node:fs/promises';
import { vt } from './vocatech.mjs';

// Today, New York time. Add start_date, end_date and timezone for other days.
for (let page = 1, pages = 1; page <= pages; page++) {
  const data = await vt('GET', `/v1/calls?sort=day_asc&limit=500&page=${page}`);
  pages = data.meta.total_pages;

  for (const call of data.calls) {
    for (const leg of call.journey) {
      if (leg.summary) console.log(call.call_id, leg.extension_name, leg.summary);

      if (leg.recording_url) {
        // recording_url is an API address: ask it for a signed download link.
        const media = await vt('GET', new URL(leg.recording_url).pathname);
        const audio = await fetch(media.url); // no key needed, good for 30 minutes
        await writeFile(`${media.id}.${media.format}`, Buffer.from(await audio.arrayBuffer()));
      }
    }
  }
}
```

**Python**

```python
from urllib.parse import urlparse

import requests
from vocatech import vt

# Today, New York time. Add start_date, end_date and timezone for other days.
page = 1
while True:
    data = vt("GET", "/v1/calls", params={"sort": "day_asc", "limit": 500, "page": page})

    for call in data["calls"]:
        for leg in call["journey"]:
            if leg["summary"]:
                print(call["call_id"], leg["extension_name"], leg["summary"])

            if leg["recording_url"]:
                # recording_url is an API address: ask it for a signed download link.
                media = vt("GET", urlparse(leg["recording_url"]).path)
                audio = requests.get(media["url"], timeout=120)  # no key needed
                with open(f'{media["id"]}.{media["format"]}', "wb") as f:
                    f.write(audio.content)

    if page >= data["meta"]["total_pages"]:
        break
    page += 1
```

**PHP**

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

// Today, New York time. Add start_date, end_date and timezone for other days.
for ($page = 1, $pages = 1; $page <= $pages; $page++) {
    $data = vt('GET', '/v1/calls?' . http_build_query(['sort' => 'day_asc', 'limit' => 500, 'page' => $page]));
    $pages = $data['meta']['total_pages'];

    foreach ($data['calls'] as $call) {
        foreach ($call['journey'] as $leg) {
            if ($leg['summary']) {
                echo $call['call_id'], ' ', $leg['extension_name'], ': ', $leg['summary'], PHP_EOL;
            }
            if ($leg['recording_url']) {
                // recording_url is an API address: ask it for a signed download link.
                $media = vt('GET', parse_url($leg['recording_url'], PHP_URL_PATH));
                // The signed link needs no key and works for 30 minutes.
                file_put_contents("{$media['id']}.{$media['format']}", file_get_contents($media['url']));
            }
        }
    }
}
```

**Transcripts come after the call.** They usually land minutes after a call ends and can take longer on a busy day, so a call you pull right away may not have one yet. Pull again later, or subscribe to `call.transcription` and have it pushed to you.

For another day or range, add `start_date`, `end_date` (`YYYY-MM-DD`) and your `timezone`. Filters for direction, status, extension and a text search are in the [Calls reference](https://vocatech.com/developers/guide/calls).

---

On the site at https://vocatech.com/developers/guide/send-a-text

# Send a text

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

**curl**

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

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

**Node**

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

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

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

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

**Python**

```python
from vocatech import VocatechError, vt

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

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

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

**PHP**

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

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

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

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

**Dry run response200 OKCopy**

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

**Send response201 CreatedCopy**

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

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

## What the status means

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

## Pictures and files (MMS)

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

**curl**

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

**Node**

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

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

**Python**

```python
from vocatech import vt

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

**PHP**

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

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

**Response201 CreatedCopy**

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

## Long texts

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

## Delivery receipts

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

## WhatsApp

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

**Response422 Unprocessable EntityCopy**

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

---

On the site at https://vocatech.com/developers/guide/receive-webhooks

# Receive webhooks

Have events pushed to your server instead of polling for them.

1**Create an endpoint** with the events you want. The answer holds `secret_key`. It is shown this once, so store it with your other secrets.

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/webhooks \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CRM sync",
    "url": "https://YOUR_DOMAIN/webhooks/vocatech",
    "event_filters": ["call.ended", "call.transcription", "message.received"]
  }'
```

**Node**

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

const hook = await vt('POST', '/v1/webhooks', {
  name: 'CRM sync',
  url: 'https://YOUR_DOMAIN/webhooks/vocatech',
  event_filters: ['call.ended', 'call.transcription', 'message.received'],
});

// hook.secret_key is shown this once. Put it in your secret store now.
console.log('webhook', hook.id, 'created');
```

**Python**

```python
from vocatech import vt

hook = vt("POST", "/v1/webhooks", {
    "name": "CRM sync",
    "url": "https://YOUR_DOMAIN/webhooks/vocatech",
    "event_filters": ["call.ended", "call.transcription", "message.received"],
})

# hook["secret_key"] is shown this once. Put it in your secret store now.
print("webhook", hook["id"], "created")
```

**PHP**

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

$hook = vt('POST', '/v1/webhooks', [
    'name'          => 'CRM sync',
    'url'           => 'https://YOUR_DOMAIN/webhooks/vocatech',
    'event_filters' => ['call.ended', 'call.transcription', 'message.received'],
]);

// $hook['secret_key'] is shown this once. Put it in your secret store now.
echo 'webhook ', $hook['id'], " created\n";
```

**Response201 CreatedCopy**

```json
{
  "id": 42,
  "company_id": 1234567890,
  "name": "CRM sync",
  "description": null,
  "url": "https://YOUR_DOMAIN/webhooks/vocatech",
  "event_filters": ["call.ended", "call.transcription", "message.received"],
  "extension_filters": [],
  "number_filters": [],
  "enabled": true,
  "created_at": "2026-09-24 13:40:00",
  "updated_at": "2026-09-24 13:40:00",
  "secret_key": "4f1c2e6a9b0d3f7e8a5c1b2d6e9f0a3c4b7d8e1f2a5c6b9d0e3f4a7b8c1d2e5f"
}
```

2**Verify, answer, then work.** Check the `X-Vocatech-Signature` header, answer `2xx` within 5 seconds, and do the work after you answer. The receivers in [Capture the raw body](https://vocatech.com/developers/guide/webhooks#raw-body) do all three.

3**Dedupe.** A retry repeats the same event `id`, so skip an id you already have. For message events, `data.message_id` names the message itself.

4**Test it.** Send a signed test event to the endpoint. The answer says what your server replied.

**curl**

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

**Node**

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

const result = await vt('POST', '/v1/webhooks/42/test');
console.log(result.success, result.status_code, result.error);
```

**Python**

```python
from vocatech import vt

result = vt("POST", "/v1/webhooks/42/test")
print(result["success"], result["status_code"], result["error"])
```

**PHP**

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

$result = vt('POST', '/v1/webhooks/42/test');
echo $result['success'] ? 'delivered' : 'failed: ' . $result['error'], PHP_EOL;
```

**Response200 OKCopy**

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

You can also manage webhooks in the portal, in company settings under Webhooks. Every event and payload is in [Webhooks in depth](https://vocatech.com/developers/guide/webhooks).

---

On the site at https://vocatech.com/developers/guide/look-up-a-caller

# Look up a caller

Two routes answer "who is this?". One is rich, one is lean.

| Route | Answers with | Use it for |
| --- | --- | --- |
| `GET /v1/callers/{number}` | Contacts (up to 5), recent calls with who handled them and the AI summary, texts and WhatsApp messages, and the last call each way | An AI agent or a screen that needs context before hello |
| `GET /v1/contacts/lookup?phone=` | The contact records for the number, up to 10 | Matching a caller to a record in your CRM |

**curl**

```bash
# Everything about one number: the last 30 days, up to 5 calls
curl "https://api.vocatech.com/v1/callers/7185550142?days=30&limit=5" \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# Only the contact records
curl "https://api.vocatech.com/v1/contacts/lookup?phone=7185550142" \
  -H "Authorization: Bearer $VOCATECH_API_KEY"
```

**Node**

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

// Everything about one number: the last 30 days, up to 5 calls
const who = await vt('GET', '/v1/callers/7185550142?days=30&limit=5');
const lastCall = who.last_call ? who.last_call.summary : 'no calls';
console.log(who.counts, lastCall);

// Only the contact records, for a screen pop or a CRM match
const hit = await vt('GET', '/v1/contacts/lookup?phone=7185550142');
if (hit.found) console.log(hit.contacts.map((c) => c.fields));
```

**Python**

```python
from vocatech import vt

# Everything about one number: the last 30 days, up to 5 calls
who = vt("GET", "/v1/callers/7185550142", params={"days": 30, "limit": 5})
print(who["counts"], who["last_call"]["summary"] if who["last_call"] else "no calls")

# Only the contact records, for a screen pop or a CRM match
hit = vt("GET", "/v1/contacts/lookup", params={"phone": "7185550142"})
if hit["found"]:
    print([c["fields"] for c in hit["contacts"]])
```

**PHP**

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

// Everything about one number: the last 30 days, up to 5 calls
$who = vt('GET', '/v1/callers/7185550142?days=30&limit=5');
echo $who['last_call']['summary'] ?? 'no calls', PHP_EOL;

// Only the contact records, for a screen pop or a CRM match
$hit = vt('GET', '/v1/contacts/lookup?phone=7185550142');
if ($hit['found']) {
    print_r(array_column($hit['contacts'], 'fields'));
}
```

**GET /v1/callers response200 OKTrimmedCopy**

```json
{
  "number": "7185550142",
  "days": 30,
  "contacts": [
    { "id": 5012, "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } }
  ],
  "calls": [
    {
      "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c",
      "direction": "incoming",
      "status": "answered",
      "start_time": "2026-09-24T13:02:11.000Z",
      "end_time": "2026-09-24T13:06:49.000Z",
      "duration": 278,
      "remote_name": "Jordan Reyes",
      "remote_number": "7185550142",
      "our_number": "7185550100",
      "handled_by": { "extension": "104", "name": "Maya Chen" },
      "summary": "Jordan asked to move the Thursday visit to Friday morning. Maya booked Friday at 9 AM.",
      "journey": [
        {
          "order": 2,
          "type": "user",
          "extension": "104",
          "extension_name": "Maya Chen",
          "start_time": "2026-09-24T13:02:24.000Z",
          "duration": 265,
          "summary": "Jordan asked to move the Thursday visit to Friday morning. Maya booked Friday at 9 AM."
        }
      ]
    }
  ],
  "messages": [
    {
      "channel": "text",
      "direction": "outgoing",
      "body": "Hi Jordan, your appointment is confirmed for Friday at 9 AM.",
      "sent_time": "2026-09-24T13:15:02Z",
      "our_number": "7185550100",
      "remote_number": "7185550142"
    }
  ],
  "last_call": { "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c", "direction": "incoming" },
  "last_incoming": { "call_id": "8a1f3c2e-5b7d-4e9a-9c1b-2d4e6f8a0b1c", "direction": "incoming" },
  "last_outgoing": null,
  "counts": { "contacts": 1, "calls": 1, "messages": 1 }
}
```

**GET /v1/contacts/lookup response200 OKCopy**

```json
{
  "phone": "7185550142",
  "found": true,
  "contacts": [
    { "id": 5012, "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } }
  ]
}
```

- Any formatting works in the number, and a leading 1 is dropped. It needs at least 7 digits.
- `days` looks back 1 to 365 days, 90 by default. `limit` caps the calls, and the messages, at 1 to 50, 10 by default.
- `last_call`, `last_incoming` and `last_outgoing` repeat an entry from `calls`, or are `null`.
- Transcripts stay out unless you add `include=transcripts`. Every read is written to your HIPAA access log.

---

On the site at https://vocatech.com/developers/guide/ai-agent-call

# Place an AI agent call

Have one of your AI agents call one person: a reminder, a confirmation, a callback, a notice.

1. **Pick an agent.** `GET /v1/agents` lists them. It must be `active`, with `outgoing_calls` on (the Outgoing calls switch on the agent's Persona tab).
2. **Place the call** with who to call and why. The answer is `202` once the call is placed. Send an `Idempotency-Key` header, your own id for the request, so a retry after a timeout gets the same call instead of placing a second one.
3. **Get the outcome.** The agent greets, says the purpose, and handles the answer with its own jobs. It reports how it went the way it reports every message: through the message delivery and message action set on the agent in the portal, marked with your `reference`.

**curl**

```bash
# 1. Find an agent that may place calls
curl https://api.vocatech.com/v1/agents \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# 2. Have it call one person (the Idempotency-Key makes a retry safe)
curl -X POST https://api.vocatech.com/v1/agents/7/calls \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: visit-20260925-0900" \
  -d '{
    "to": "7185550142",
    "purpose": "confirm the Friday 9 AM visit",
    "person_name": "Jordan Reyes",
    "reference": "visit-20260925-0900"
  }'
```

**Node**

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

// 1. An agent that is on and may place calls
const { agents } = await vt('GET', '/v1/agents');
const agent = agents.find((a) => a.active && a.outgoing_calls);

// 2. One call, to one person
const { call } = await vt('POST', `/v1/agents/${agent.id}/calls`, {
  to: '7185550142',
  purpose: 'confirm the Friday 9 AM visit',
  person_name: 'Jordan Reyes',
  reference: 'visit-20260925-0900',
});
console.log(call.call_sid, call.state); // 202: placed
```

**Python**

```python
from vocatech import vt

# 1. An agent that is on and may place calls
agents = vt("GET", "/v1/agents")["agents"]
agent = next(a for a in agents if a["active"] and a["outgoing_calls"])

# 2. One call, to one person
call = vt("POST", f"/v1/agents/{agent['id']}/calls", {
    "to": "7185550142",
    "purpose": "confirm the Friday 9 AM visit",
    "person_name": "Jordan Reyes",
    "reference": "visit-20260925-0900",
})["call"]
print(call["call_sid"], call["state"])  # 202: placed
```

**PHP**

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

// 1. An agent that is on and may place calls
$agents = vt('GET', '/v1/agents')['agents'];
$agent = current(array_filter($agents, fn ($a) => $a['active'] && $a['outgoing_calls']));

// 2. One call, to one person
$call = vt('POST', "/v1/agents/{$agent['id']}/calls", [
    'to'          => '7185550142',
    'purpose'     => 'confirm the Friday 9 AM visit',
    'person_name' => 'Jordan Reyes',
    'reference'   => 'visit-20260925-0900',
])['call'];
echo $call['call_sid'], ' ', $call['state'], PHP_EOL; // 202: placed
```

**GET /v1/agents response200 OKCopy**

```json
{
  "agents": [
    {
      "id": 7,
      "name": "Front desk",
      "extension": "850",
      "active": true,
      "outgoing_calls": true,
      "provisioned": true
    }
  ],
  "meta": { "total": 1 }
}
```

**POST /v1/agents/7/calls response202 AcceptedCopy**

```json
{
  "call": {
    "call_sid": "5d7e9a1c-3b2f-4c8e-a6d0-9f1b2c3d4e5f",
    "agent_id": 7,
    "from": "7185550100",
    "to": "7185550142",
    "state": "trying",
    "kind": "notice",
    "purpose": "confirm the Friday 9 AM visit",
    "reference": "visit-20260925-0900"
  }
}
```

- One call per request. This is not a bulk dialer.
- The agent calls inside its calling hours only: 8 AM to 9 PM in the agent's time zone, unless you changed them on its Persona tab. Outside them the call is refused with `422`. Send `"allow_quiet_hours": true` to place it anyway.
- `to` is a 10-digit US or Canadian number, or an extension of 2 to 6 digits.
- It never dials 911 or another N11 service code, 933 or 988, a premium number (900 or 976), or an area code that belongs to another country, such as those in the Caribbean. US territories are fine.
- Each key may place 10 agent calls a minute and 500 a day.

---

On the site at https://vocatech.com/developers/guide/support-ticket

# Open a support ticket

Open a ticket with Vocatech support from your help desk, an automation or an AI agent's action. It lands in our support inbox and is worked like an email or a text.

**curl**

```bash
curl -X POST https://api.vocatech.com/v1/support/tickets \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Fax line not receiving",
    "message": "Our fax line has not received anything since 8 AM. Please call back.",
    "contact_name": "Maya Chen",
    "contact_phone": "7185550100",
    "reply_by": "call",
    "reference": "helpdesk-4821",
    "source": "Office help desk"
  }'
```

**Node**

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

const { ticket } = await vt('POST', '/v1/support/tickets', {
  subject: 'Fax line not receiving',
  message: 'Our fax line has not received anything since 8 AM. Please call back.',
  contact_name: 'Maya Chen',
  contact_phone: '7185550100',
  reply_by: 'call',
  reference: 'helpdesk-4821', // the same reference within 7 days adds to this ticket
  source: 'Office help desk',
});
console.log(ticket.key, ticket.appended ? 'added to the open ticket' : 'opened');
```

**Python**

```python
from vocatech import vt

ticket = vt("POST", "/v1/support/tickets", {
    "subject": "Fax line not receiving",
    "message": "Our fax line has not received anything since 8 AM. Please call back.",
    "contact_name": "Maya Chen",
    "contact_phone": "7185550100",
    "reply_by": "call",
    "reference": "helpdesk-4821",  # the same reference within 7 days adds to this ticket
    "source": "Office help desk",
})["ticket"]
print(ticket["key"], "added" if ticket["appended"] else "opened")
```

**PHP**

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

$ticket = vt('POST', '/v1/support/tickets', [
    'subject'       => 'Fax line not receiving',
    'message'       => 'Our fax line has not received anything since 8 AM. Please call back.',
    'contact_name'  => 'Maya Chen',
    'contact_phone' => '7185550100',
    'reply_by'      => 'call',
    'reference'     => 'helpdesk-4821', // the same reference within 7 days adds to this ticket
    'source'        => 'Office help desk',
])['ticket'];
echo $ticket['key'], $ticket['appended'] ? ' added' : ' opened', PHP_EOL;
```

**Response201 CreatedCopy**

```json
{
  "ticket": {
    "key": "case-20260924-133000-7d2f0c9a1b3e4d5f6a7b8c9d0e1f2a3b",
    "subject": "Fax line not receiving",
    "status": "open",
    "created_at": "2026-09-24T13:30:00+00:00",
    "appended": false
  }
}
```

- `message` is required: what the person needs, in their own words, up to 4,000 characters.
- The same `reference` within 7 days adds to the ticket it opened, and answers `200` with `appended: true`.
- `409` means the ticket is busy right now. Wait for `Retry-After` (60 seconds) and send it again.
- Each key may open 10 tickets a minute and 200 a day.

---

On the site at https://vocatech.com/developers/guide/sync-contacts

# Sync contacts

Keep the contacts that pop up on calls in step with your own system.

1. `GET /v1/contacts/fields` returns your field names. Fields with `is_match` decide which contact a row updates. Fields with `is_phone` hold numbers.
2. `POST /v1/contacts` takes up to 500 contacts per request. A row updates the contact whose match fields all equal it, or creates a new one.
3. `DELETE /v1/contacts` removes the ids you deleted on your side, up to 500 per request.

**curl**

```bash
# 1. Your field names
curl https://api.vocatech.com/v1/contacts/fields \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# 2. Create or update, up to 500 per request
curl -X POST https://api.vocatech.com/v1/contacts \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "contacts": [
      { "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } },
      { "fields": { "Name": "Riley Morgan", "Phone": "7185550143;7185550144", "Account": "A-1043" } }
    ]
  }'

# 3. Delete, up to 500 ids per request
curl -X DELETE https://api.vocatech.com/v1/contacts \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "ids": [5031, 5032] }'
```

**Node**

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

// 1. Your field names, and which ones decide "same contact"
const { fields } = await vt('GET', '/v1/contacts/fields');
console.log(fields.map((f) => (f.is_match ? `${f.name} (match)` : f.name)));

// 2. Create or update, up to 500 per request. Use your own field names.
const rows = [
  { fields: { Name: 'Jordan Reyes', Phone: '7185550142', Account: 'A-1042' } },
  { fields: { Name: 'Riley Morgan', Phone: '7185550143;7185550144', Account: 'A-1043' } },
];
for (let i = 0; i < rows.length; i += 500) {
  const res = await vt('POST', '/v1/contacts', { contacts: rows.slice(i, i + 500) });
  console.log(res.summary);
  for (const e of res.errors) console.warn('row', i + e.index, e.message);
}

// 3. Remove the ones you deleted on your side
await vt('DELETE', '/v1/contacts', { ids: [5031, 5032] });
```

**Python**

```python
from vocatech import vt

# 1. Your field names, and which ones decide "same contact"
fields = vt("GET", "/v1/contacts/fields")["fields"]
print([f["name"] + (" (match)" if f["is_match"] else "") for f in fields])

# 2. Create or update, up to 500 per request. Use your own field names.
rows = [
    {"fields": {"Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042"}},
    {"fields": {"Name": "Riley Morgan", "Phone": "7185550143;7185550144", "Account": "A-1043"}},
]
for i in range(0, len(rows), 500):
    res = vt("POST", "/v1/contacts", {"contacts": rows[i:i + 500]})
    print(res["summary"])
    for e in res["errors"]:
        print("row", i + e["index"], e["message"])

# 3. Remove the ones you deleted on your side
vt("DELETE", "/v1/contacts", {"ids": [5031, 5032]})
```

**PHP**

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

// 1. Your field names, and which ones decide "same contact"
$fields = vt('GET', '/v1/contacts/fields')['fields'];
foreach ($fields as $f) {
    echo $f['name'], $f['is_match'] ? ' (match)' : '', PHP_EOL;
}

// 2. Create or update, up to 500 per request. Use your own field names.
$rows = [
    ['fields' => ['Name' => 'Jordan Reyes', 'Phone' => '7185550142', 'Account' => 'A-1042']],
    ['fields' => ['Name' => 'Riley Morgan', 'Phone' => '7185550143;7185550144', 'Account' => 'A-1043']],
];
foreach (array_chunk($rows, 500) as $n => $batch) {
    $res = vt('POST', '/v1/contacts', ['contacts' => $batch]);
    foreach ($res['errors'] as $e) {
        echo 'row ', $n * 500 + $e['index'], ': ', $e['message'], PHP_EOL;
    }
}

// 3. Remove the ones you deleted on your side
vt('DELETE', '/v1/contacts', ['ids' => [5031, 5032]]);
```

**POST /v1/contacts response200 OKCopy**

```json
{
  "summary": { "total": 2, "created": 1, "updated": 1, "errors": 0 },
  "contacts": [
    {
      "contact": { "id": 5012, "fields": { "Name": "Jordan Reyes", "Phone": "7185550142", "Account": "A-1042" } },
      "action": "updated"
    },
    {
      "contact": { "id": 5033, "fields": { "Name": "Riley Morgan", "Phone": "7185550143;7185550144", "Account": "A-1043" } },
      "action": "created"
    }
  ],
  "errors": []
}
```

- Use the field names exactly as `GET /v1/contacts/fields` returns them. Unknown names are ignored.
- Phone fields keep digits only and drop a leading 1. Put several numbers in one field with semicolons: `7185550143;7185550144`.
- Values over 255 characters are cut.
- A batch answers `200` with a summary, and one entry in `errors` for each row that failed, by its index in your batch. A single contact sent as `{"fields": {...}}` answers `201` when created and `200` when updated.

---

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

# Calls

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/calls` | Calls with their journey, summaries, transcripts and recording links |
| GET | `/v1/reports/calls` | The old name for the same list. It still answers: move to `/v1/calls` |

| Parameter | Meaning |
| --- | --- |
| `start_date`, `end_date` | `YYYY-MM-DD`, both or neither. Neither means today. At most 366 days. |
| `timezone` | An IANA name. The default is `America/New_York`. |
| `page`, `limit` | Pages from 1. `limit` is 1 to 500, 500 by default. |
| `sort` | `day_desc` (default), `day_asc`, `duration_desc` or `duration_asc` |
| `direction` | `incoming`, `outgoing` or `voicemail` |
| `status` | A leg status, such as `answered` or `missed`. `missed` never returns an outgoing call. |
| `extension` | Calls with a leg on this extension |
| `type` | Calls with a leg of this type, such as `user`, `hunt_group`, `auto_attendant` or `call_center` |
| `search` | Part of the call id, the caller's name, an extension, the caller's number or your number |

`status`, `extension` and `type` filter legs: a call comes back when one of its legs matches, and its journey then shows the matching legs only.

Each call has `call_id`, `direction`, `status` (its first leg's), `extension` and `extension_name` (the person who took or placed it), `remote_name`, `remote_number`, `group_number` (your number on the call), `start_time`, `end_time`, `duration` in seconds, and `journey`. Each leg has `order`, `type`, `extension`, `extension_name`, its times and `duration`, `summary`, `transcription`, `recording_url` and `recordings`. A call an employee places through the dial-out portal reads `outgoing`.

**Recordings in parts.** A call 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. `recordings` lists them oldest first, each with `part`, `parts`, `recording_id`, `start_time`, `duration` and `url` (`GET /v1/media/rec_<n>?part=N`). `recording_url`, `summary` and `transcription` are the latest part's. Nearly every leg has one part, and a leg that was not recorded has an empty list.

[Calls in the API reference](https://vocatech.com/developers/reference/calls)

---

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

# Messages

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/v1/messages` | Send a text or an MMS |
| GET | `/v1/messages` | Text and WhatsApp history |
| GET | `/v1/messages/queue` | What an API Messaging key has waiting, and what is left of today |
| DELETE | `/v1/messages/queue` | Cancel everything still waiting |
| DELETE | `/v1/messages/queue/{id}` | Cancel one waiting message |
| GET | `/v1/reports/messages` | The old name for `GET /v1/messages`. It still answers |

## Send: POST /v1/messages

| Field | Meaning |
| --- | --- |
| `platform` | `text`. `whatsapp` answers `422`. |
| `from` | A texting number on your account |
| `to` | A 10-digit US or Canadian number |
| `message` | Up to 1,600 characters |
| `media` | Up to 10 `https` links, for an MMS. `media_url` also works. |
| `name` | Optional. The contact's name, for a new conversation. |
| `members` | Webex numbers only. The email addresses of the people to add to a new conversation space. Leave it out to use the number's default members. |
| `test` | `true` for a dry run |

The answer depends on how the number is set up in Textdock. A Webex number posts the text into the contact's conversation space in Webex, and opens one if needed (`mode: "webex"`). An API or email number sends it straight to the carrier (`mode: "api"` or `"email"`); an email number also sends a copy by email. Either way, `message_sent` says whether it left. The `message_id` a send returns is not guaranteed to match `data.message_id` on the webhook events for that text.

## History: GET /v1/messages

| Parameter | Meaning |
| --- | --- |
| `start_date`, `end_date`, `timezone`, `page`, `limit` | As for calls |
| `sort` | `day_desc` (default) or `day_asc` |
| `direction` | `incoming` or `outgoing` |
| `channel` | `text` or `whatsapp` |
| `status` | Texts always read `delivered` in this list, so other values return WhatsApp messages only |
| `search` | Part of either number or of the text |

Each message has `message_id`, `direction`, `status`, `remote_number`, `group_number`, `sent_time`, `channel`, `type` (`media` when it carries attachments), `body` and `attachments`. Fetch an attachment by its `att_` id through [Media](https://vocatech.com/developers/guide/media).

## The API Messaging queue

An API Messaging key never gets a per-minute `429` on sends. Its whole batch is accepted and sent in order at the key's pace:

- A send that fits the pace goes out at once (`201`). Once anything is waiting, new sends join the back of the line (`202`).
- The pace counts message parts in the last 60 seconds, 60 to start. The day counts messages in the last 24 hours, the waiting ones included, 5,000 to start. Vocatech sets both per key.
- Only the day can refuse a send: `429` when today's allowance, with what is waiting, is spoken for.
- Every number a key texts is looked up for its carrier before the send. Messages to T-Mobile numbers are also held to 960 a day per account (`limits.tmobile_day_allowance`), shared with the account's broadcasts; T-Mobile's day resets at midnight Pacific. When those are spent, a send to a T-Mobile number, or to a number whose carrier is not known yet, joins the queue (`202`) even with the minute open, and T-Mobile messages wait for the next day while the rest keep flowing.
- These sends are paid per message. The account's included texting limit (50 texts an hour and 150 a day as standard) is for the texts its people and the main key send. It never holds an API Messaging key back, and the key's sends never use it up. Incoming texts are free and count toward nothing.
- The queue works through the line about once a minute. A message gets up to 3 tries. Finished messages stay in the list for 7 days.

**curl**

```bash
# What this key has waiting, and what is left of today
curl https://api.vocatech.com/v1/messages/queue \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# Cancel one waiting message
curl -X DELETE https://api.vocatech.com/v1/messages/queue/48213 \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# Cancel everything still waiting: the wrong batch went in
curl -X DELETE https://api.vocatech.com/v1/messages/queue \
  -H "Authorization: Bearer $VOCATECH_API_KEY"
```

**Node**

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

const { queue, limits } = await vt('GET', '/v1/messages/queue');
console.log(queue.pending, 'waiting,', limits.remaining_today, 'left today');

await vt('DELETE', '/v1/messages/queue/48213'); // one message
const all = await vt('DELETE', '/v1/messages/queue'); // everything still waiting
console.log(all.canceled, 'canceled');
```

**Python**

```python
from vocatech import vt

q = vt("GET", "/v1/messages/queue")
print(q["queue"]["pending"], "waiting,", q["limits"]["remaining_today"], "left today")

vt("DELETE", "/v1/messages/queue/48213")  # one message
done = vt("DELETE", "/v1/messages/queue")  # everything still waiting
print(done["canceled"], "canceled")
```

**PHP**

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

$q = vt('GET', '/v1/messages/queue');
echo $q['queue']['pending'], ' waiting, ', $q['limits']['remaining_today'], " left today\n";

vt('DELETE', '/v1/messages/queue/48213'); // one message
$done = vt('DELETE', '/v1/messages/queue'); // everything still waiting
echo $done['canceled'], " canceled\n";
```

**POST /v1/messages response on an API Messaging key202 AcceptedCopy**

```json
{
  "status": "queued",
  "message": {
    "queue_id": 48213,
    "position": 61,
    "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/48213."
  }
}
```

**GET /v1/messages/queue response200 OKCopy**

```json
{
  "queue": {
    "pending": 2,
    "items": [
      {
        "queue_id": 48213,
        "to": "7185550142",
        "from": "7185550100",
        "status": "queued",
        "messages_counted": 1,
        "carrier": "tmobile",
        "attempts": 0,
        "error": null,
        "message_id": null,
        "created_at": "2026-09-24 13:15:02",
        "sent_at": null
      }
    ]
  },
  "limits": {
    "per_minute": 60,
    "per_day": 5000,
    "used_today": 812,
    "remaining_today": 4186,
    "tmobile_day_allowance": 960
  }
}
```

The list shows up to 200 items: everything waiting first, then finished ones, newest first. An item's `status` is `queued`, `sending`, `sent`, `failed` or `canceled`, and its times are UTC. Only a `queued` message can be canceled: one already sending or finished answers `409`, and an id that is not this key's answers `404`.

[Messages in the API reference](https://vocatech.com/developers/reference/messages)

---

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

# Faxes

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/v1/faxes` | Send a fax |
| GET | `/v1/faxes` | Fax history, sent and received |

| Field | Meaning |
| --- | --- |
| `from` | One of your active fax lines |
| `to` | A 10-digit US or Canadian number. Premium numbers (900, 976) and other countries' area codes are refused. |
| `file` | The document, a base64 PDF of at most 25 pages. Up to 20 MB. |
| `filename` | Optional. `document.pdf` by default. |
| `recipient_name`, `subject` | Optional |
| `test` | `true` for a dry run |

**curl**

```bash
# Remove "test": true to send it for real.
# base64 -w0 is GNU coreutils; on a Mac use: base64 -i intake-form.pdf
curl -X POST https://api.vocatech.com/v1/faxes \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d @- <<EOF
{
  "from": "7185550101",
  "to": "7185550143",
  "filename": "intake-form.pdf",
  "subject": "Signed intake form",
  "test": true,
  "file": "$(base64 -w0 intake-form.pdf)"
}
EOF
```

**Node**

```javascript
import { readFile } from 'node:fs/promises';
import { vt } from './vocatech.mjs';

const res = await vt('POST', '/v1/faxes', {
  from: '7185550101', // one of your fax lines
  to: '7185550143',
  file: (await readFile('intake-form.pdf')).toString('base64'),
  filename: 'intake-form.pdf',
  subject: 'Signed intake form',
  test: true, // remove to send it for real
});
console.log(res.status, res.fax);
```

**Python**

```python
import base64

from vocatech import vt

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

res = vt("POST", "/v1/faxes", {
    "from": "7185550101",  # one of your fax lines
    "to": "7185550143",
    "file": document,
    "filename": "intake-form.pdf",
    "subject": "Signed intake form",
    "test": True,  # remove to send it for real
})
print(res["status"], res["fax"])
```

**PHP**

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

$res = vt('POST', '/v1/faxes', [
    'from'     => '7185550101', // one of your fax lines
    'to'       => '7185550143',
    'file'     => base64_encode(file_get_contents('intake-form.pdf')),
    'filename' => 'intake-form.pdf',
    'subject'  => 'Signed intake form',
    'test'     => true, // remove to send it for real
]);
echo $res['status'], PHP_EOL;
```

**Dry run response200 OKCopy**

```json
{
  "status": "test",
  "fax": {
    "from": "7185550101",
    "to": "7185550143",
    "filename": "intake-form.pdf",
    "fax_line": "Front office fax",
    "valid": true
  }
}
```

**Send response201 CreatedCopy**

```json
{
  "status": "queued",
  "fax": {
    "fax_id": "fax_90412",
    "from": "7185550101",
    "to": "7185550143",
    "filename": "intake-form.pdf",
    "subject": "Signed intake form",
    "created_at": "2026-09-24T09:20:41-04:00"
  }
}
```

- Limits: a PDF of at most 25 pages and 20 MB (`400` for another file type or more pages, `413` above 20 MB), 3 faxes a minute and 50 faxes sent a day per account (the day runs midnight to midnight Eastern), and the account's monthly allowance of outbound pages, 1,000 a month for each fax line on the account unless Vocatech set another figure, pooled across the lines, the same count the portal and email to fax keep. The day and monthly limits answer `429` when reached. The fax API is for sending your own documents, not for marketing or bulk faxing.
- Faxing must be on for the account. Otherwise both fax routes answer `403`, as does a `from` that is not one of your active fax lines.
- If the fax service refuses the job, the send answers `500` with its reason in the message.
- `created_at` in the send answer carries New York time with its offset. The other times in the API are UTC.

History takes `start_date`, `end_date`, `timezone`, `page` and `limit` as for calls, `sort` (`day_desc` or `day_asc`), `direction` (`incoming` or `outgoing`), `status` (`in_progress`, `completed`, `failed` or `restricted`) and `search` (part of the other number or the subject). Each fax has `fax_id`, `direction`, `status`, `from`, `to`, `pages`, `subject`, `has_document` and `sent_at`. When `has_document` is true, fetch the PDF with `GET /v1/media/{fax_id}`.

[Faxes in the API reference](https://vocatech.com/developers/reference/faxes)

---

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

# Media

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/media/{id}` | A signed download link for one attachment, recording or fax |

| Id starts with | What it is | Where the id comes from |
| --- | --- | --- |
| `att_` | A message attachment | `attachments` in `GET /v1/messages` and in message webhooks |
| `rec_` | A call recording | `recording_url`, or a part's `url` in `recordings`, on a call leg |
| `fax_` | A fax document | `fax_id` in `GET /v1/faxes` and in fax webhooks |

**A recording200 OKCopy**

```json
{
  "id": "rec_58213407",
  "type": "recording",
  "content_type": "audio/mp3",
  "format": "mp3",
  "duration": 264,
  "url": "<a signed link, good for 30 minutes>",
  "expires_at": "2026-09-24T14:05:12.000Z",
  "part": 1,
  "parts": 1,
  "recording_id": 10592210,
  "start_time": "2026-09-24T13:02:25.000Z"
}
```

**An attachment200 OKCopy**

```json
{
  "id": "att_902114",
  "type": "attachment",
  "source": "sms",
  "content_type": "image/jpeg",
  "filename": "side-entrance.jpg",
  "size": 184220,
  "url": "<a signed link, good for 30 minutes>",
  "expires_at": "2026-09-24T14:05:12.000Z"
}
```

- The `url` works for 30 minutes and needs no key. Ask again for a fresh one.
- An attachment on a text you sent answers with the link you sent it with, and no `expires_at`.
- A recording answers with its latest part. Add `?part=N` for part N of a call that was parked and picked up again (from 1, oldest first). The answer names its `part` of `parts`, its `recording_id` and `start_time`. A part that does not exist answers `404`, and the message says how many parts there are.
- A fax answers with `content_type: application/pdf` and its `pages`.
- API Messaging keys may fetch attachments only. A recording or a fax answers them `403`.
- `404`: not found, not yours, or not ready yet. `410`: the attachment is gone. `503`: storage trouble on our side. The file exists, so tell us.

[Media in the API reference](https://vocatech.com/developers/reference/media)

---

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

# Callers

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/callers/{number}` | Everything the account knows about one number |

| Parameter | Meaning |
| --- | --- |
| `days` | How far back, 1 to 365. 90 by default. |
| `limit` | Up to how many calls, and messages, 1 to 50. 10 by default. |
| `include` | Any of `contacts`, `calls`, `messages` (the default is all three) and `transcripts` |

The answer has `number`, `days`, `contacts` (up to 5), `calls` (each with `handled_by`, `summary` and its journey), `messages` (text cut at 400 characters), `last_call`, `last_incoming`, `last_outgoing` and `counts`. The calls come from the same data as `GET /v1/calls`. Transcripts, when asked for, are cut at 6,000 characters. The `agents` scope reaches this route.

[Callers in the API reference](https://vocatech.com/developers/reference/callers)

---

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

# Contacts

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/contacts/fields` | Your contact fields, with `is_match`, `is_phone` and their order |
| GET | `/v1/contacts` | The contacts, 100 a page by default and up to 500, with `search` across every value |
| POST | `/v1/contacts` | Create or update one (`{"fields": {...}}`) or up to 500 (`{"contacts": [...]}`) |
| DELETE | `/v1/contacts` | Delete one (`{"id": 5031}`) or up to 500 (`{"ids": [...]}`) |
| GET | `/v1/contacts/lookup?phone=` | The contacts a number belongs to, up to 10 |

Deleting one contact answers `{"deleted": 5031}`, or `404` when it does not exist. A batch delete answers a summary with the ids it deleted and one error for each id it could not. The lookup needs at least 7 digits (`400` otherwise). Matching and phone rules are in [Sync contacts](https://vocatech.com/developers/guide/sync-contacts).

[Contacts in the API reference](https://vocatech.com/developers/reference/contacts)

---

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

# AI agents

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/agents` | Your AI agents: `id`, `name`, `extension`, `active`, `outgoing_calls`, `provisioned` |
| POST | `/v1/agents/{id}/calls` | Have one agent place one call |

| Field | Meaning |
| --- | --- |
| `to` | Required. A 10-digit number, or an extension of 2 to 6 digits. |
| `purpose` | Why the agent is calling, in a few words, up to 200 characters. `subject` also works. |
| `instructions` | What to do on the call, up to 2,000 characters. Send `purpose`, `instructions` or both. |
| `person_name` | Who to ask for, up to 80 characters. |
| `reference` | Your id for the matter: letters, digits, `.` `_` `:` and `-`, up to 80. It comes back with the outcome. |
| `kind` | `notice` (default), a short message with a question, or `callback`, the office returning their call. |
| `job` | The name of one of the agent's jobs to start with, such as a reschedule job. |
| `allow_quiet_hours` | `true` to call outside the agent's calling hours. |

Send an `Idempotency-Key` header, your own id for the request, up to 80 characters: a repeat of it within a day answers the same call (`202`, the same `call_sid`) instead of placing a second one. Without the header, the same agent, number and `reference` within two minutes is treated as the same request. An agent places at most 50 calls a day; past that the request is refused with `422` until the next day.

`404`: no agent with that id on your account. `422`: the call cannot be placed, and the message says why (a missing field, a refused number, the agent or its outgoing calls switched off, its line not ready yet, outside its calling hours, or the day's calls used up). `502`: the agent platform did not answer, and the call may have been placed: check for the placed-call tag (your `reference` in the agent's message) before you retry, or retry with the same `Idempotency-Key` and get the same call. `503`: outgoing calls are not available right now.

[AI agents in the API reference](https://vocatech.com/developers/reference/ai-agents)

---

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

# Support

| Method | Path | What it does |
| --- | --- | --- |
| POST | `/v1/support/tickets` | Open a ticket, or add to one by `reference` |

| Field | Meaning |
| --- | --- |
| `message` | Required. Up to 4,000 characters. |
| `subject` | Optional. One line, up to 120 characters. |
| `contact_name`, `contact_phone`, `contact_email` | Who to get back to. A nested `contact` object with `name`, `phone` and `email` works too. |
| `contact_company`, `contact_company_phone` | The business the person is calling about and its main number, as they gave them (`company` and `company_phone` in the `contact` object). Support finds the account on the business number first. A business number that is not a 10-digit US number is left out. |
| `reply_by` | `call`, `text` or `email` |
| `caller_id` | The number the person called from, when it is not `contact_phone`. Anything that is not a 10-digit US number (an extension, a withheld number) is left out and the ticket still opens. |
| `reference` | Your id: letters, digits, `.` `_` `:` and `-`, up to 80. The same one within 7 days adds to its ticket. |
| `source` | The system that sent it, up to 80 characters |

`201` opened, `200` added to an open ticket, `409` busy (wait for `Retry-After: 60`), `422` a field is wrong, `502` or `503` the desk cannot take it right now.

[Support in the API reference](https://vocatech.com/developers/reference/support)

---

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

# Knowledge

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/knowledge/answer?q=` | One question about Vocatech's service, answered |
| POST | `/v1/knowledge/answer` | The same, with `{"q": "..."}` or `{"question": "..."}` |

The answer comes from Vocatech's published facts only, in one to three short sentences, the way a person at our front desk would say it. Nothing about your account is in it. When the knowledge base does not cover the question, `found` is `false` and nothing is guessed.

**curl**

```bash
curl -G https://api.vocatech.com/v1/knowledge/answer \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  --data-urlencode "q=Is there a contract?"
```

**Node**

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

const q = new URLSearchParams({ q: 'Is there a contract?' });
const a = await vt('GET', `/v1/knowledge/answer?${q}`);
console.log(a.found ? a.answer : a.note);
```

**Python**

```python
from vocatech import vt

a = vt("GET", "/v1/knowledge/answer", params={"q": "Is there a contract?"})
print(a["answer"] if a["found"] else a["note"])
```

**PHP**

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

$a = vt('GET', '/v1/knowledge/answer?' . http_build_query(['q' => 'Is there a contract?']));
echo $a['found'] ? $a['answer'] : $a['note'], PHP_EOL;
```

**Response200 OKCopy**

```json
{
  "question": "Is there a contract?",
  "found": true,
  "answer": "No. Service is month-to-month, and there is no contract to sign.",
  "section": "Contracts and billing",
  "note": "Answer the caller from this in your own words; state only what it says."
}
```

`q` is 3 to 500 characters. `source`, optional, names the system that asks, for our log. Each key may ask 30 questions a minute and 2,000 a day.

[Knowledge in the API reference](https://vocatech.com/developers/reference/knowledge)

---

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

# Webhooks in depth

Webhooks push events to your server as they happen: calls, transcripts, texts and faxes. Each delivery is a signed `POST` with a JSON body.

## Endpoints and filters

| Method | Path | What it does |
| --- | --- | --- |
| GET | `/v1/webhooks` | List your endpoints |
| POST | `/v1/webhooks` | Create one. The answer holds `secret_key`, this once. |
| GET | `/v1/webhooks/{id}` | Read one |
| PATCH | `/v1/webhooks/{id}` | Change its name, URL, events, filters or `enabled` |
| DELETE | `/v1/webhooks/{id}` | Delete it. Answers `204`. |
| POST | `/v1/webhooks/{id}/test` | Send a signed `webhook.test` event and report what your server answered |
| GET | `/v1/webhooks/failures` | Deliveries that failed, newest first |

| Field | Meaning |
| --- | --- |
| `url` | Required. `https` on a public internet address. See the rules below. |
| `event_filters` | Required. At least one of the [events](#webhook-events). On create, `events` also works. |
| `name` | Optional. The URL's host by default. |
| `description` | Optional |
| `number_filters` | Optional. Only events that involve these numbers, 10 digits each, up to 200. Empty means every number. |
| `extension_filters` | Optional. Call events only: only these extensions. |
| `enabled` | On `PATCH`: `false` stops new events, `true` starts them again. |

The URL rules, checked when you save the URL (`422` when it fails) and again at every delivery:

- It uses `https` and carries no user name or password.
- Its host resolves only to public internet addresses. Private, loopback, link-local and internal names are refused.
- We never follow a redirect. A `3xx` answer counts as a failed delivery, so point the webhook at the final URL.

A main key sees every endpoint on the account. An API Messaging key sees and changes only the endpoints it created, and may subscribe them to the three message events only.

**Number filters and call events.** On `call.started`, `call.answered` and `call.ended`, `group_number` is one main number of your account, not always the number that was dialed. A number filter matches those events on the caller's number or that main number, so leave `number_filters` empty on an endpoint that needs every call.

**curl**

```bash
# Change what an endpoint receives
curl -X PATCH https://api.vocatech.com/v1/webhooks/42 \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "event_filters": ["call.ended", "call.transcription"] }'

# Pause it: new events stop
curl -X PATCH https://api.vocatech.com/v1/webhooks/42 \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": false }'

# Deliveries that failed, newest first
curl "https://api.vocatech.com/v1/webhooks/failures?limit=20" \
  -H "Authorization: Bearer $VOCATECH_API_KEY"

# Delete it: its pending retries stop too
curl -X DELETE https://api.vocatech.com/v1/webhooks/42 \
  -H "Authorization: Bearer $VOCATECH_API_KEY"
```

**Node**

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

// Change what an endpoint receives
await vt('PATCH', '/v1/webhooks/42', { event_filters: ['call.ended', 'call.transcription'] });

// Pause it: new events stop
await vt('PATCH', '/v1/webhooks/42', { enabled: false });

// Deliveries that failed, newest first
const { failures, meta } = await vt('GET', '/v1/webhooks/failures?limit=20');
console.log(meta.total, failures.map((f) => [f.event_type, f.attempts, f.error_message]));

// Delete it: its pending retries stop too
await vt('DELETE', '/v1/webhooks/42');
```

**Python**

```python
from vocatech import vt

# Change what an endpoint receives
vt("PATCH", "/v1/webhooks/42", {"event_filters": ["call.ended", "call.transcription"]})

# Pause it: new events stop
vt("PATCH", "/v1/webhooks/42", {"enabled": False})

# Deliveries that failed, newest first
failed = vt("GET", "/v1/webhooks/failures", params={"limit": 20})
for f in failed["failures"]:
    print(f["event_type"], f["attempts"], f["error_message"])

# Delete it: its pending retries stop too
vt("DELETE", "/v1/webhooks/42")
```

**PHP**

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

// Change what an endpoint receives
vt('PATCH', '/v1/webhooks/42', ['event_filters' => ['call.ended', 'call.transcription']]);

// Pause it: new events stop
vt('PATCH', '/v1/webhooks/42', ['enabled' => false]);

// Deliveries that failed, newest first
$failed = vt('GET', '/v1/webhooks/failures?limit=20');
foreach ($failed['failures'] as $f) {
    echo $f['event_type'], ' ', $f['attempts'], ' ', $f['error_message'], PHP_EOL;
}

// Delete it: its pending retries stop too
vt('DELETE', '/v1/webhooks/42');
```

## Events

| Event | Fires when | Arrives |
| --- | --- | --- |
| `call.started` | A call reaches one of your lines (a person, a group or a menu), or one of your lines places a call. | Seconds after |
| `call.answered` | That leg is answered. | Seconds after |
| `call.ended` | That leg ends. | Seconds after |
| `call.transcription` | A recorded leg is transcribed and summarized: one event per recording, so a call parked and picked up again sends one per part. | Minutes after the call. Up to about 6 hours when transcription runs behind. |
| `message.received` | A text or WhatsApp message comes in. | Seconds, at most about a minute |
| `message.sent` | A text goes out through the API or an automation: a send on a number in `api` or `email` mode, a picture message through the API, or a mass or group text. A text sent from Webex, an email reply or a WhatsApp reply does not fire it; those are in `GET /v1/messages`. | Seconds to about a minute |
| `message.status_updated` | A carrier or WhatsApp reports a new status, such as `delivered`, `failed` or `read`. | Seconds after the report |
| `fax.sent` | An outgoing fax is logged while it is still sending. | About a minute |
| `fax.received` | An incoming fax is logged while it is still arriving. | About a minute |
| `fax.delivered` | A fax is logged already complete. | About a minute |
| `fax.failed` | A fax is logged already failed or restricted. | About a minute |

- **Calls come in legs.** Each leg sends its own events, so a call that rings a group can send several `call.started`. Only recorded legs send `call.transcription`.
- **A fax sends one event**, from its status the first time we see it. A fax that finishes later does not send a second event. For an outgoing fax's final status, read `GET /v1/faxes`. An incoming fax arrives as `fax.received`, or as `fax.delivered` when it was already complete, so subscribe to both.
- **Timing** is how it runs today, not a promise. Build for an event that arrives late, twice, or out of order.

## Payloads

Every event has the same envelope:

| Field | Meaning |
| --- | --- |
| `id` | This event's id on this endpoint. A retry repeats it. |
| `event_type` | One of the events above, or `webhook.test` |
| `timestamp` | When the event was built, ISO 8601 with an offset |
| `company_id` | Your account |
| `data` | The event itself |

**call.endedCopy**

```json
{
  "id": "evt_6ab52ce04f1a23.81920744",
  "event_type": "call.ended",
  "timestamp": "2026-09-24T13:06:52+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "3c9e7a14-2b6d-4f80-9a1e-5d7c2b8f4e60",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T13:02:24.000Z",
    "end_time": "2026-09-24T13:06:49.000Z",
    "duration": 262
  }
}
```

`call.started` and `call.answered` carry the same fields, with `end_time` and `duration` set to `null`. On `call.ended`, `duration` counts the seconds from answer to hang-up, and is `null` when the leg was never answered. `direction` is the leg's: `incoming` when it received the call, `outgoing` when it placed it.

**call.transcriptionCopy**

```json
{
  "id": "evt_6ab52d4a9c0e17.40211586",
  "event_type": "call.transcription",
  "timestamp": "2026-09-24T13:11:30+00:00",
  "company_id": 1234567890,
  "data": {
    "call_id": "5e2b9d71-0c4a-4f3e-8b6a-1d9c7e2f4a08",
    "direction": "incoming",
    "extension": "104",
    "extension_name": "Maya Chen",
    "remote_name": "Jordan Reyes",
    "remote_number": "7185550142",
    "group_number": "7185550100",
    "start_time": "2026-09-24T13:02:11.000Z",
    "end_time": "2026-09-24T13:06:49.000Z",
    "duration": 278,
    "summary": "Jordan asked to move the Thursday visit to Friday morning. Maya booked Friday at 9 AM.",
    "transcription": "Maya: Good morning, how can I help?\nJordan: Hi, I need to move my Thursday visit.\nMaya: Sure. Friday at 9 AM works.",
    "recording_id": 10592210,
    "part": 1,
    "parts": 1,
    "recording_start_time": "2026-09-24T13:02:25.000Z",
    "recording_duration": 264
  }
}
```

`call.transcription` adds `summary` and `transcription` (speaker by speaker). Its `direction` and `duration` are the whole call's, and its extension is the recorded leg's.

It comes once per recording. A call parked and picked up again is recorded in parts and sends one event per part, and these fields tell them apart: `recording_id`, `part` of `parts`, `recording_start_time` and `recording_duration`. Part N 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 linked to the same leg of the report.

**message.receivedCopy**

```json
{
  "id": "evt_6ab52e11d07b45.12098876",
  "event_type": "message.received",
  "timestamp": "2026-09-24T13:20:05+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "sms_4821937",
    "direction": "incoming",
    "channel": "text",
    "from": "+17185550142",
    "to": "+17185550100",
    "body": "Friday at 9 works. Photo of the side entrance attached.",
    "status": "delivered",
    "error": null,
    "sent_at": "2026-09-24T13:20:03.000Z",
    "attachments": [
      {
        "url": "https://api.vocatech.com/v1/media/att_902114",
        "content_type": "image/jpeg",
        "filename": "side-entrance.jpg",
        "size": 184220
      }
    ]
  }
}
```

**message.status_updatedCopy**

```json
{
  "id": "evt_6ab52e3f8a1c02.55310947",
  "event_type": "message.status_updated",
  "timestamp": "2026-09-24T13:15:09+00:00",
  "company_id": 1234567890,
  "data": {
    "message_id": "sms_4821930",
    "direction": "outgoing",
    "channel": "text",
    "from": "+17185550100",
    "to": "+17185550142",
    "body": "",
    "status": "delivered",
    "error": null,
    "sent_at": "2026-09-24T13:15:02.000Z",
    "attachments": []
  }
}
```

Message events write numbers as `+1` and ten digits. `channel` is `text` or `whatsapp`. `error` says why a send failed and is otherwise `null`. Each attachment's `url` is a [Media](https://vocatech.com/developers/guide/media) address: call it with your key for a download link. A status update may not repeat the text.

**fax.receivedCopy**

```json
{
  "id": "evt_6ab52f02c4d9e8.90417736",
  "event_type": "fax.received",
  "timestamp": "2026-09-24T13:31:40+00:00",
  "company_id": 1234567890,
  "data": {
    "fax_id": "fax_90398",
    "direction": "incoming",
    "from": "+17185550144",
    "to": "+17185550101",
    "pages": 3,
    "subject": null,
    "status": "in_progress",
    "sent_at": "2026-09-24T13:30:52.000Z"
  }
}
```

Fax events write numbers with `+1` too. `status` is `in_progress`, `completed`, `failed` or `restricted`. Fetch the document with `GET /v1/media/{fax_id}`.

**webhook.testCopy**

```json
{
  "id": "test_6ab53001a2b3c4.11223344",
  "event_type": "webhook.test",
  "timestamp": "2026-09-24T13:40:00+00:00",
  "company_id": 1234567890,
  "data": {
    "message": "This is a test webhook delivery from Vocatech."
  }
}
```

**Two ids for one call.** On call events, `call_id` is the id the phone system gave that call or leg. It is not guaranteed to equal the `call_id` in `GET /v1/calls`. To join the two, match `remote_number` and the start time.

## Headers and signature

**Request headersCopy**

```http
POST /webhooks/vocatech HTTP/1.1
Content-Type: application/json
User-Agent: Vocatech-Webhook/1.0
X-Vocatech-Event: call.ended
X-Vocatech-Signature: t=1790258812,v1=3f6a1c9e0b7d4e2a8c5f1b3d9e7a2c4f6b8d0e1a3c5f7b9d2e4a6c8f0b1d3e5a
```

The signature proves the event came from us and was not changed on the way:

**How v1 is madeCopy**

```text
signed = "t=" + t + "." + raw_body
v1     = hex( HMAC-SHA256( key = secret_key, message = signed ) )
```

- `t` is the Unix time the delivery was signed. Reject a delivery more than 300 seconds away from your clock.
- The key is your webhook's `secret_key`, used as the text you were given. Do not decode it.
- The message is `t=`, the time, a period, then the raw body exactly as it arrived. Parsing and re-encoding the JSON changes the bytes and breaks the match.
- Compare in constant time.
- Each retry is signed again, with a new `t`. The body, and its `id`, stay the same.

## Verify the signature

**Node**

```javascript
// verify.mjs
import crypto from 'node:crypto';

// header:  the X-Vocatech-Signature value
// rawBody: the request body as a Buffer, exactly as it arrived
// secret:  the webhook's secret_key, as the text you were given
export function verifyVocatech(header, rawBody, secret, toleranceSec = 300) {
  const parts = {};
  for (const piece of String(header || '').split(',')) {
    const at = piece.indexOf('=');
    if (at > 0) parts[piece.slice(0, at).trim()] = piece.slice(at + 1).trim();
  }
  if (!/^\d+$/.test(parts.t || '') || !parts.v1) return false;
  if (Math.abs(Date.now() / 1000 - Number(parts.t)) > toleranceSec) return false;

  const expected = crypto
    .createHmac('sha256', secret)
    .update(`t=${parts.t}.`)
    .update(rawBody)
    .digest('hex');

  const a = Buffer.from(expected, 'utf8');
  const b = Buffer.from(parts.v1, 'utf8');
  // timingSafeEqual throws when the lengths differ, so check them first.
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

**Python**

```python
# verify.py
import hashlib
import hmac
import time


def verify_vocatech(header, raw_body, secret, tolerance=300):
    """header: X-Vocatech-Signature. raw_body: bytes as received. secret: str."""
    parts = {}
    for piece in (header or "").split(","):
        key, sep, value = piece.strip().partition("=")
        if sep:
            parts[key] = value
    t, v1 = parts.get("t", ""), parts.get("v1", "")
    if not t.isdigit() or not v1:
        return False
    if abs(time.time() - int(t)) > tolerance:
        return False

    signed = b"t=" + t.encode() + b"." + raw_body  # bytes, never a decoded str
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())
```

**PHP**

```php
<?php
// verify.php
// $header: X-Vocatech-Signature. $raw: the body as received. $secret: secret_key.
function vt_verify(string $header, string $raw, string $secret, int $tolerance = 300): bool
{
    $parts = [];
    foreach (explode(',', $header) as $piece) {
        [$key, $value] = array_pad(explode('=', trim($piece), 2), 2, '');
        $parts[$key] = $value;
    }
    $t = $parts['t'] ?? '';
    $v1 = $parts['v1'] ?? '';
    if (!ctype_digit($t) || $v1 === '' || abs(time() - (int) $t) > $tolerance) {
        return false;
    }

    $expected = hash_hmac('sha256', "t={$t}.{$raw}", $secret);
    return hash_equals($expected, $v1); // constant time
}
```

In Node, `crypto.timingSafeEqual` throws when the two lengths differ, so compare the lengths first. In Python, build the signed message from bytes, never from a decoded string. In PHP, compare with `hash_equals`.

## Capture the raw body

Web frameworks parse JSON before your code sees it, and a parsed body no longer matches the signature. Take the raw bytes, verify, answer, then work:

**Node**

```javascript
// Express: npm install express
import express from 'express';
import { verifyVocatech } from './verify.mjs';

const app = express();
const SECRET = process.env.VOCATECH_WEBHOOK_SECRET;
const seen = new Set(); // use your database in production

// express.raw keeps the exact bytes. express.json() would parse them,
// and a re-encoded body no longer matches the signature.
app.post('/webhooks/vocatech', express.raw({ type: 'application/json' }), (req, res) => {
  const signature = req.get('X-Vocatech-Signature');
  if (!Buffer.isBuffer(req.body) || !verifyVocatech(signature, req.body, SECRET)) {
    return res.sendStatus(401);
  }
  res.sendStatus(200); // answer within 5 seconds, then work

  const event = JSON.parse(req.body.toString('utf8'));
  if (seen.has(event.id)) return; // a retry of one you already have
  seen.add(event.id);
  setImmediate(() => handle(event));
});

function handle(event) {
  console.log(event.event_type, event.data);
}

app.listen(3000);
```

**Python**

```python
# Flask: pip install flask
import json
import os
import threading

from flask import Flask, abort, request
from verify import verify_vocatech

app = Flask(__name__)
SECRET = os.environ["VOCATECH_WEBHOOK_SECRET"]
seen = set()  # use your database in production


@app.post("/webhooks/vocatech")
def vocatech_webhook():
    raw = request.get_data()  # the exact bytes, before any parsing
    if not verify_vocatech(request.headers.get("X-Vocatech-Signature"), raw, SECRET):
        abort(401)

    event = json.loads(raw)
    if event["id"] not in seen:  # skip a retry of one you already have
        seen.add(event["id"])
        threading.Thread(target=handle, args=(event,)).start()
    return "", 200  # answer within 5 seconds, then work


def handle(event):
    print(event["event_type"], event["data"])
```

**PHP**

```php
<?php
// Laravel, routes/api.php (API routes skip the CSRF check)
use App\Jobs\HandleVocatechEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Route;

Route::post('/webhooks/vocatech', function (Request $request) {
    $raw = $request->getContent(); // the exact bytes, not $request->all()
    $ok = vt_verify(
        $request->header('X-Vocatech-Signature', ''),
        $raw,
        config('services.vocatech.webhook_secret')
    );
    if (!$ok) {
        abort(401);
    }

    $event = json_decode($raw, true);
    // Cache::add is false when the id is already there: a retry you already have.
    if (Cache::add('vocatech-event:' . $event['id'], true, now()->addDays(8))) {
        HandleVocatechEvent::dispatch($event); // your queued job does the work
    }
    return response()->noContent(); // 204, well within 5 seconds
});
```

In plain PHP, `file_get_contents('php://input')` returns the raw body.

## Retries and failures

Answer with any `2xx` within 5 seconds. Anything else fails the delivery: another status, a `3xx` redirect, a timeout or a connection error. A failed delivery is retried up to four times, five attempts in all:

| Attempt | When |
| --- | --- |
| 1 | As the event happens |
| 2 | Within about 5 minutes of the failure |
| 3 | At least 5 minutes after attempt 2 |
| 4 | At least 30 minutes after attempt 3 |
| 5 | At least 2 hours after attempt 4, about 2 hours 40 minutes after the first failure. Then it stops. |

- Retries go to the URL the event was first sent to. Turning an endpoint off stops new events, not retries already under way. Deleting it stops those too.
- `GET /v1/webhooks/failures` lists deliveries that are failing or gave up, newest first, with the full payload, the last error and the number of attempts. A delivery that later succeeds leaves the list. One that gives up stays for 7 days after its last attempt. `limit` is 1 to 100, 20 by default.
- There is no replay route. To recover a missed event, take its payload from the failures list, or read the same data from `GET /v1/calls`, `GET /v1/messages` and `GET /v1/faxes`.
- Deliveries can repeat, so dedupe as in [Receive webhooks](https://vocatech.com/developers/guide/receive-webhooks).

**GET /v1/webhooks/failures response200 OKTrimmedCopy**

```json
{
  "failures": [
    {
      "id": 7781,
      "endpoint_id": 42,
      "endpoint_url": "https://YOUR_DOMAIN/webhooks/vocatech",
      "event_type": "call.ended",
      "payload": {
        "id": "evt_6ab52ce04f1a23.81920744",
        "event_type": "call.ended",
        "timestamp": "2026-09-24T13:06:52+00:00",
        "company_id": 1234567890,
        "data": { "call_id": "3c9e7a14-2b6d-4f80-9a1e-5d7c2b8f4e60" }
      },
      "error_message": "HTTP 500",
      "attempts": 3,
      "retried_at": "2026-09-24 13:17:04",
      "created_at": "2026-09-24 13:06:53"
    }
  ],
  "meta": { "page": 1, "limit": 20, "total": 1, "total_pages": 1 }
}
```

## Test tool

Two ways to watch real deliveries before your own code is done:

- **The hosted test tool** at api.vocatech.com/test. Enter your key and your company id (it is in `GET /v1/whoami`), pick call events, then watch them arrive on the page or send one test event to your own URL. A session lasts 30 minutes, and events can take up to a minute to show on the page. Place a call to one of your numbers to see one.
- **`POST /v1/webhooks/{id}/test`** sends a signed `webhook.test` event to a saved endpoint and answers with the status your server returned. It waits up to 10 seconds.

While a hosted session runs, a temporary endpoint named Test Session appears in your webhook list. It is removed after the session ends.

---

On the site at https://vocatech.com/developers/guide/rate-limits

# Rate limits

Limits are counted per key, in fixed windows in UTC: each minute, each hour, and each day from 00:00 UTC. Each kind of request has its own counter, so polling reports never uses up your room to send a text.

| Counter | Counts | Per minute | Per hour | Per day |
| --- | --- | --- | --- | --- |
| `default` | Every request 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 | None | 200 |
| `faxes` | `POST /v1/faxes`, per account | 3 | None | 50 faxes sent (Eastern day) |
| `dial` | `POST /v1/agents/{id}/calls` | 10 | None | 500 |
| `tickets` | `POST /v1/support/tickets` | 10 | None | 200 |
| `knowledge` | `GET` or `POST /v1/knowledge/answer` | 30 | None | 2,000 |

- Every request counts, including requests that were refused.
- A request without a live key (no key, a wrong key, a key turned off) counts against its source address instead: 60 a minute and 600 an hour.
- API Messaging keys are not held to the 20 and 200 on sends. Their queue paces them (see [The API Messaging queue](https://vocatech.com/developers/guide/messages#ref-queue)), under a backstop of 100,000 send requests a day.
- Texts sent with the main key also count toward the account's included texting limit, 50 an hour and 150 a day as standard (100 and 300 when Vocatech raises it), shared with the texts the account's people send. Past it the send is answered `429` with the hour or the day named. API Messaging keys are paid per message and are outside that limit.
- Faxes are counted per account, not per key. The 50 a day counts faxes actually sent: a dry run or a refused request does not use one.
- Faxes also stop at the account's monthly allowance of outbound pages, 1,000 for each fax line on the account unless Vocatech set another figure, pooled across the lines.

Over a limit, the API answers `429` with a `Retry-After` header: the seconds until the window resets. The body names the window, the limit and the counter:

**Response429 Too Many RequestsCopy**

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

When an API Messaging key's day allowance refuses a send, the body says so in words:

**Response429 Too Many RequestsCopy**

```json
{
  "error": "Rate limit exceeded",
  "message": "This key is limited to 5000 messages per day. 4990 have been sent in the last 24 hours and 10 are waiting in the queue, so this send does not fit today.",
  "period": "day",
  "limit": 5000,
  "used": 4990,
  "queued": 10,
  "retry_after": 36120
}
```

## Back off and retry

Wait for `Retry-After` when it is there. Without it, back off exponentially with a little jitter. Retry `429` and `503`. Never retry a text send that answered `504` without checking `GET /v1/messages` first, and stop rather than sleep for hours on a day limit.

**curl**

```bash
# curl retries 408, 429, 500, 502, 503 and 504 by itself and honors
# Retry-After (curl 7.66 or newer). Use it on reads, never on text sends.
curl --retry 5 --retry-max-time 120 \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  https://api.vocatech.com/v1/calls
```

**Node**

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

const sleep = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));

export async function withRetry(call, tries = 5) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await call();
    } catch (err) {
      const retryable = err.status === 429 || err.status === 503;
      if (!retryable || attempt >= tries) throw err;
      const wait = err.retryAfter ?? Math.min(60, 2 ** attempt) + Math.random();
      if (wait > 120) throw err; // a day window: stop, do not sleep for hours
      await sleep(wait);
    }
  }
}

const calls = await withRetry(() => vt('GET', '/v1/calls'));
```

**Python**

```python
import random
import time

from vocatech import VocatechError, vt


def with_retry(call, tries=5):
    for attempt in range(1, tries + 1):
        try:
            return call()
        except VocatechError as err:
            if err.status not in (429, 503) or attempt == tries:
                raise
            wait = err.retry_after or min(60, 2 ** attempt) + random.random()
            if wait > 120:  # a day window: stop, do not sleep for hours
                raise
            time.sleep(wait)


calls = with_retry(lambda: vt("GET", "/v1/calls"))
```

**PHP**

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

function vt_with_retry(callable $call, int $tries = 5): mixed
{
    for ($attempt = 1; ; $attempt++) {
        try {
            return $call();
        } catch (VocatechError $e) {
            if (!in_array($e->status, [429, 503], true) || $attempt >= $tries) {
                throw $e;
            }
            $wait = $e->retryAfter ?? min(60, 2 ** $attempt) + mt_rand(0, 1000) / 1000;
            if ($wait > 120) {
                throw $e; // a day window: stop, do not sleep for hours
            }
            usleep((int) ($wait * 1000000));
        }
    }
}

$calls = vt_with_retry(fn () => vt('GET', '/v1/calls'));
```

To stay well under the limits, use webhooks instead of polling, and ask for 500 rows a page.

---

On the site at https://vocatech.com/developers/guide/errors

# Errors

Every error has a status code and a JSON body. Read the status first: it tells you what to do. The body comes in one of four shapes. The `vt()` helpers in the [Quickstart](https://vocatech.com/developers/guide/quickstart) read all four.

### From an endpoint

Most errors. The message says what to fix.

**Endpoint error400 Bad RequestCopy**

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

### From the router

An unknown path, a method the path does not take, some not-found answers, and faults we did not expect.

**Router error404 Not FoundCopy**

```json
{
  "statusCode": 404,
  "error": {
    "type": "RESOURCE_NOT_FOUND",
    "description": "Webhook not found."
  }
}
```

### From the key check

`401`, `403` for a scope or an IP allow-list, and `503` when a key cannot be checked. Examples are in [Authentication and keys](https://vocatech.com/developers/guide/keys).

**Key check error503 Service UnavailableCopy**

```json
{
  "error": "Service Unavailable",
  "message": "The API could not check this key just now. Retry in a few seconds."
}
```

### From a rate limit

`429`, with `retry_after` in the body and a `Retry-After` header. See [Rate limits](https://vocatech.com/developers/guide/rate-limits).

## Status codes

| Status | When | What to do |
| --- | --- | --- |
| `400` | A value is wrong or missing: a date, a number, a file, a window over 366 days, an end date before the start. | Fix the request. The message names the field. |
| `401` | No key, a wrong key, or a key that was turned off or regenerated. | Check the key. Retrying will not help. |
| `403` | The key's scope or IP allow-list refuses the request, or the action is not allowed: `from` is not your number, the recipient opted out, faxing is off. | Read the message. Do not retry as is. |
| `404` | Not found, not yours, or an unknown path. | Check the id and the path. |
| `405` | The path does not take that method. | Use the method in the reference. |
| `409` | A queued message is already sending or done, or a support ticket is busy (with `Retry-After: 60`). | Treat it as done, or wait and send again. |
| `410` | A message attachment is no longer available. | There is nothing to fetch. |
| `413` | A fax document over 20 MB. | Send a smaller file. |
| `422` | Validation: a webhook URL that is not public `https`, a missing event or message, WhatsApp sending, an AI agent call that cannot be placed. | Fix the request. The message says what. |
| `429` | A rate limit, a day allowance, or the monthly fax page limit. | Wait `Retry-After` seconds. |
| `500` | Something failed on our side, or the fax service refused a fax. | Retry later with backoff. If it repeats, tell us the time and the path. |
| `502` | A service behind the API answered and refused, or did not answer: the messaging service did not send, the support desk could not take it, or the agent platform did not answer and the call may have been placed. | Read the message. For an AI agent call, check for the placed-call tag before you retry, or retry with the same `Idempotency-Key`. Otherwise retry in a minute if it says so. |
| `503` | The key could not be checked (`Retry-After: 5`), a text could not reach the messaging service and was not sent, a desk is not available, or storage had trouble on our side. | Safe to retry after a pause. |
| `504` | A text send was not confirmed. It may have gone out. | Check `GET /v1/messages` before retrying. |

---

On the site at https://vocatech.com/developers/guide/dates

# Pagination and dates

List routes take `page`, from 1, and `limit`, and answer with `meta`:

**meta in GET /v1/callsCopy**

```json
{
  "meta": {
    "page": 2,
    "limit": 500,
    "total_pages": 4,
    "total_calls": 1873
  }
}
```

| Route | `limit` | Total in `meta` |
| --- | --- | --- |
| `GET /v1/calls` | 1 to 500, default 500 | `total_calls` |
| `GET /v1/messages` | 1 to 500, default 500 | `total_messages` |
| `GET /v1/faxes` | 1 to 500, default 500 | `total_faxes` |
| `GET /v1/contacts` | 1 to 500, default 100 | `total_contacts` |
| `GET /v1/webhooks/failures` | 1 to 100, default 20 | `total` |
| `GET /v1/callers/{number}` | 1 to 50 calls, default 10, no pages | `counts` |
| `GET /v1/messages/queue` | Up to 200 items, no pages | `queue.pending` |

## Dates and time zones

- `start_date` and `end_date` are `YYYY-MM-DD`, and you send both or neither. Neither, or only one, means today.
- They are read in `timezone`, an IANA name such as `America/Chicago`. The default is `America/New_York`. A name the API does not know falls back to New York, so check `query.timezone` in the answer.
- A request covers at most 366 days, and `end_date` may not come before `start_date`. Either answers `400`. For more history, walk a year at a time.
- The answer echoes what it used in `query`: the local dates and the time zone.
- Every time in an answer is UTC, except `created_at` in the send answers of `POST /v1/messages` and `POST /v1/faxes`, which carries New York time with its offset. Webhook endpoint, failure and queue listings write times as `2026-09-24 13:06:53`, also UTC.

## Stable order

Rows that tie on the sort key come back in a fixed order, so a page never repeats or skips a row while the data stands still. New calls land while you page, though. To walk a range cleanly, sort `day_asc`, or walk a window that has closed, such as yesterday.

**curl**

```bash
# Every page, oldest first, until page reaches meta.total_pages (uses jq)
page=1
while :; do
  body=$(curl -s -H "Authorization: Bearer $VOCATECH_API_KEY" \
    "https://api.vocatech.com/v1/messages?start_date=2026-09-01&end_date=2026-09-23&sort=day_asc&limit=500&page=$page")
  echo "$body" | jq -c '.messages[]'
  [ "$page" -ge "$(echo "$body" | jq '.meta.total_pages')" ] && break
  page=$((page + 1))
done
```

**Node**

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

// Walks every page of /v1/calls, /v1/messages or /v1/faxes, oldest first.
async function* walk(path, listKey, query = {}) {
  for (let page = 1; ; page++) {
    const qs = new URLSearchParams({ sort: 'day_asc', limit: '500', ...query, page: String(page) });
    const data = await vt('GET', `${path}?${qs}`);
    yield* data[listKey];
    if (page >= data.meta.total_pages) return;
  }
}

for await (const msg of walk('/v1/messages', 'messages', { start_date: '2026-09-01', end_date: '2026-09-23' })) {
  console.log(msg.sent_time, msg.direction, msg.remote_number);
}
```

**Python**

```python
from vocatech import vt


def walk(path, list_key, **query):
    """Every page of /v1/calls, /v1/messages or /v1/faxes, oldest first."""
    page = 1
    while True:
        data = vt("GET", path, params={"sort": "day_asc", "limit": 500, **query, "page": page})
        yield from data[list_key]
        if page >= data["meta"]["total_pages"]:
            return
        page += 1


for msg in walk("/v1/messages", "messages", start_date="2026-09-01", end_date="2026-09-23"):
    print(msg["sent_time"], msg["direction"], msg["remote_number"])
```

**PHP**

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

// Every page of /v1/calls, /v1/messages or /v1/faxes, oldest first.
function vt_walk(string $path, string $listKey, array $query = []): Generator
{
    for ($page = 1; ; $page++) {
        $qs = http_build_query(array_merge(['sort' => 'day_asc', 'limit' => 500], $query, ['page' => $page]));
        $data = vt('GET', $path . '?' . $qs);
        yield from $data[$listKey];
        if ($page >= $data['meta']['total_pages']) {
            return;
        }
    }
}

foreach (vt_walk('/v1/messages', 'messages', ['start_date' => '2026-09-01', 'end_date' => '2026-09-23']) as $msg) {
    echo $msg['sent_time'], ' ', $msg['direction'], ' ', $msg['remote_number'], PHP_EOL;
}
```

For a nightly sync, walk yesterday in full each night. For a first import, walk a year at a time.

---

On the site at https://vocatech.com/developers/guide/changelog

# Changelog

Changes that affect integrations, newest first.

1. 2026-10-25
  AI agent calls: a safe retry, one dial policy, a daily cap
    - `POST /v1/agents/{id}/calls` takes an `Idempotency-Key` header (up to 80 characters). A repeat within a day answers the same call instead of placing a second one; without it, the same agent, number and `reference` within two minutes is the same request.
  - A `502` on that route now says 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.
  - A `reference` may hold a colon (`ZD:12345`) all the way to the placed-call tag.
  - An agent places at most 50 calls a day (`422` past that). The numbers it never dials are one list for the API, the portal and the agent itself: service codes, premium numbers, and the area codes of other countries.
2. 2026-10-22
  API Messaging keys are outside the account's texting limit
    - A send from an API Messaging key is paid per message and no longer counts toward the account's included texting limit (50 texts an hour and 150 a day as standard), and that limit no longer stops it. Until today a batch stopped at the account's hourly limit with `Company has reached its hourly message limit`. The key's own pace and the carriers' daily room are what pace it now.
  - Texts sent with the main key count toward that limit as before, together with the texts the account's people send.
  - A send to a T-Mobile number (or one whose carrier is not known yet) after the account's 960 T-Mobile messages for the day joins the queue and waits for the next day, as a queued message always did, instead of going out to be rejected. Every number is looked up for its carrier before it is sent, and T-Mobile's day resets at midnight Pacific, as T-Mobile counts it.
3. 2026-10-21
  The monthly fax allowance is per line, pooled
    - An account's monthly allowance of outbound fax pages is 1,000 for each fax line on it, pooled across the lines, so an account with three lines may send 3,000 pages a month from any one of them. Until today the whole account shared one 1,000 whatever its line count. The `429` text names the allowance, the lines and the pages a line.
4. 2026-10-18
  Fax limits said the way they are counted
    - The monthly fax limit counts pages, the same way the portal and email to fax count it; the API counted whole faxes against it until today. The reference, this guide and the `429` text now say pages.
  - The fax counters have been 3 a minute and 50 sent a day per account since 2026-09-25; the 2026-09-24 entry below named that first day's 10 and 500.
  - `message.sent` fires for a text sent through the API or an automation, never for one sent from Webex, by email reply or on WhatsApp; the events table here said otherwise.
  - An AI agent call is refused outside the agent's own calling hours (its Persona tab), not a fixed Eastern window; the reference said 8 AM to 9 PM Eastern.
5. 2026-09-25
  Recordings in parts
    - A call that is parked and picked up again is recorded in parts. Each leg in `GET /v1/calls` lists its parts in the new `recordings` field, oldest first, each with `part`, `parts`, `recording_id`, `start_time`, `duration` and its own `url`.
  - `GET /v1/media/rec_<n>?part=N` fetches one part. Without `?part`, the answer is the latest part, and it now names it: `part`, `parts`, `recording_id`, `start_time`.
  - `recording_url`, `summary` and `transcription` always come from the same recording, the latest part. On a parked call the link used to play the first part while the words came from the second.
  - `call.transcription` events carry `recording_id`, `part`, `parts`, `recording_start_time` and `recording_duration`, so the events of a call recorded in parts can be told apart.
6. 2026-09-24
  Separate limits, clearer errors, safer sending
    - Support tickets take the business the person is calling about: `contact_company` and `contact_company_phone`. Support finds the account on that number first. A `caller_id` or business number that is not a 10-digit US number is left out instead of refusing the ticket.
  - Each kind of request has its own rate-limit counter per key: reports, texts, faxes, AI agent calls, support tickets, knowledge questions and everything else. They used to share one, so a key that polled reports could run out of room to send a text. The `429` body now names the counter.
  - New counters: faxes 10 a minute and 500 a day, AI agent calls 10 and 500, support tickets 10 and 200, knowledge questions 30 and 2,000.
  - A request without a live key is limited per source address: 60 a minute and 600 an hour.
  - When the API cannot check a key, it answers `503` with `Retry-After: 5`. It used to answer `401`.
  - Text sends that fail say what happened: `503` (not sent, safe to retry), `504` (not confirmed, check `GET /v1/messages` before retrying), the messaging service's own `4xx`, or `502`. They were all `500`.
  - WhatsApp sending through `POST /v1/messages` answers `422`, dry runs included. WhatsApp history stays in `GET /v1/messages`.
  - `GET /v1/calls`, `GET /v1/messages` and `GET /v1/faxes` cover at most 366 days a request (`400` otherwise). Messages and faxes echo the local dates you asked for, and rows that tie on the sort key page in a stable order.
  - Webhook URLs must be `https` on a public internet address (`422` otherwise). Deliveries never follow redirects: a `3xx` is a failed delivery.
  - `POST /v1/users` no longer creates or changes keys. It answers `403`. Keys are made in the portal.
  - Faxes: documents over 20 MB answer `413`, the account's monthly outbound limit is enforced (`429`), and premium and foreign numbers are refused.
  - AI agent calls never dial 911 or other service codes, 988, premium numbers or other countries' area codes.
  - `GET /v1/media` answers an attachment on a text you sent with the link you sent it with.
  - Removed: `GET /v1/settings`, which never worked, and the stage docs at `/st-docs`.
  - The [API reference](https://vocatech.com/developers/reference) is new, with a request console.
7. 2026-09-15
  Knowledge
  `GET` and `POST /v1/knowledge/answer`: a question about Vocatech's service, answered from our knowledge base. New `knowledge` scope. The `support` scope reaches it too.
8. 2026-09-11
  Callers and AI agents
  `GET /v1/callers/{number}`, `GET /v1/contacts/lookup`, `GET /v1/agents` and `POST /v1/agents/{id}/calls`. New `agents` scope.
9. 2026-09-08
  Support tickets
  `POST /v1/support/tickets` opens a ticket with Vocatech support. New `support` scope.
10. 2026-09-02
  Texting pace on main keys
  `POST /v1/messages` on a main key: up to 20 a minute and 200 a day. API Messaging keys carry more.
11. 2026-08-30
  Calls page by call
  A call's journey is never split across two pages of `GET /v1/calls`.
12. 2026-08-26
  Fax history fixed
  `GET /v1/faxes` no longer fails with `500`.
13. 2026-08-12
  Webhooks by number
  `number_filters` limits an endpoint to chosen numbers. An API Messaging key manages its own webhooks, for message events only.
14. 2026-08-07
  One API Messaging key per integrator
  Up to 10 API Messaging keys per account, each with its own label, pace and IP allow-list.
15. 2026-08-06
  Signed attachment links
  `GET /v1/media` answers message attachments with a signed link good for 30 minutes, like recordings and faxes.
16. 2026-08-03
  Parts counted the carriers' way
  `messages_counted` follows the carriers' rules for basic and extended characters.
17. 2026-07-30
  Real numbers only
  A text's `to` must be a valid 10-digit US or Canadian number.
18. 2026-07-28
  The queue
  API Messaging keys hand over a whole batch (`202`) and it goes out in order at the key's pace. `GET` and `DELETE /v1/messages/queue`. An MMS carries every attachment in one message.
19. 2026-07-27
  Scopes, API Messaging and MMS
  Keys can be narrowed to a scope. API Messaging keys, with their own pace. `media` on `POST /v1/messages`, and `messages_counted` in send answers.
20. 2026-07-21
  IP allow-list
  Lock a key to exact addresses. `GET /v1/whoami` shows the address the API sees.
21. 2026-07-16
  Cleaner events
  `call.transcription` carries the clean, speaker-labeled transcript. Message events carry `error`. `POST /v1/messages` returns `message_id`.
22. 2026-06-02
  Faxes
  `POST` and `GET /v1/faxes`, `fax_` media and the `fax.*` events.
23. 2026-03-25
  One media route
  `GET /v1/media/{id}` for message attachments and call recordings.
24. 2026-03-16
  Contacts
  List, create or update, and delete contacts through the API.
25. 2026-03-13
  Message events
  `message.sent`, `message.received` and `message.status_updated` webhooks.
26. 2026-03-11
  Flat paths and dry runs
  `/v1/calls`, `/v1/messages` and `/v1/webhooks`, and dry runs with `"test": true`.
