Skip to content

Tutorials

Receive call results on your server

Build a webhook endpoint that verifies, acknowledges, and stores every call OnCore completes — end to end, in about fifteen minutes.

By the end of this you will have a running endpoint that receives an end-of-call report for every completed call, verifies it came from OnCore, and stores the transcript and summary.

This is the integration most teams need first. Polling Calls to find out what happened is slower, costs more requests, and carries less data.

You will need: Node.js 20+, your API key, and your client server secret — both on the API Keys page of your dashboard.

Start a server that logs everything#

Before verifying anything, prove the request arrives. Create server.mjs:

server.mjs
import express from "express";

const app = express();

app.post("/oncore/webhooks", express.json(), (req, res) => {
  console.log("[webhook] received", {
    type: req.body?.type,
    hasSecret: Boolean(req.header("x-sadie-core-secret")),
  });
  res.status(200).end();
});

app.listen(3000, () => console.log("listening on :3000"));
Terminal
npm install express
node server.mjs

Expose it to the internet#

OnCore cannot reach localhost. Tunnel it:

Terminal
ngrok http 3000

Copy the https:// forwarding address. Your webhook URL is that address plus /oncore/webhooks.

Point your assistant at it#

Set serverUrl on the assistant. This overrides the tenant-level URL for this assistant only, which is exactly what you want while developing:

Terminal
curl -X PATCH https://core-api.heysadie.ai/assistants/ASSISTANT_ID \
  -H "Authorization: ApiKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "serverUrl": "https://YOUR-TUNNEL.ngrok.app/oncore/webhooks" }'

Place a call and watch#

Call the number attached to the assistant, speak briefly, then hang up. Within moments your terminal should print a line with type set. If nothing appears, work through Webhooks not arriving.

Verify the secret#

Now that delivery works, reject anything that is not from OnCore. The secret arrives verbatim in a header:

server.mjs
app.post("/oncore/webhooks", express.json(), (req, res) => {
  if (req.header("x-sadie-core-secret") !== process.env.ONCORE_CLIENT_SERVER_SECRET) {
    console.warn("[webhook] rejected: bad secret");
    return res.status(401).end();
  }

  res.status(200).end();
  void handleEvent(req.body);
});
Terminal
ONCORE_CLIENT_SERVER_SECRET=your_secret node server.mjs

Acknowledge before you process

Voice and messaging deliveries are not retried. If your handler throws, or takes long enough to time out, that call's report is gone. Always res.status(200) first and do the work after.

Store what matters#

The report carries the transcript, a summary, a recording URL and any structured data the assistant captured:

server.mjs
async function handleEvent(event) {
  if (event?.type !== "end-of-call-report") return;

  const record = {
    callId: event.call_id,
    endedAt: new Date().toISOString(),
    summary: event.summary ?? null,
    transcript: event.transcript ?? null,
    recordingUrl: event.recording_url ?? null,
  };

  console.log("[call] storing", record.callId);
  // await db.calls.insert(record);
}

TODO — verify

Field names on the report payload are written here from the end-of-call report reference. Confirm each against a real delivery before relying on them in production — log the whole event object once and read it.

Handle both voice and messaging#

If the same endpoint serves both, branch on which identifier is present rather than assuming one shape:

server.mjs
function channelOf(event) {
  if (event.call_id) return "voice";
  if (event.conversation_id) return `messaging:${event.channel}`;
  return "unknown";
}

:::

What you have#

A verified, non-blocking webhook receiver that captures every completed call. Two things to add before production:

  • Persistence. Swap the console.log for a real write, and make it idempotent on call_id so a duplicate cannot double-insert.
  • A queue. void handleEvent(...) is fine for a tutorial. In production, push onto a queue and process out of band so a slow database never threatens your 2xx.

Next#