API guide

Vocatech API

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 and the OpenAPI file

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

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

# 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.
// 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 || '';
}
# 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
// 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 https://api.vocatech.com/v1/whoami \
  -H "Authorization: Bearer $VOCATECH_API_KEY"
import { vt } from './vocatech.mjs';

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

me = vt("GET", "/v1/whoami")
print(me["company_id"], me["client_ip"], me["ip_restricted"])
<?php
require __DIR__ . '/vocatech.php';

$me = vt('GET', '/v1/whoami');
echo $me['company_id'], ' ', $me['client_ip'], PHP_EOL;
Response200 OK
{
  "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.

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.

# 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"
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);
}
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
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 OK
{
  "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, or have events pushed to you in Receive webhooks.

Authentication and keys

Every request carries a key in the Authorization header:

Header
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 Unauthorized
{
  "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.

PropertyMain keyAPI Messaging keys
Made inIntegrations page, Portal Public API, GenerateTextdock, API Messaging
How manyOne per accountUp to 10 per account: one for each integrator, each with its own label
ReachesEvery routeTexting only (the messaging scope)
Sending textsUp to 20 a minute and 200 a day through POST /v1/messagesA 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
IP allow-listOptionalRequired

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

ScopeReachesNotes
messaging/v1/messages (send, history, queue), /v1/reports/messages, /v1/media, /v1/webhooksMedia: message attachments only, never recordings or faxes. Webhooks: the three message events, on endpoints this key created.
agents/v1/agents, /v1/callers, /v1/contacts/lookupWhat an AI receptionist needs: who is calling, and your agents.
support/v1/support, /v1/knowledgeOpen tickets and ask the knowledge base.
knowledge/v1/knowledgeAsk the knowledge base, nothing else.

Outside its scope, a key gets 403:

Response403 Forbidden
{
  "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 Forbidden
{
  "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.

Recipes

Short, complete walk-throughs for the jobs integrations do most. The Node, Python and PHP examples use the vt() helper from the Quickstart. The names and numbers in them are samples.

Pull today's calls with transcripts and recordings

  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.
# 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"
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()));
      }
    }
  }
}
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
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.

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.

# 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."
  }'
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;
}
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
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 OK
{
  "status": "test",
  "message": {
    "mode": "api",
    "platform": "text",
    "from": "7185550100",
    "to": "7185550142",
    "name": "Vocatech Text",
    "email_recipient": null,
    "valid": true,
    "messages_counted": 1
  }
}
Send response201 Created
{
  "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

StatusMeansDo this
200A 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.
201Sent (status: "created").Check message_sent.
202Queued (status: "queued"). API Messaging keys only: it goes out in order at the key's pace.Track it with GET /v1/messages/queue.
400A field is wrong: a number, the length, an attachment link.Fix it. The message names it.
403Refused: 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.
422WhatsApp sending through the API is not available.Send WhatsApp from Webex or the portal.
429A limit was reached.Wait Retry-After seconds.
502The messaging service answered and did not send.Read the message. Retry later if it is temporary.
503The messaging service could not be reached. Nothing was sent.Safe to retry.
504The 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 -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"]
  }'
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);
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
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 Created
{
  "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 Entity
{
  "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."
  }
}

Receive webhooks

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

1Create 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 -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"]
  }'
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');
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
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 Created
{
  "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"
}

2Verify, 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 do all three.

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

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

curl -X POST https://api.vocatech.com/v1/webhooks/42/test \
  -H "Authorization: Bearer $VOCATECH_API_KEY"
import { vt } from './vocatech.mjs';

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

result = vt("POST", "/v1/webhooks/42/test")
print(result["success"], result["status_code"], result["error"])
<?php
require __DIR__ . '/vocatech.php';

