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.
Set up
Section titled “Set up”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 andlocalhostare 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.
Events
Section titled “Events”| 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.
Request format
Section titled “Request format”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. |
call.completed
Section titled “call.completed”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.
campaign.contact.completed
Section titled “campaign.contact.completed”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}campaign.completed
Section titled “campaign.completed”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 }}Responding and retries
Section titled “Responding and retries”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.
Verifying the signature
Section titled “Verifying the signature”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=5e1c0b4c8c2f3a7e9d6b0a4f1c8e2d7b3a9f6c5d4e1b0a8c7f2e3d9b6a5c4f10To verify:
- Read the raw request body exactly as received. Do not parse and re-serialize the JSON, because that changes the bytes.
- Split the header at the comma and take the timestamp
tand the signaturev1. - Build the signed string: the timestamp, a dot, then the raw body. For example
1762248695.{"id":"evt_..."}. - Compute the HMAC-SHA256 of that string, keyed with your signing secret (the whole
whsec_...value, as text), and write it as lowercase hex. - Compare it to
v1with a constant-time comparison. - Reject the request if
tis 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.
Node.js
Section titled “Node.js”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);}Python
Section titled “Python”import hashlibimport hmacimport 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", ""))