Skip to content
VaakyoDocs
Navigation
Open console →

Webhooks

Webhooks

Vaakyo posts each event of every call to your server as it happens, signed with a secret only you and Vaakyo know, and retries deliveries that fail, so a slow or broken endpoint never affects a live call.

Where events go

TargetSet up withReceivesSigned with
Workspace endpointsPOST /api/v1/webhooksThe event types it subscribes to, for every call in the workspaceThe endpoint’s own secret
An agent’s webhook_urlThe agent’s Webhook & number tabThe lifecycle events of that agent’s callsThe workspace’s default secret
A campaign’s webhook_urlWebhook settings when you create a campaignThe lifecycle events of that campaign’s callsThe workspace’s default secret

All of them can be set. An event goes to every matching target.

Delivery runs in the background: Vaakyo saves each delivery and a worker posts it. Your endpoint’s speed never changes how the call sounds.

Register an endpoint

Managing endpoints needs the keys.manage permission, which API keys have.

curl -X POST https://api.vaakyo.com/api/v1/webhooks \
  -H "X-API-Key: $VAAKYO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://crm.example.com/vaakyo/events",
    "description": "CRM sync",
    "events": ["call.started", "call.ended", "call.completed"]
  }'
{
  "id": "we_5f0c2a7e9b1d4c3a8e6f0b2d4c6a8e0f",
  "url": "https://crm.example.com/vaakyo/events",
  "description": "CRM sync",
  "events": ["call.completed", "call.ended", "call.started"],
  "active": true,
  "secret_hint": "whsec_Abc1…9xYz",
  "created_at": "2026-10-01T15:02:11.481000+05:30",
  "created_by": "key:backend",
  "secret": "whsec_Abc1...full secret...9xYz"
}

secret is returned only here, once. Store it now; later you see only secret_hint.

FieldTypeDefaultRules
urlstringrequiredhttps, on the public internet: private, loopback, link-local and cloud-metadata addresses are refused when you save it and again on every delivery (a host that resolves to one fails without retries). Redirects are not followed. Up to 500 characters.
descriptionstring""Up to 200 characters.
eventsarray["*"]"*" for the call lifecycle (call.queued, call.placed, call.started, call.ended, call.completed), or a list of event types from Webhook events. Turn-by-turn events such as user.turn or tool.call are sent only when listed by name.
activebooleantrueAn inactive endpoint gets nothing.

A workspace can have up to 20 endpoints. GET /api/v1/webhooks/events lists the event types you can subscribe to.

Manage endpoints

# List
curl https://api.vaakyo.com/api/v1/webhooks -H "X-API-Key: $VAAKYO_API_KEY"

# Change some fields (only the ones you send)
curl -X PATCH https://api.vaakyo.com/api/v1/webhooks/$ENDPOINT_ID \
  -H "X-API-Key: $VAAKYO_API_KEY" -H "Content-Type: application/json" \
  -d '{"active": false}'

# Delete
curl -X DELETE https://api.vaakyo.com/api/v1/webhooks/$ENDPOINT_ID -H "X-API-Key: $VAAKYO_API_KEY"

# Send a test "ping" now
curl -X POST https://api.vaakyo.com/api/v1/webhooks/$ENDPOINT_ID/test -H "X-API-Key: $VAAKYO_API_KEY"

The test sends one ping event straight away (no retries) and returns the delivery, including your server’s response.

The request

Every delivery is a POST with a JSON body:

{
  "id": "evt_7d0c6f0f2b8e4b0a9c1e3f5a7b9d1e3f",
  "type": "tool.result",
  "created_at": "2026-10-01T09:15:04.512000+00:00",
  "workspace_id": "6df74233a1b04c2e9f8d7c6b5a4e3d2c",
  "call_id": "00b1d622a8f04f4f8f3a3c2d9e5b7a11",
  "agent_id": "1b81a241c0e44f5c9d1f0e2a7c3b9d10",
  "data": {
    "message": "check_availability -> HTTP 200",
    "level": "info",
    "latency_ms": 184,
    "name": "check_availability",
    "status": 200,
    "result": {"status": 200, "result": {"free_slots": ["10:30", "12:00"]}}
  }
}
FieldMeaning
idThe event id. The same event sent to two targets has the same id.
typeThe event type. See Webhook events.
created_atWhen the event happened, in UTC.
workspace_id, call_id, agent_idWhich call it belongs to.
data.messageA one-line description.
data.levelinfo, warning or error.
data.latency_msFor timing events, the latency; otherwise null.
data.*Fields specific to the event type.

