Skip to content

Webhooks

Webhooks let Sahay tell your server when something happens, so you do not have to poll. Sahay sends an HTTP POST with a JSON body to a URL you choose.

An organization admin sets up the webhook in the Sahay dashboard under Developers, on the Webhooks tab.

  • Each organization has one webhook: one URL, one signing secret, and the list of events it receives.
  • The URL must use https, must not contain a username or password, and must resolve to a public address. Addresses on private networks and localhost are refused. Sahay checks this when you save the webhook and again before every delivery.
  • The signing secret starts with whsec_. It is shown once, when the webhook is created. If you lose it, rotate it in the dashboard. The old secret stops verifying immediately.
  • The dashboard can send a test event and shows your recent deliveries and their results.
Event When it is sent
call.completed A call placed with POST /calls has ended.
campaign.contact.completed One contact of a campaign reached a final result.
campaign.completed A running campaign has no contacts left to dial.

You choose which events your webhook receives. An event you did not choose is not sent.

Every delivery has the same envelope:

{
"id": "evt_3c9a41d2e0b84c5f9a17d6b2c8e4f013",
"type": "call.completed",
"created_at": "2026-11-04T09:31:35.120000+00:00",
"data": {
"call_id": "call_5b7e2d90c1a44f3e8d21a0b7",
"status": "completed",
"to_number": "+919876543210",
"agent_id": "agent_8f3a1c",
"duration_seconds": 94,
"metadata": { "order_id": "A-1042" },
"ended_at": "2026-11-04T09:31:34+00:00"
}
}
Field Description
id The event id. Every attempt to deliver the same event carries the same id, so use it to ignore duplicates.
type The event type.
created_at When the event was created, in UTC.
data The event data, described below.

Headers on each request:

Header Value
Content-Type application/json
User-Agent Sahay-Webhooks/1
Sahay-Event-Id The same value as the envelope id.
Sahay-Signature t=<unix timestamp>,v1=<signature>. See below.

Sent when a call placed with POST /calls ends. status is completed, failed, no_answer, busy or refused. duration_seconds is null when the call was not answered. metadata is what you sent when you placed the call, or null.

{
"call_id": "call_5b7e2d90c1a44f3e8d21a0b7",
"status": "completed",
"to_number": "+919876543210",
"agent_id": "agent_8f3a1c",
"duration_seconds": 94,
"metadata": { "order_id": "A-1042" },
"ended_at": "2026-11-04T09:31:34+00:00"
}

The event does not carry the transcript, summary or outcome. Read the call with GET /calls/{call_id} for those. They can take a short time after the call ends to become available.

Sent when a campaign contact reaches a final result. For an answered call, it is sent once the outcome has been worked out, so outcome and summary are filled in. For a contact that was never answered, it is sent after the last attempt, with outcome and summary set to null.

status is answered, no_answer, busy or failed.

{
"campaign_id": "camp_a91c33",
"contact_id": "cc_01",
"phone": "+919876543210",
"status": "answered",
"outcome": "interested",
"summary": "Wants a quote for 20 units.",
"duration_seconds": 74
}

Sent once, when a running campaign has no contacts left to dial. contact_counts is the number of contacts in each status.

{
"campaign_id": "camp_a91c33",
"name": "November follow-ups",
"contact_counts": { "answered": 38, "no_answer": 10, "busy": 2 }
}

Respond with any 2xx status within 10 seconds. Sahay does not follow redirects, so a 3xx counts as a failure.

If a delivery fails (a non-2xx status, a timeout or a connection error), Sahay tries again: an event is attempted up to 3 times in total, at once, then about 10 seconds later, then about 60 seconds after that.

Retries that have not run when Sahay restarts are dropped. If you need certainty, reconcile with GET /calls and GET /campaigns instead of relying on webhooks alone. Events are not guaranteed to arrive in order.

Check the signature on every request, so you know it came from Sahay.

The Sahay-Signature header looks like this:

Sahay-Signature: t=1762248695,v1=5e1c0b4c8c2f3a7e9d6b0a4f1c8e2d7b3a9f6c5d4e1b0a8c7f2e3d9b6a5c4f10

To verify:

  1. Read the raw request body exactly as received. Do not parse and re-serialize the JSON, because that changes the bytes.
  2. Split the header at the comma and take the timestamp t and the signature v1.
  3. Build the signed string: the timestamp, a dot, then the raw body. For example 1762248695.{"id":"evt_..."}.
  4. Compute the HMAC-SHA256 of that string, keyed with your signing secret (the whole whsec_... value, as text), and write it as lowercase hex.
  5. Compare it to v1 with a constant-time comparison.
  6. Reject the request if t is too far from the current time. We suggest a tolerance of 5 minutes (300 seconds), which protects you from replayed requests.

Each retry is signed again with a fresh timestamp, so the signature of a retry differs from the first attempt even though the body and event id are the same.

import crypto from "node:crypto";
// rawBody: the request body exactly as received (a string or Buffer, not parsed JSON)
export function verify(rawBody, header, secret, toleranceSeconds = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const t = parts.t;
if (Math.abs(Date.now() / 1000 - Number(t)) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 ?? "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str, tolerance_seconds: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
t = parts.get("t", "")
if abs(time.time() - int(t)) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))