sphoroVOICEdocs
Markdown

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.

POST/v1/webhook_endpoints
Shell
curl -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"]
  }'
Response201 Created
{
  "id": "whep_5c2f81a4d90b",
  "object": "webhook_endpoint",
  "url": "https://example.com/webhooks/sphoro",
  "events": ["call.ended", "call.failed"],
  "enabled": true,
  "secret": "whsec_9a41c7f0e2b83d5641a7c908f2e14b6d"
}
Copy the secret now. It is returned once, on creation, and redacted on every read afterwards — for the same reason API keys are: a leaked read-only credential must not also grant the ability to forge signed events. Store it beside your API key as SPHORO_WEBHOOK_SECRET.
FieldTypeDescription
url
required
stringWhere to deliver. Must be https — events carry transcripts, and a signature proves who sent something, not that nobody read it on the way.
events
optional
array of stringsWhich events to receive. Defaults to ["*"], everything. A family works too: "call.*".
description
optional
stringA note for whoever finds this endpoint later and wonders what depends on it.
enabled
optional
booleanDefaults to true. Set false to pause delivery without deleting the endpoint and losing its secret.
metadata
optional
objectYour 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:

HTTP
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, not express.json; in Flask, request.get_data(), not request.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 t is more than five minutes old — otherwise a request captured once stays replayable indefinitely.
  • Treat v1 as 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 "", 204
const 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);
The 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:

POST/v1/webhook_endpoints/{id}/test
Shell
curl -s -X POST https://voice.sphoro.com/v1/webhook_endpoints/whep_5c2f81a4d90b/test \
  -H "Authorization: Bearer $SPHORO_API_KEY"
Either it arrived
{ "delivered": true, "response_code": 204 }
Or it says why not
{ "delivered": false, "response_code": 500, "error": "endpoint returned HTTP 500: ..." }

And the full attempt history, when a delivery you expected never arrived:

GET/v1/webhook_endpoints/{id}/deliveries
Shell
curl -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:

JSON
{
  "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

HeaderCarries
VoiceAI-Signaturet=<unix seconds>,v1=<hex HMAC>
VoiceAI-TimestampThe same timestamp, on its own, for convenience.
VoiceAI-Event-IdThe event id. Stable across retries — this is what you deduplicate on.
VoiceAI-Event-TypeThe event type, so you can route without parsing the body.
VoiceAI-Delivery-Attempt1 on the first try, higher on a retry.

Events you can subscribe to

TypeFires whendata carries
call.startedA call begins.agent_id, direction, to, from
call.endedA call finishes — however it finished.duration_seconds, turns, end_reason
call.failedA call ended abnormally.The failure
call.transcript.updatedA turn completes.The turn's role and text
call.transferredThe agent transferred the caller.The destination
call.recording.readyA recording finished processing.Where to fetch it
call.analysedThe summary and extracted fields were written — a few seconds after call.ended, which never carries them.summary, extracted, model
call.opt_outA caller asked not to be contacted again.The number
call.tool.calledThe agent invoked a function.The function
csat.receivedA 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

BehaviourDetail
SuccessAny 2xx. Anything else is a failure.
RetriesUp to six attempts, widening: 1s, 5s, 25s, 2m, 10m.
Acknowledge firstRespond, then do the work. A handler that finishes the job before replying eats into the delivery timeout and turns a success into a retry.
DeduplicateDelivery is at-least-once, so you will see the same event twice eventually. VoiceAI-Event-Id is stable across retries — key on it.
OrderingNot guaranteed. A retried call.started can land after call.ended; use created_at if order matters.
Auto-disableAn endpoint that fails 20 deliveries in a row is disabled and the reason recorded. Fix it, then PATCH it back with {"enabled": true}.
HTTPS onlyEvents carry transcripts. A signature proves origin; it does not keep anything private.

Managing endpoints

Shell
# 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

SymptomUsual cause
Every signature mismatchesYour framework parsed the body before you hashed it. This is the cause more often than everything else combined.
Some signatures mismatchClock drift on your server past the five-minute window. Check NTP.
Nothing arrives at allThe endpoint was auto-disabled after 20 consecutive failures. Read the deliveries list, fix the cause, re-enable it.
Events arrive twiceExpected. You acknowledged too slowly and we retried — key on VoiceAI-Event-Id.
Some event types never arriveThe 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.