Webhooks

Register a signed webhook endpoint over the API or in the workspace, choose its events, and verify every delivery.

A webhook endpoint receives a signed POST from Skysay each time a call or SMS in its project changes state, so your system can react without polling. You can register one with the REST API, as shown below, or in the workspace under Webhooks. Both create the same kind of endpoint.

How an endpoint is scoped

An endpoint belongs to one project. It receives every subscribed event for every call and SMS in that project, whichever agent handled it. To separate traffic, use one project per system, or filter on data in your handler.

Only an https:// URL on a public address receives deliveries. Skysay does not follow redirects.

Register an endpoint

POST /v1/agent-endpoints · scope agents:write

curl -s -X POST "$SKYSAY_BASE_URL/v1/agent-endpoints" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "project_id": "proj_4Nc8TkQ2sW6yBd3F",
    "name": "Order system",
    "webhook_url": "https://hooks.example.com/skysay",
    "audio_stream_url": "https://hooks.example.com/skysay/unused-audio",
    "events": ["call.answered", "call.completed", "call.failed", "call.canceled", "call.transcript.ready"]
  }'

project_id, webhook_url and audio_stream_url are required. Get your project id from GET /v1/workspace-context (scope account:read). You do not need to send organization_id: the key's own workspace is used.

audio_stream_url is required because this route also registers bring-your-own-agent audio agents. For an endpoint that only receives events, pass any URL you control. Skysay streams audio to it only if a call is routed to this endpoint's own agent, which never happens unless you route a number or a call to that agent.

The response is 201 and is the only time the signing secret is shown:

{
  "id": "ae_5RwP8yHc2QdLz6Jn",
  "agent_id": "agent_3TfW9kLp6NqX2vBz",
  "project_id": "proj_4Nc8TkQ2sW6yBd3F",
  "name": "Order system",
  "status": "active",
  "webhook_url": "https://hooks.example.com/skysay",
  "events": ["call.answered", "call.completed", "call.failed", "call.canceled", "call.transcript.ready"],
  "webhook_secret": "whsec_2mR8…",
  "webhook_secret_set": true,
  "consecutive_failures": 0,
  "last_delivery_at": null,
  "last_delivery_status": "",
  "last_error": ""
}

Store webhook_secret now. Every later read shows "[REDACTED]". To use a secret you generated, send it as webhook_secret when you create the endpoint: 16 to 200 characters of letters, digits and ._~+/=-.

Leave out events, or send an empty array, to subscribe to the default set: call.created, call.answered, media.started, call.ended, call.completed, call.failed and call.canceled. An unknown event name answers 400 and names it.

Every endpoint has a companion agent

Registering an endpoint also creates an external_audio agent wrapping it, returned as agent_id. It shows up in GET /v1/agents with metadata.legacy_agent_endpoint_alias: true and is retired when you delete the endpoint. Each hosted agent likewise owns an endpoint, listed in GET /v1/agent-endpoints with a telephony-ai:// URL; it receives no deliveries. Deleting a hosted agent's own endpoint (its agent_endpoint_id) retires that agent, so delete only the endpoints you registered.

Manage endpoints

List endpoints with an explicit page size, for example GET /v1/agent-endpoints?limit=100. The result is { agent_endpoints, pagination }, not an array, and a missing limit is 400. The delivery feed likewise returns { deliveries, pagination, event_catalog, retry_policy }. Follow list pagination before deciding an endpoint is absent; do not register a duplicate because a match is outside the first page or because a response cannot be parsed.

MethodPathScopeWhat it does
GET/v1/agent-endpointsagents:readList endpoints with their status, subscribed events and last delivery result.
PATCH/v1/agent-endpoints/{agent_endpoint_id}agents:writeReplace the events list. Send the complete list; an empty array is refused.
POST/v1/agent-endpoints/{agent_endpoint_id}/test-eventagents:writeSend a signed webhook.test event now, through the real pipeline.
POST/v1/agent-endpoints/{agent_endpoint_id}/pauseagents:writeStop deliveries without deleting the endpoint.
POST/v1/agent-endpoints/{agent_endpoint_id}/resumeagents:writeResume a paused or auto-disabled endpoint.
DELETE/v1/agent-endpoints/{agent_endpoint_id}agents:writeDelete the endpoint. Undelivered events are dropped.
GET/v1/webhook-deliveriesagents:readThe delivery feed: each attempt's event, status, retries and last error, plus the event catalogue and retry policy.

Send a test event before relying on an endpoint:

curl -s -X POST "$SKYSAY_BASE_URL/v1/agent-endpoints/ae_5RwP8yHc2QdLz6Jn/test-event" \
  -H "Authorization: Bearer $SKYSAY_API_KEY"

The test event goes to that one endpoint whatever its subscription. Its data carries a message and the retry policy, and the response reports the delivery attempt. A paused, auto-disabled or deleted endpoint answers 409. Resume it first.

Change an endpoint's events or URL

Edit the endpoint in place rather than deleting it and registering a new one. Editing keeps the signing secret, so your signature check keeps working; a new endpoint gets a new secret.

In the workspace, open Webhooks and choose Edit on the endpoint. Tick the events it should receive, change the URL if it has moved, and save. The dialog shows how many events the endpoint now receives. A deleted endpoint cannot be edited.

Over the API, events and URL are separate calls:

# Replace the events list. Send the complete list.
curl -s -X PATCH "$SKYSAY_BASE_URL/v1/agent-endpoints/ae_5RwP8yHc2QdLz6Jn" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"events": ["call.completed", "call.failed"]}'

