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:
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"));npm install express
node server.mjsExpose it to the internet#
OnCore cannot reach localhost. Tunnel it:
ngrok http 3000Copy 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:
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:
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);
});ONCORE_CLIENT_SERVER_SECRET=your_secret node server.mjsAcknowledge 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:
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:
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.logfor a real write, and make it idempotent oncall_idso 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 your2xx.
Next#
- End-of-call report — the full payload.
- Webhooks overview — both delivery models, including HMAC-signed WhatsApp events.
- Tool calls to your server — letting an assistant call your backend mid-conversation.