Webhooks
Your server gets a signed HTTPS request the moment a call ends, instead of asking us every few seconds whether it has. Register one endpoint, verify every delivery, and you are done.
Step 1 · Register your endpoint
Give us an https URL and the events you care about. Subscribing to everything is the wrong default — you will parse deliveries you have no handler for.
/v1/webhook_endpointscurl -s -X POST https://voice.sphoro.com/v1/webhook_endpoints \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/sphoro",
"description": "orders service, production",
"events": ["call.ended", "call.failed"]
}'{
"id": "whep_5c2f81a4d90b",
"object": "webhook_endpoint",
"url": "https://example.com/webhooks/sphoro",
"events": ["call.ended", "call.failed"],
"enabled": true,
"secret": "whsec_9a41c7f0e2b83d5641a7c908f2e14b6d"
}SPHORO_WEBHOOK_SECRET.| Field | Type | Description |
|---|---|---|
urlrequired | string | Where to deliver. Must be https — events carry transcripts, and a signature proves who sent something, not that nobody read it on the way. |
eventsoptional | array of strings | Which events to receive. Defaults to ["*"], everything. A family works too: "call.*". |
descriptionoptional | string | A note for whoever finds this endpoint later and wonders what depends on it. |
enabledoptional | boolean | Defaults to true. Set false to pause delivery without deleting the endpoint and losing its secret. |
metadataoptional | object | Your own string key/values, returned on every read. |
Step 2 · Verify every delivery
An endpoint that skips this accepts events from anyone who learns its URL — and its URL is one misdirected log line away from being public. Every delivery carries a signature:
VoiceAI-Signature: t=1755432000,v1=8f2c4b9edb31a5c7...Split it on the comma, read t and v1, then recompute
HMAC-SHA256(secret, "<t>.<raw body>") and compare. The timestamp is
inside the MAC rather than merely alongside it, which is what stops a captured request
being replayable forever.
Four things decide whether an implementation is correct, and three of them are silent when wrong:
- Use the raw bytes of the body. Parsing the JSON and re-serialising it changes
them, and the signature will never match. In Express that means
express.raw, notexpress.json; in Flask,request.get_data(), notrequest.json. - Compare in constant time. A byte-by-byte
==leaks, through its own timing, how much of a guessed signature was right. - Check the age. Reject anything where
tis more than five minutes old — otherwise a request captured once stays replayable indefinitely. - Treat
v1as a list. A delivery carries one today, but the header is defined to allow several so a secret can be rotated without dropping events mid-rotation. Accept the delivery if any entry matches.
Complete, runnable handlers — standard library plus one web framework, nothing else to install:
import crypto from "node:crypto";
import express from "express";
const app = express();
const TOLERANCE = 5 * 60; // seconds
function verify(rawBody, header, secret) {
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) {
throw new Error("timestamp outside tolerance");
}
const expected = crypto
.createHmac("sha256", secret)
.update(timestamp + "." + rawBody)
.digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(parts.v1 ?? "", "hex");
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
throw new Error("signature mismatch");
}
return JSON.parse(rawBody);
}
// express.raw keeps the bytes intact; express.json would not.
app.post("/webhooks/sphoro", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verify(req.body, req.header("VoiceAI-Signature"), process.env.SPHORO_WEBHOOK_SECRET);
} catch {
return res.sendStatus(400);
}
// Acknowledge first, do the work after — a slow handler turns a success into a retry.
res.sendStatus(204);
if (event.type === "call.ended") {
enqueue(event.call_id, event.data.duration_seconds);
}
});import hashlib, hmac, json, os, time
from flask import Flask, request
app = Flask(__name__)
TOLERANCE = 300 # seconds
def verify(raw_body: bytes, header: str, secret: str) -> dict:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp = int(parts["t"])
if abs(time.time() - timestamp) > TOLERANCE:
raise ValueError("timestamp outside tolerance")
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, parts.get("v1", "")):
raise ValueError("signature mismatch")
return json.loads(raw_body)
@app.post("/webhooks/sphoro")
def handle():
try:
# request.get_data() is the raw bytes; request.json is not.
event = verify(
request.get_data(),
request.headers["VoiceAI-Signature"],
os.environ["SPHORO_WEBHOOK_SECRET"],
)
except (ValueError, KeyError):
return "", 400
if event["type"] == "call.ended":
enqueue_followup(event["call_id"])
return "", 204const tolerance = 5 * time.Minute
func verify(rawBody []byte, header, secret string) (map[string]any, error) {
var timestamp, signature string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(part, "=")
switch k {
case "t":
timestamp = v
case "v1":
signature = v
}
}
sent, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil || time.Since(time.Unix(sent, 0)).Abs() > tolerance {
return nil, errors.New("timestamp outside tolerance")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(timestamp + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
return nil, errors.New("signature mismatch")
}
var event map[string]any
return event, json.Unmarshal(rawBody, &event)
}
func handle(w http.ResponseWriter, r *http.Request) {
// The raw bytes, before anything decodes them.
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
event, err := verify(body, r.Header.Get("VoiceAI-Signature"), os.Getenv("SPHORO_WEBHOOK_SECRET"))
if err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusNoContent)
if event["type"] == "call.ended" {
go followUp(event["call_id"].(string))
}
}static final long TOLERANCE = 300; // seconds
static boolean verify(byte[] rawBody, String header, String secret) throws Exception {
Map<String, String> parts = new HashMap<>();
for (String part : header.split(",")) {
String[] kv = part.split("=", 2);
parts.put(kv[0], kv[1]);
}
long timestamp = Long.parseLong(parts.get("t"));
if (Math.abs(Instant.now().getEpochSecond() - timestamp) > TOLERANCE) {
return false;
}
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256"));
mac.update((timestamp + ".").getBytes(UTF_8));
byte[] expected = mac.doFinal(rawBody);
return MessageDigest.isEqual(expected, HexFormat.of().parseHex(parts.get("v1")));
}
// In the handler — getInputStream(), not a parsed body:
byte[] body = request.getInputStream().readAllBytes();
if (!verify(body, request.getHeader("VoiceAI-Signature"), System.getenv("SPHORO_WEBHOOK_SECRET"))) {
response.setStatus(400);
return;
}
response.setStatus(204);VoiceAI- header prefix is the wire format, not a
typo. These header names are fixed by integrations already deployed against them, so they
keep the platform's original name. Match on them exactly.Step 3 · Test it before a real call depends on it
Send a synthetic delivery and read the outcome in the response, rather than placing a call and hoping:
/v1/webhook_endpoints/{id}/testcurl -s -X POST https://voice.sphoro.com/v1/webhook_endpoints/whep_5c2f81a4d90b/test \
-H "Authorization: Bearer $SPHORO_API_KEY"{ "delivered": true, "response_code": 204 }{ "delivered": false, "response_code": 500, "error": "endpoint returned HTTP 500: ..." }And the full attempt history, when a delivery you expected never arrived:
/v1/webhook_endpoints/{id}/deliveriescurl -s https://voice.sphoro.com/v1/webhook_endpoints/whep_5c2f81a4d90b/deliveries \
-H "Authorization: Bearer $SPHORO_API_KEY"Each record carries the event, the status code we got back, the error if there was one, and which attempt it was. "Did you send it?" is the first question in every webhook support thread, and this answers it without one.
What arrives
Every delivery is a POST with a JSON body in this shape, whatever the event:
{
"id": "evt_8b3f21a90c47",
"type": "call.ended",
"call_id": "call_1d8e4c7b9a02",
"created_at": "2026-08-18T09:15:31Z",
"data": {
"duration_seconds": 89.2,
"turns": 6,
"end_reason": "completed"
}
}Headers on every delivery
| Header | Carries |
|---|---|
VoiceAI-Signature | t=<unix seconds>,v1=<hex
HMAC> |
VoiceAI-Timestamp | The same timestamp, on its own, for convenience. |
VoiceAI-Event-Id | The event id. Stable across retries — this is what you deduplicate on. |
VoiceAI-Event-Type | The event type, so you can route without parsing the body. |
VoiceAI-Delivery-Attempt | 1 on the first try, higher on a retry. |
Events you can subscribe to
| Type | Fires when | data carries |
|---|---|---|
call.started | A call begins. | agent_id,
direction, to, from |
call.ended | A call finishes — however it finished. | duration_seconds, turns,
end_reason |
call.failed | A call ended abnormally. | The failure |
call.transcript.updated | A turn completes. | The
turn's role and text |
call.transferred | The agent transferred the caller. | The destination |
call.recording.ready | A recording finished processing. | Where to fetch it |
call.analysed | The summary and extracted fields were
written — a few seconds after call.ended, which never carries
them. | summary, extracted, model |
call.opt_out | A caller asked not to be contacted again. | The number |
call.tool.called | The agent invoked a function. | The function |
csat.received | A caller submitted a rating. | The rating |
Most integrations want call.ended and call.failed and nothing else.
Subscribe with "*" for everything, or a family like "call.*".
How delivery behaves
| Behaviour | Detail |
|---|---|
| Success | Any 2xx. Anything else is a
failure. |
| Retries | Up to six attempts, widening: 1s, 5s, 25s, 2m, 10m. |
| Acknowledge first | Respond, then do the work. A handler that finishes the job before replying eats into the delivery timeout and turns a success into a retry. |
| Deduplicate | Delivery is at-least-once, so you will see the same
event twice eventually. VoiceAI-Event-Id is stable across retries — key on
it. |
| Ordering | Not guaranteed. A retried call.started can
land after call.ended; use created_at if order
matters. |
| Auto-disable | An endpoint that fails 20 deliveries in a row is
disabled and the reason recorded. Fix it, then PATCH it back with
{"enabled": true}. |
| HTTPS only | Events carry transcripts. A signature proves origin; it does not keep anything private. |
Managing endpoints
# List them (secrets are redacted)
curl -s https://voice.sphoro.com/v1/webhook_endpoints \
-H "Authorization: Bearer $SPHORO_API_KEY"
# Change what one is subscribed to, or re-enable it
curl -s -X PATCH https://voice.sphoro.com/v1/webhook_endpoints/whep_5c2f81a4d90b \
-H "Authorization: Bearer $SPHORO_API_KEY" \
-H "Content-Type: application/json" \
-d '{"events":["call.ended"],"enabled":true}'
# Remove it
curl -s -X DELETE https://voice.sphoro.com/v1/webhook_endpoints/whep_5c2f81a4d90b \
-H "Authorization: Bearer $SPHORO_API_KEY"When deliveries are not arriving
| Symptom | Usual cause |
|---|---|
| Every signature mismatches | Your framework parsed the body before you hashed it. This is the cause more often than everything else combined. |
| Some signatures mismatch | Clock drift on your server past the five-minute window. Check NTP. |
| Nothing arrives at all | The endpoint was auto-disabled after 20 consecutive failures. Read the deliveries list, fix the cause, re-enable it. |
| Events arrive twice | Expected. You acknowledged too slowly and we retried — key on
VoiceAI-Event-Id. |
| Some event types never arrive | The endpoint is not subscribed to them. Check
events. |
You are done
With an API key, an agent, a call and a verified webhook, the integration is complete: your software dials, the conversation happens in the language you chose, and your server hears how it went. Everything the API can do beyond this — knowledge bases the agent answers from, functions it can call, keypad menus — is described in openapi.json, and your account manager can walk you through what fits.