Now in early access·Start your 14-day free trial →
By Your Side
Sign inBook a demoStart free trial

Documentation

Webhooks

Receive real-time events as your call progresses. Set a webhookUrl when placing a call and By Your Side posts a signed JSON payload to your endpoint at key moments.

Set up a webhook

Pass a webhookUrl in the body of POST /v1/agent/calls. The URL must be publicly reachable over HTTPS. By Your Side will POST events to it during and after the call.

Place a call with a webhookUrl
curl -X POST https://api.byourside.ai/v1/agent/calls \
  -H "Authorization: Bearer bys_ak_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "to":         "+14155550123",
    "objective":  "Confirm the appointment for tomorrow at 2 PM.",
    "webhookUrl": "https://your-server.example.com/webhooks/bys"
  }'

Events

Two events are dispatched per call:

EventWhen it fires
call.in_progressThe moment the call is answered and the AI begins speaking.
call.completedCall finished normally. Summary and extracted fields are populated.
call.no_answerDestination did not pick up.
call.voicemailReached voicemail.
call.declinedCall rejected by recipient.
call.failedTechnical failure.

Every call produces exactly two events: one call.in_progress (if it was answered) and one terminal event. If the call is never answered (no_answer, declined, failed) only the terminal event fires.

Payload shape

Each request body is a JSON object. Fields that are not yet available at the time of the event (for example endedAt during call.in_progress) are null.

FieldTypeDescription
eventstringEvent name, e.g. call.completed.
eventIdstringUnique per delivery. Retries of the same delivery reuse the same eventId; dedup on this field.
timestampstringISO 8601 time when the event was built.
callIdstringThe call ID from place_call.
statusstringCurrent call status.
tostringDestination number (E.164).
objectivestringThe objective you supplied.
startedAtstring | nullWhen the call was answered. Set for in_progress and terminal events where the call connected.
endedAtstring | nullWhen the call ended. Set on terminal events only.
durationSecnumber | nullCall duration in seconds. Set on terminal events only.
summarystring | nullPlain-English summary. Set on call.completed.
extractedobject | nullExtracted field values. Set on call.completed.
errorstring | nullError token. Set on call.failed and similar outcomes.

call.in_progress payload example

call.in_progress payload
{
  "event":       "call.in_progress",
  "eventId":     "f47ac10b-58cc-4372-a567-0e02b2c3d479:call.in_progress:1750163000000",
  "timestamp":   "2026-06-17T14:03:15.000Z",
  "callId":      "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status":      "in_progress",
  "to":          "+14155550123",
  "objective":   "Confirm the appointment for tomorrow at 2 PM.",
  "startedAt":   "2026-06-17T14:03:12.000Z",
  "endedAt":     null,
  "durationSec": null,
  "summary":     null,
  "extracted":   null
}

call.completed payload example

call.completed payload
{
  "event":       "call.completed",
  "eventId":     "f47ac10b-58cc-4372-a567-0e02b2c3d479:call.completed:1750163148000",
  "timestamp":   "2026-06-17T14:05:48.000Z",
  "callId":      "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "status":      "completed",
  "to":          "+14155550123",
  "objective":   "Confirm the appointment for tomorrow at 2 PM.",
  "startedAt":   "2026-06-17T14:03:12.000Z",
  "endedAt":     "2026-06-17T14:05:48.000Z",
  "durationSec": 156,
  "summary":     "The contact confirmed the appointment at 2 PM.",
  "extracted":   { "confirmed": true }
}

Signature verification

Every webhook request includes an X-BYS-Signature header. Verify it before processing the payload to confirm the request came from By Your Side and was not tampered with.

Header format: t=<unix_seconds>,v1=<hex>

The hex value is HMAC-SHA256(webhookSecret, "{t}.{rawBody}") where t is the Unix timestamp in seconds and rawBody is the raw (unparsed) request body as a string. Reject the request if the signature does not match or if the timestamp is older than your tolerance (5 minutes is a common choice).

Important: read the body as raw bytes before parsing. Parsing JSON first and re-serializing it will produce a different string and break verification.

Using the SDK helpers (recommended)

Node SDK - verifyWebhook
import { verifyWebhook } from './src/index.js';

// req is an Express request with raw body
const valid = verifyWebhook(
  req.headers['x-bys-signature'],
  req.body.toString('utf8'),
  process.env.BYS_WEBHOOK_SECRET
);
if (!valid) return res.status(400).send('Bad signature');
Python SDK - verify_webhook
from byourside import verify_webhook

valid = verify_webhook(
    sig_header=request.headers.get('X-BYS-Signature', ''),
    raw_body=request.get_data(as_text=True),
    secret=WEBHOOK_SECRET,
)
if not valid:
    abort(400, 'Invalid signature')

Verifying manually (Node)

Manual verification - Node
import crypto from 'node:crypto';

function verifyWebhook(sigHeader, rawBody, secret) {
  // sigHeader format: "t=<unix_seconds>,v1=<hex>"
  const parts  = Object.fromEntries(sigHeader.split(',').map((p) => p.split('=')));
  const t      = parts.t;
  const v1     = parts.v1;
  if (!t || !v1) return false;

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

  const valid = crypto.timingSafeEqual(Buffer.from(v1, 'hex'), Buffer.from(expected, 'hex'));

  // Reject if older than 5 minutes
  const age = Math.floor(Date.now() / 1000) - parseInt(t, 10);
  return valid && age < 300;
}

Verifying manually (Python)

Manual verification - Python
import hmac, hashlib, time

def verify_webhook(sig_header: str, raw_body: str, secret: str) -> bool:
    # sig_header format: "t=<unix_seconds>,v1=<hex>"
    parts = dict(p.split('=', 1) for p in sig_header.split(','))
    t  = parts.get('t', '')
    v1 = parts.get('v1', '')
    if not t or not v1:
        return False

    mac = hmac.new(secret.encode(), f'{t}.{raw_body}'.encode(), hashlib.sha256)
    expected = mac.hexdigest()

    valid = hmac.compare_digest(v1, expected)
    age   = int(time.time()) - int(t)
    return valid and age < 300

Delivery semantics

  • At-least-once. An event may be delivered more than once (for example, if your server does not return a 2xx within the timeout). Deduplicate on eventId: retries of the same delivery reuse the same eventId.
  • Ordering. call.in_progress is emitted before the terminal event in wall-clock time. Under retries, arrival order is not guaranteed. Use the timestamp field to order events, not arrival order.
  • Your endpoint must return 2xx. Any non-2xx response or a timeout is treated as a failed delivery and retried. Return 200 immediately; do your processing asynchronously if needed.
  • No transcript in the payload. The webhook payload is intentionally lean. Call GET /v1/agent/calls/{callId} to fetch the full transcript and recording URL.

For the full endpoint and parameter reference, see the API reference. For SDK usage, see SDKs.