Vocatech

In depth

Webhooks in depth

Written beside the API’s code, and read from it every hour.

Plain text

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"

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 goes out through the API or an automation: a send on a number in api or email mode, a picture message through the API, or a mass or group text. A text sent from Webex, an email reply or a WhatsApp reply does not fire it; those are in GET /v1/messages.Seconds to about a minute
message.status_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);
}

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);

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.