Skip to content

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.

Log first, verify second
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:

Terminal
ngrok http 3000
# then set serverUrl to the https:// forwarding address

Check 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.

Respond first, process after
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:

Verify against the RAW body
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:

  1. 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.
  2. Comparing with ===. It works, but leaks timing information. Use timingSafeEqual.
  3. 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:

Dedupe on delivery id
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.