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

```bash
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**

```json
{
  "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`.

| Field | Type | Description |
| --- | --- | --- |
| `url` required | 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. |
| `events` optional | array of strings | Which events to receive. Defaults to `["*"]`, everything. A family works too: `"call.*"`. |
| `description` optional | string | A note for whoever finds this endpoint later and wonders what depends on it. |
| `enabled` optional | boolean | Defaults to true. Set false to pause delivery without deleting the endpoint and losing its secret. |
| `metadata` optional | 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:

**HTTP**

```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:

**Node**

```js
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);
  }
});
```

**Python**

```python
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
```

**Go**

```go
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))
    }
}
```

**Java**

```java
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**

```bash
curl -s -X POST https://voice.sphoro.com/v1/webhook_endpoints/whep_5c2f81a4d90b/test \
  -H "Authorization: Bearer $SPHORO_API_KEY"
```

**Either it arrived**

```json
{ "delivered": true, "response_code": 204 }
```

**Or it says why not**

```json
{ "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**

```bash
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**

```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

| 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

**Shell**

```bash
# 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](https://voice.sphoro.com/openapi.json), and your account manager can walk you through what fits.