Headers:

HeaderValue
Content-Typeapplication/json
User-AgentVaakyo-Webhooks/1.0
X-Voxa-EventThe event type.
X-Voxa-DeliveryThe delivery id (whd_...). Retries reuse it, so use it to drop duplicates.
X-Voxa-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>

Verify the signature

The signature is an HMAC-SHA256 of the string <t>.<raw request body>, keyed with your secret (whsec_...).

  1. Read the raw body, before any JSON parsing.
  2. Split the header into t and v1.
  3. Reject the request if t is more than 5 minutes from now (this stops replays).
  4. Compute the HMAC and compare it to v1 in constant time.

Python

import hashlib
import hmac
import time


def verify(secret: str, header: str, raw_body: bytes, tolerance: int = 300) -> bool:
    parts = dict(item.split("=", 1) for item in header.split(","))
    timestamp, signature = parts.get("t", ""), parts.get("v1", "")
    if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, signature)

Node

import crypto from "node:crypto";

export function verify(secret, header, rawBody, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=", 2)));
  const timestamp = Number(parts.t);
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > tolerance) return false;
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.`)
    .update(rawBody)
    .digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1 ?? "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express, read the raw body with express.raw({ type: "application/json" }) on the webhook route.

Respond and retries

  • Respond with any 2xx within 10 seconds. Do slow work after you respond.
  • Redirects are not followed; a 3xx counts as a failure.
  • Retries. Any other status, a timeout or a connection error is retried after 10 seconds, 1 minute, 5 minutes, 30 minutes and 2 hours. After the sixth failed attempt the delivery is failed.
  • Order. Events are sent roughly in order, but a retry can arrive after a later event. Sort by created_at if order matters.
  • Duplicates. A delivery can arrive more than once. Use X-Voxa-Delivery (or the event id plus your endpoint) to ignore repeats.

Delivery log

Every attempt is logged. Read it in the console under Webhooks, or through the API:

curl "https://api.vaakyo.com/api/v1/webhooks/deliveries?endpoint_id=$ENDPOINT_ID&status=failed" \
  -H "X-API-Key: $VAAKYO_API_KEY"

Filters: endpoint_id (agent for agent webhook URLs, campaign for campaign webhook URLs), status (comma-separated), event_type (comma-separated), call_id, since and until (ISO 8601 times, on when the delivery was created), limit (up to 200, default 50) and skip. Each delivery has last_status_code, last_latency_ms (how long your server took to answer the last attempt) and last_attempt_at. The console’s Logs → Webhooks tab shows the same log with these filters, see Logs.

Delivery statusMeaning
pendingWaiting for its first attempt.
deliveredYour server answered 2xx.
retryingIt failed and will be tried again at next_attempt_at.
failedEvery attempt failed, or the endpoint was deleted.
# One delivery, with the request body and your last response
curl https://api.vaakyo.com/api/v1/webhooks/deliveries/$DELIVERY_ID -H "X-API-Key: $VAAKYO_API_KEY"

# Try it once more, now
curl -X POST https://api.vaakyo.com/api/v1/webhooks/deliveries/$DELIVERY_ID/retry -H "X-API-Key: $VAAKYO_API_KEY"

A manual retry is one extra attempt; it does not restart the automatic schedule.

Rotate a secret

# An endpoint's secret
curl -X POST https://api.vaakyo.com/api/v1/webhooks/$ENDPOINT_ID/rotate-secret -H "X-API-Key: $VAAKYO_API_KEY"

# The default secret, used for agents' webhook URLs
curl -X POST https://api.vaakyo.com/api/v1/webhooks/default-secret/rotate -H "X-API-Key: $VAAKYO_API_KEY"

Both return the new secret once. The old secret stops working at once, including for retries still waiting, so update your server first and keep accepting both secrets for a moment if you can.

Esc