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.
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 connect the same way.
Add an endpoint
- Open Settings
Integrations. - In the Automation card, click Webhook endpoint.
- Enter the Address. It must start with
https://. - If you like, choose Events. Without a choice you get all of them.
- Click Add.
- 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:
{
"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').
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())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);
}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
pingevent 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.