# Webhooks: events from scoco to your systems

> scoco sends every event, such as an ended call or a booked appointment, as a signed JSON message to an HTTPS address of your choice.

https://scoco.ai/en/docs/webhooks/

With webhooks, scoco tells your own systems what happens as it happens, for example "call ended" or "appointment booked". You enter an HTTPS address, and scoco sends every event there as a signed JSON message. [Zapier, Make and n8n](https://scoco.ai/en/docs/zapier-make-n8n/) connect the same way.

## Add an endpoint

1. Open **Settings › Integrations**.
2. In the **Automation** card, click **Webhook endpoint**.
3. Enter the **Address**. It must start with `https://`.
4. If you like, choose **Events**. Without a choice you get all of them.
5. Click **Add**.
6. The **Signing key** window shows the key (`whsec_…`) exactly once. Save it with **Copy key** and close with **I have saved the key**.

The key cannot be shown again. If you lose it, remove the endpoint with **Remove** in its menu and add it again.

## Events

| Event | Sent when |
|---|---|
| `call.completed` | a call has ended, with its result, duration and the caller's number |
| `call.summary_ready` | the AI summary of a call is ready |
| `conversation.completed` | a WhatsApp conversation has ended, with its result and the customer's number; only with WhatsApp active |
| `conversation.summary_ready` | the AI summary of a WhatsApp conversation is ready; only with WhatsApp active |
| `ticket.status_changed` | the status of a call or WhatsApp conversation changes: once automatically after every conversation (with `initial: true`), then on every change in the Inbox, marking as spam included |
| `ticket.assigned` | a call or WhatsApp conversation is assigned, reassigned or unassigned (then `to: null`) |
| `message.sms_received` | a text message arrives at your scoco number |
| `message.sms_sent` | scoco sends a text message or tries to: check `data.status`, it can be `failed` |
| `appointment.booked` | your assistant books an appointment |
| `appointment.cancelled` | an appointment is cancelled: by the caller on the phone, by the customer on WhatsApp or by your team in the dashboard |
| `appointment.rescheduled` | an appointment is moved: by the caller on the phone, by the customer on WhatsApp or by your team in the dashboard |
| `contact.created` | a contact is created, by hand or automatically from a conversation; an import sends nothing |

Events are only ever added, none is removed. Your receiver should simply ignore events it does not know.

Test calls made from the dashboard trigger no events, and neither does a change you make directly in your calendar.

## What a message looks like

Every delivery is a `POST` with `Content-Type: application/json`. The `X-Scoco-Event` header names the type, here a `call.completed`:

```json
{
  "id": "5f0c…",
  "type": "call.completed",
  "created_at": "2025-08-06T11:58:39+00:00",
  "tenant_id": "a319…",
  "data": {
    "call_id": "…",
    "channel": "voice",
    "assistant_id": "…",
    "caller_e164": "+4930123456",
    "started_at": "2025-08-06T11:56:01+00:00",
    "ended_at": "2025-08-06T11:58:33+00:00",
    "duration_seconds": 151.48,
    "result": "message_taken",
    "contact_id": null
  }
}
```

What `data` holds depends on the event. It always carries the relevant IDs; the dashboard shows all the details. Fields in `data` are only ever added, too. A field can be `null` when the value is missing, and `duration_seconds` is a decimal number.

The `id` stays the same on every retry, so you can recognise a message you have already processed.

`call.summary_ready` and `conversation.summary_ready` contain `language` and `original`. If the conversation ran in another language than your assistant's, `language` is its code, such as `ar`, otherwise `null`. Name, company and request arrive in your account's language, names in Latin script. Where that differs from the caller's own words, those are in `original`, under the same keys, such as the name in Arabic script under `caller_name`. Otherwise `original` is `null`.

`call.summary_ready` contains `caller_silent`. It is `true` when the caller hung up without saying anything. `summary` is then a fixed sentence instead of an AI summary, and the fields that otherwise come from the conversation are `null`.

Both appointment changes carry the same `appointment_id` as the appointment's `appointment.booked`. `appointment.cancelled` contains `starts_at`, `ends_at` and `cancelled_by` (`caller` or `user`). `appointment.rescheduled` contains `previous_starts_at`, the new `starts_at` and `ends_at`, and `rescheduled_by` (`caller` or `user`). Both are sent only once the change has reached your calendar.

## Verify the signature

Every delivery carries the header `X-Scoco-Signature: t=1754481521,v1=5257a869e7…`. `v1` is an HMAC-SHA256 (hexadecimal) of the string `<t>.<raw request body>`. The key is your signing key exactly as it was shown, `whsec_` included. "Standard Webhooks" libraries strip `whsec_` and decode the rest; with them the check fails.

`t` is the time of signing, not `created_at`: every retry is signed again. Reject messages whose `t` is more than about 5 minutes old; that protects you against replayed messages.

Verify the bytes as they arrive, not JSON you serialised again: a single changed space changes the signature. In Node.js, `express.raw({ type: "application/json" })` gives you the raw body; in PHP, `file_get_contents('php://input')`.

**Python:**

```python
import hashlib, hmac, time

def verify(secret: str, body: bytes, header: str, tolerance: int = 300) -> bool:
    try:
        parts = dict(p.split("=", 1) for p in header.split(","))
        t, v1 = int(parts["t"]), parts["v1"]
    except (KeyError, ValueError):
        return False
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())
```

**Node.js:**

```js
const crypto = require("node:crypto");

function verify(secret, body, header, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split(/=(.*)/s, 2)));
  const t = Number(parts.t);
  if (Math.abs(Date.now() / 1000 - t) > tolerance) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(body).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "utf8");
  return given.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected, "utf8"), given);
}
```

**PHP:**

```php
function verify(string $secret, string $body, string $header, int $tolerance = 300): bool {
    $parts = [];
    foreach (explode(',', $header) as $p) {
        [$k, $v] = array_pad(explode('=', $p, 2), 2, '');
        $parts[$k] = $v;
    }
    $t = (int) ($parts['t'] ?? 0);
    if (abs(time() - $t) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $t . '.' . $body, $secret);
    return hash_equals($expected, $parts['v1'] ?? '');
}
```

## Delivery, retries, switching off

- A delivery counts as successful when your endpoint answers with a 2xx status within 10 seconds. Answer right away and process the message afterwards.
- If a delivery fails, scoco tries again: up to 5 attempts in total, the first included, with pauses of about 1 minute, 5 minutes, 15 minutes and 1 hour.
- If 20 deliveries in a row fail and the first of them is at least 24 hours old, scoco switches the endpoint off and emails the owners. **Enable** switches it back on. Events from the time in between are not delivered later.
- **Log** shows the latest 50 deliveries. A delivery is deleted after 30 days, and removing the endpoint deletes its log too.
- **Test** sends a signed `ping` event to your endpoint. It does not appear in the log and does not count as a failure.

## Data protection

The messages contain personal data such as callers' numbers, names and summaries. By adding an endpoint, you instruct scoco to send this data to the system you name. Make sure it is protected there.

_Last checked: 29 October 2026_
