Troubleshooting
Webhooks not arriving
Why a webhook never showed up, why verification fails, and how to debug delivery you cannot see.
Webhook problems split cleanly in two: it never arrived, or it arrived and you rejected it. Establish which before debugging.
Is your endpoint even being called?#
Log unconditionally at the very top of the handler — before verification, before parsing. If nothing appears, the request is not reaching you.
app.post("/oncore/webhooks", (req, res) => {
console.log("[webhook] hit", {
at: new Date().toISOString(),
secret: req.header("x-sadie-core-secret") ? "present" : "MISSING",
length: req.header("content-length"),
});
// …verification below
});It never arrived#
Check where the event is configured to go#
Call and conversation events go to the assistant's serverUrl when one is set, and fall back to your tenant-level server URL otherwise. A per-assistant override silently wins over the tenant default — if you configured one and are watching the other, you will see nothing.
Check reachability from the internet#
localhost and private addresses are not reachable from OnCore. During development, tunnel:
ngrok http 3000
# then set serverUrl to the https:// forwarding addressCheck that you are not failing before you respond#
A crash inside the handler still returns a non-2xx, which for voice and messaging events means the delivery is simply gone:
Model A deliveries are not retried
Voice, messaging and tool events use the shared-secret model and are delivered once. A non-2xx response, a timeout, or a crash means the event is not redelivered. Respond 2xx immediately and do the work asynchronously.
app.post("/oncore/webhooks", express.json(), (req, res) => {
if (req.header("x-sadie-core-secret") !== process.env.ONCORE_CLIENT_SERVER_SECRET) {
return res.status(401).end();
}
res.status(200).end(); // acknowledge immediately
void handleEvent(req.body); // never let this delay the response
});Check that the event fires at all#
Not every call produces every event. An end-of-call report requires a call that actually connected — a failed or unanswered call may not produce one.
It arrived but verification fails#
Model A — the shared-secret header#
The header is x-sadie-core-secret, sent verbatim. Compare against the client server secret from the API Keys page, not an API key. Header names are case-insensitive; most frameworks lowercase them for you, but check yours.
Model B — the HMAC signature#
WhatsApp onboarding events are signed instead, and this is where most integrations go wrong:
import { createHmac, timingSafeEqual } from "crypto";
app.post(
"/oncore/whatsapp-webhooks",
express.raw({ type: "application/json" }), // ← must be raw, not json
(req, res) => {
const secret = process.env.ONCORE_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"));
res.status(200).end();
},
);Three ways this fails:
- Parsing before verifying.
express.json()replaces the raw bytes. Re-serializing the parsed object produces different bytes — key order and whitespace differ — so the HMAC will never match. The raw body parser must come first. - Comparing with
===. It works, but leaks timing information. UsetimingSafeEqual. - Forgetting the
sha256=prefix. The header value includes it; the digest alone does not.
Processing the same event twice#
Model B is at-least-once with up to 5 retries. All retries of one event reuse the same X-OnCore-Delivery id — dedupe on it:
const seen = new Set<string>(); // use Redis or a table in production
const delivery = req.header("X-OnCore-Delivery");
if (delivery && seen.has(delivery)) return res.status(200).end();
if (delivery) seen.add(delivery);Telling event types apart on one endpoint#
Voice events carry call_id. Messaging events carry conversation_id, plus channel and customer_identifier. Branch on the presence of those fields rather than assuming a single shape.
Related#
- Webhooks overview — both delivery models in full.
- End-of-call report — the payload after a voice call.
- Tool calls to your server — the request shape when an assistant invokes your tool.