$result = vt('POST', '/v1/webhooks/42/test');
echo $result['success'] ? 'delivered' : 'failed: ' . $result['error'], PHP_EOL;
Response200 OK
{
  "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.

Look up a caller

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

RouteAnswers withUse 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 wayAn AI agent or a screen that needs context before hello
GET /v1/contacts/lookup?phone=The contact records for the number, up to 10Matching a caller to a record in your CRM
# 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"
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));
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
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 OKTrimmed
{
  "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 OK
{
  "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.

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.
  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.
# 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
curl -X POST https://api.vocatech.com/v1/agents/7/calls \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to": "7185550142",
    "purpose": "confirm the Friday 9 AM visit",
    "person_name": "Jordan Reyes",
    "reference": "visit-20260925-0900"
  }'
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
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
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 OK
{
  "agents": [
    {
      "id": 7,
      "name": "Front desk",
      "extension": "850",
      "active": true,
      "outgoing_calls": true,
      "provisioned": true
    }
  ],
  "meta": { "total": 1 }
}
POST /v1/agents/7/calls response202 Accepted
{
  "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.

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 -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"
  }'
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');
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
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 Created
{
  "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.

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.
# 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] }'
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] });
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
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 OK
{
  "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.

Reference by area

The short version of each area. Every field, parameter and response is in the API reference. Answers can carry more fields than the examples here show.

Calls

MethodPathWhat it does
GET/v1/callsCalls with their journey, summaries, transcripts and recording links
GET/v1/reports/callsThe old name for the same list. It still answers: move to /v1/calls
ParameterMeaning
start_date, end_dateYYYY-MM-DD, both or neither. Neither means today. At most 366 days.
timezoneAn IANA name. The default is America/New_York.
page, limitPages from 1. limit is 1 to 500, 500 by default.
sortday_desc (default), day_asc, duration_desc or duration_asc
directionincoming, outgoing or voicemail
statusA leg status, such as answered or missed. missed never returns an outgoing call.
extensionCalls with a leg on this extension
typeCalls with a leg of this type, such as user, hunt_group, auto_attendant or call_center
searchPart 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

Messages

MethodPathWhat it does
POST/v1/messagesSend a text or an MMS
GET/v1/messagesText and WhatsApp history
GET/v1/messages/queueWhat an API Messaging key has waiting, and what is left of today
DELETE/v1/messages/queueCancel everything still waiting
DELETE/v1/messages/queue/{id}Cancel one waiting message
GET/v1/reports/messagesThe old name for GET /v1/messages. It still answers

Send: POST /v1/messages

FieldMeaning
platformtext. whatsapp answers 422.
fromA texting number on your account
toA 10-digit US or Canadian number
messageUp to 1,600 characters
mediaUp to 10 https links, for an MMS. media_url also works.
nameOptional. The contact's name, for a new conversation.
membersWebex 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.
testtrue 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

ParameterMeaning
start_date, end_date, timezone, page, limitAs for calls
sortday_desc (default) or day_asc
directionincoming or outgoing
channeltext or whatsapp
statusTexts always read delivered in this list, so other values return WhatsApp messages only
searchPart 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.

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.
  • Messages to T-Mobile numbers are also held to 960 a day per account (limits.tmobile_day_allowance). When those are spent, T-Mobile messages wait for the next day while the rest keep flowing.
  • 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.
# 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"
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');
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
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 Accepted
{
  "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 OK
{
  "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

Faxes

MethodPathWhat it does
POST/v1/faxesSend a fax
GET/v1/faxesFax history, sent and received
FieldMeaning
fromOne of your active fax lines
toA 10-digit US or Canadian number. Premium numbers (900, 976) and other countries' area codes are refused.
fileThe document, a base64 PDF of at most 25 pages. Up to 20 MB.
filenameOptional. document.pdf by default.
recipient_name, subjectOptional
testtrue for a dry run
# 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
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);
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
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 OK
{
  "status": "test",
  "fax": {
    "from": "7185550101",
    "to": "7185550143",
    "filename": "intake-form.pdf",
    "fax_line": "Front office fax",
    "valid": true
  }
}
Send response201 Created
{
  "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 outbound limit, 1,000 unless Vocatech set another. 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

Media

MethodPathWhat it does
GET/v1/media/{id}A signed download link for one attachment, recording or fax
Id starts withWhat it isWhere the id comes from
att_A message attachmentattachments in GET /v1/messages and in message webhooks
rec_A call recordingrecording_url, or a part's url in recordings, on a call leg
fax_A fax documentfax_id in GET /v1/faxes and in fax webhooks
A recording200 OK
{
  "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 OK
{
  "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

Callers

MethodPathWhat it does
GET/v1/callers/{number}Everything the account knows about one number
ParameterMeaning
daysHow far back, 1 to 365. 90 by default.
limitUp to how many calls, and messages, 1 to 50. 10 by default.
includeAny 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

Contacts

MethodPathWhat it does
GET/v1/contacts/fieldsYour contact fields, with is_match, is_phone and their order
GET/v1/contactsThe contacts, 100 a page by default and up to 500, with search across every value
POST/v1/contactsCreate or update one ({"fields": {...}}) or up to 500 ({"contacts": [...]})
DELETE/v1/contactsDelete 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.

Contacts in the API reference

AI agents

MethodPathWhat it does
GET/v1/agentsYour AI agents: id, name, extension, active, outgoing_calls, provisioned
POST/v1/agents/{id}/callsHave one agent place one call
FieldMeaning
toRequired. A 10-digit number, or an extension of 2 to 6 digits.
purposeWhy the agent is calling, in a few words, up to 200 characters. subject also works.
instructionsWhat to do on the call, up to 2,000 characters. Send purpose, instructions or both.
person_nameWho to ask for, up to 80 characters.
referenceYour id for the matter: letters, digits, . _ : and -, up to 80. It comes back with the outcome.
kindnotice (default), a short message with a question, or callback, the office returning their call.
jobThe name of one of the agent's jobs to start with, such as a reschedule job.
allow_quiet_hourstrue to call outside the agent's calling hours.

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, or outside its calling hours). 502: the agent platform could not be reached, so retry in a minute. 503: outgoing calls are not available right now.

AI agents in the API reference

Support

MethodPathWhat it does
POST/v1/support/ticketsOpen a ticket, or add to one by reference
FieldMeaning
messageRequired. Up to 4,000 characters.
subjectOptional. One line, up to 120 characters.
contact_name, contact_phone, contact_emailWho to get back to. A nested contact object with name, phone and email works too.
contact_company, contact_company_phoneThe 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_bycall, text or email
caller_idThe 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.
referenceYour id: letters, digits, . _ : and -, up to 80. The same one within 7 days adds to its ticket.
sourceThe 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

Knowledge

MethodPathWhat it does
GET/v1/knowledge/answer?q=One question about Vocatech's service, answered
POST/v1/knowledge/answerThe 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 -G https://api.vocatech.com/v1/knowledge/answer \
  -H "Authorization: Bearer $VOCATECH_API_KEY" \
  --data-urlencode "q=Is there a contract?"
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);
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
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 OK
{
  "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

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

MethodPathWhat it does
GET/v1/webhooksList your endpoints
POST/v1/webhooksCreate 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}/testSend a signed webhook.test event and report what your server answered
GET/v1/webhooks/failuresDeliveries that failed, newest first
FieldMeaning
urlRequired. https on a public internet address. See the rules below.
event_filtersRequired. At least one of the events. On create, events also works.
nameOptional. The URL's host by default.
descriptionOptional
number_filtersOptional. Only events that involve these numbers, 10 digits each, up to 200. Empty means every number.
extension_filtersOptional. Call events only: only these extensions.
enabledOn 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.

# 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"
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');
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
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

EventFires whenArrives
call.startedA call reaches one of your lines (a person, a group or a menu), or one of your lines places a call.Seconds after
call.answeredThat leg is answered.Seconds after
call.endedThat leg ends.Seconds after
call.transcriptionA 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.receivedA text or WhatsApp message comes in.Seconds, at most about a minute
message.sentA text or WhatsApp message goes out, from the API, Webex or the portal.Seconds to about a minute
message.status_updatedA carrier or WhatsApp reports a new status, such as delivered, failed or read.Seconds after the report
fax.sentAn outgoing fax is logged while it is still sending.About a minute
fax.receivedAn incoming fax is logged while it is still arriving.About a minute
fax.deliveredA fax is logged already complete.About a minute
fax.failedA 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:

FieldMeaning
idThis event's id on this endpoint. A retry repeats it.
event_typeOne of the events above, or webhook.test
timestampWhen the event was built, ISO 8601 with an offset
company_idYour account
dataThe event itself
call.ended
{
  "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.transcription
{
  "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.received
{
  "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_updated
{
  "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 address: call it with your key for a download link. A status update may not repeat the text.

fax.received
{
  "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.test
{
  "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 headers
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 made
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

// 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);
}
# 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
// 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:

// 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);
# 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
// 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:

AttemptWhen
1As the event happens
2Within about 5 minutes of the failure
3At least 5 minutes after attempt 2
4At least 30 minutes after attempt 3
5At 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.
GET /v1/webhooks/failures response200 OKTrimmed
{
  "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.

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.

CounterCountsPer minutePer hourPer day
defaultEvery request not listed below6003,600100,000
reportsGET /v1/calls, GET /v1/messages, /v1/reports/*3003,000100,000
messagesPOST /v1/messages on a main key20None200
faxesPOST /v1/faxes, per account3None50 faxes sent (Eastern day)
dialPOST /v1/agents/{id}/calls10None500
ticketsPOST /v1/support/tickets10None200
knowledgeGET or POST /v1/knowledge/answer30None2,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), under a backstop of 100,000 send requests a day.
  • 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 outbound limit, 1,000 unless Vocatech set another.

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 Requests
{
  "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 Requests
{
  "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 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
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'));
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
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.

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 read all four.

From an endpoint

Most errors. The message says what to fix.

Endpoint error400 Bad Request
{
  "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 Found
{
  "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.

Key check error503 Service Unavailable
{
  "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.

Status codes

StatusWhenWhat to do
400A 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.
401No key, a wrong key, or a key that was turned off or regenerated.Check the key. Retrying will not help.
403The 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.
404Not found, not yours, or an unknown path.Check the id and the path.
405The path does not take that method.Use the method in the reference.
409A 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.
410A message attachment is no longer available.There is nothing to fetch.
413A fax document over 20 MB.Send a smaller file.
422Validation: 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.
429A rate limit, a day allowance, or the monthly fax limit.Wait Retry-After seconds.
500Something 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.
502A service behind the API answered and refused: the messaging service did not send, or the support desk or the AI agent platform could not take it.Read the message. Retry in a minute if it says so.
503The 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.
504A text send was not confirmed. It may have gone out.Check GET /v1/messages before retrying.

Pagination and dates

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

meta in GET /v1/calls
{
  "meta": {
    "page": 2,
    "limit": 500,
    "total_pages": 4,
    "total_calls": 1873
  }
}
RoutelimitTotal in meta
GET /v1/calls1 to 500, default 500total_calls
GET /v1/messages1 to 500, default 500total_messages
GET /v1/faxes1 to 500, default 500total_faxes
GET /v1/contacts1 to 500, default 100total_contacts
GET /v1/webhooks/failures1 to 100, default 20total
GET /v1/callers/{number}1 to 50 calls, default 10, no pagescounts
GET /v1/messages/queueUp to 200 items, no pagesqueue.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.

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

Changelog

Changes that affect integrations, newest first.

  1. 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.
  2. 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 is new, with a request console.
  3. 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.

  4. Callers and AI agents

    GET /v1/callers/{number}, GET /v1/contacts/lookup, GET /v1/agents and POST /v1/agents/{id}/calls. New agents scope.

  5. Support tickets

    POST /v1/support/tickets opens a ticket with Vocatech support. New support scope.

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

  7. Calls page by call

    A call's journey is never split across two pages of GET /v1/calls.

  8. Fax history fixed

    GET /v1/faxes no longer fails with 500.

  9. Webhooks by number

    number_filters limits an endpoint to chosen numbers. An API Messaging key manages its own webhooks, for message events only.

  10. One API Messaging key per integrator

    Up to 10 API Messaging keys per account, each with its own label, pace and IP allow-list.

  11. Signed attachment links

    GET /v1/media answers message attachments with a signed link good for 30 minutes, like recordings and faxes.

  12. Parts counted the carriers' way

    messages_counted follows the carriers' rules for basic and extended characters.

  13. Real numbers only

    A text's to must be a valid 10-digit US or Canadian number.

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

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

  16. IP allow-list

    Lock a key to exact addresses. GET /v1/whoami shows the address the API sees.

  17. Cleaner events

    call.transcription carries the clean, speaker-labeled transcript. Message events carry error. POST /v1/messages returns message_id.

  18. Faxes

    POST and GET /v1/faxes, fax_ media and the fax.* events.

  19. One media route

    GET /v1/media/{id} for message attachments and call recordings.

  20. Contacts

    List, create or update, and delete contacts through the API.

  21. Message events

    message.sent, message.received and message.status_updated webhooks.

  22. Flat paths and dry runs

    /v1/calls, /v1/messages and /v1/webhooks, and dry runs with "test": true.

Support

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

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