Webhooks
Overview
How OnCore delivers webhooks, and how to verify each kind.
OnCore pushes events to your server as HTTP POST requests with JSON bodies. Call and conversation events are sent to the assistant's serverUrl if set (per-assistant override), otherwise to your tenant-level server URL — both configured in your OnCore dashboard.
There are two delivery models, and they are verified differently:
| Model | Events | Verification | Retries |
|---|---|---|---|
| Shared-secret header | Voice, messaging, and tool events | Plaintext x-sadie-core-secret header | None |
| HMAC-signed | WhatsApp onboarding events | X-OnCore-Signature: sha256=<hex> over the raw body | 5 attempts, exponential backoff |
Both use the same secret value: your client server secret, shown on the API Keys page in your OnCore dashboard.
Model A — shared-secret header#
Voice, messaging, and tool deliveries carry your client server secret verbatim in a header:
POST /your-webhook-endpoint HTTP/1.1
Content-Type: application/json
x-sadie-core-secret: YOUR_CLIENT_SERVER_SECRETCompare it against the value you have on file and reject mismatches:
app.post("/sadie/webhooks", (req, res) => {
if (req.header("x-sadie-core-secret") !== process.env.SADIE_CLIENT_SERVER_SECRET) {
return res.status(401).end();
}
// handle req.body by its `type` field
res.status(200).end();
});No retries — respond fast
These deliveries are currently not retried: if your endpoint is down or returns a non-2xx status, the event is not redelivered. Respond quickly with a 2xx and do your processing asynchronously.
Model B — HMAC signature (WhatsApp events)#
WhatsApp onboarding events are signed instead. Each delivery carries:
X-OnCore-Signature: sha256=<hex>— HMAC-SHA256 of the raw request body bytes, keyed by the same client server secretX-OnCore-Delivery: <uuid>— the delivery id
Verify against the raw body before JSON-parsing it, using a constant-time comparison:
import { createHmac, timingSafeEqual } from "crypto";
import express from "express";
const app = express();
app.post(
"/sadie/whatsapp-webhooks",
express.raw({ type: "application/json" }), // keep the raw bytes
(req, res) => {
const secret = process.env.SADIE_CLIENT_SERVER_SECRET!;
const expected = `sha256=${createHmac("sha256", secret).update(req.body).digest("hex")}`;
const received = req.header("X-OnCore-Signature") ?? "";
const a = Buffer.from(expected);
const b = Buffer.from(received);
if (a.length !== b.length || !timingSafeEqual(a, b)) {
return res.status(401).end();
}
const event = JSON.parse(req.body.toString("utf8"));
// dedupe on req.header("X-OnCore-Delivery") — retries reuse the same id
res.status(200).end();
},
);Delivery is at-least-once: a non-2xx response or timeout is retried up to 5 times with exponential backoff. All retries of one event reuse the same X-OnCore-Delivery id — dedupe on it so a retried delivery isn't processed twice. Respond 2xx promptly to stop retries.
Event catalog#
| Event | Fires when | Model | Page |
|---|---|---|---|
assistant-request | A voice call starts | A | Assistant request |
end-of-call-report | A voice call ends | A | End-of-call report |
assistant-request (messaging) | A messaging conversation starts | A | End-of-conversation report |
end-of-conversation-report | A messaging conversation ends | A | End-of-conversation report |
| Tool invocation | The assistant calls one of your tools | A | Tool proxy |
whatsapp.onboarding.tier_pending / completed / failed, whatsapp.calling.live | WhatsApp onboarding lifecycle | B | WhatsApp events |
Telling calls and conversations apart
Voice events carry a call_id; messaging events carry a conversation_id plus channel and customer_identifier. Branch on the presence of these fields (or on type + channel) in a shared endpoint.