# Move the endpoint to a new URL, through its companion agent.
curl -s -X PATCH "$SKYSAY_BASE_URL/v1/agents/agent_3TfW9kLp6NqX2vBz" \
  -H "Authorization: Bearer $SKYSAY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"webhook_url": "https://hooks.example.com/skysay/v2"}'

Both need scope agents:write. The events call answers 409 for a deleted endpoint and 400 for an empty list or an unknown event name. Its response carries the endpoint's agent_id, which is the agent to send the new webhook_url to. Changing the URL does not change audio_stream_url, and the new URL must be https:// on a public address to receive deliveries.

What Skysay sends

Each delivery is an HTTPS POST with Content-Type: application/json, User-Agent: Skysay-Webhooks/1, and two signature headers:

HeaderValue
X-Agent-Telco-TimestampUnix time, in seconds, when the delivery was signed
X-Agent-Telco-Signaturesha256= then the hex HMAC-SHA256 of timestamp + "." + raw body, keyed with your webhook_secret

The body is one event:

{
  "id": "call_lifecycle:call.completed:call_8GmT3xRk5VqW2nYp",
  "type": "call.completed",
  "created_at": "2026-09-24T09:06:03Z",
  "organization_id": "org_7Hq2LmX9pRt4VwZ1",
  "data": {
    "call_id": "call_8GmT3xRk5VqW2nYp",
    "direction": "outbound",
    "status": "completed",
    "from": "+12025550143",
    "to": "+12025550188",
    "occurred_at": "2026-09-24T09:06:03Z",
    "event": {}
  }
}

The data of call.completed and call.failed also says where the call's workflow ended, in workflow, the same object GET /v1/calls/{call_id} returns (null for a call that ran no workflow). It is the value when the call ended: while its settled is false, read the call again for the current one. See Where a call's workflow ended.

id is stable for each state of each call, so a call produces one call.completed however many parts of the platform observed it. Store id and ignore a repeat. Treat the body as a signal and read the authoritative record with GET /v1/calls/{call_id}; see Read call evidence and Post-call results.

Retries and auto-disable

A 2xx response within 10 seconds is success. Anything else is a failure, including a redirect or a timeout. Failed deliveries are retried after 1 minute, 5 minutes, 30 minutes, 2 hours and 8 hours: six attempts in all. When 5 deliveries in a row have used up all six attempts, the endpoint's status becomes auto_disabled and deliveries stop until you call resume.

Deliveries are at least once, and order is not guaranteed. Acknowledge quickly and do the work afterwards.

Verify every delivery

Verify the signature over the exact raw bytes you received, before parsing the JSON. Re-serialising parsed JSON changes the bytes and the signature will not match. Reject a timestamp more than five minutes from your clock.

import hashlib
import hmac
import time

TOLERANCE_SECONDS = 300


def verify_skysay_webhook(secret: str, raw_body: bytes, timestamp: str, signature: str) -> bool:
    """Return True only for a fresh delivery signed with this endpoint's secret."""
    try:
        sent_at = int(timestamp)
    except (TypeError, ValueError):
        return False
    if abs(time.time() - sent_at) > TOLERANCE_SECONDS:
        return False
    expected = hmac.new(
        secret.encode("utf-8"),
        timestamp.encode("utf-8") + b"." + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest("sha256=" + expected, signature or "")


# Flask example
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = "whsec_..."  # load from your secret store


@app.post("/skysay")
def skysay_webhook():
    if not verify_skysay_webhook(
        SECRET,
        request.get_data(),  # raw bytes, before any JSON parsing
        request.headers.get("X-Agent-Telco-Timestamp", ""),
        request.headers.get("X-Agent-Telco-Signature", ""),
    ):
        abort(401)
    event = request.get_json()
    # dedupe on event["id"], then queue the work
    return "", 204
import crypto from "node:crypto";
import express from "express";

const TOLERANCE_SECONDS = 300;
const SECRET = process.env.SKYSAY_WEBHOOK_SECRET; // whsec_...

function verifySkysayWebhook(secret, rawBody, timestamp, signature) {
  const sentAt = Number.parseInt(timestamp, 10);
  if (!Number.isFinite(sentAt)) return false;
  if (Math.abs(Date.now() / 1000 - sentAt) > TOLERANCE_SECONDS) return false;
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", secret).update(`${timestamp}.`).update(rawBody).digest("hex");
  const a = Buffer.from(expected);
  const b = Buffer.from(signature || "");
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

const app = express();

// express.raw keeps the body as the exact bytes Skysay signed.
app.post("/skysay", express.raw({ type: "application/json" }), (req, res) => {
  const ok = verifySkysayWebhook(
    SECRET,
    req.body,
    req.get("X-Agent-Telco-Timestamp"),
    req.get("X-Agent-Telco-Signature"),
  );
  if (!ok) return res.sendStatus(401);
  const event = JSON.parse(req.body.toString("utf8"));
  // dedupe on event.id, then queue the work
  res.sendStatus(204);
});

app.listen(3000);

The same headers and algorithm sign Integration Actions, so one verifier serves both.

Event catalogue

EventWhen it fires
call.created, call.queued, call.dialingThe call exists, is waiting for a worker, is ringing out
call.answered, media.startedSomeone picked up; audio is flowing
call.ended, call.completedThe call finished normally
call.failed, call.canceledThe call ended without a conversation, or was withdrawn before dialling
call.transcript.ready, call.recording.readyEvidence is ready to fetch
call.extraction.ready, call.extraction.failedThe optional post-call extraction finished
sms.received, sms.sent, sms.delivery_updatedAn SMS arrived, left, or its carrier status changed
campaign.launched, campaign.contact.completed, campaign.completedCampaign progress

call.failed and call.canceled are terminal like call.completed. Wait for any of the three. GET /v1/webhook-deliveries returns the current catalogue as event_catalog.