Skip to content

Webhooks

Receive webhooks on localhost

Tunnel OnCore's webhook deliveries to a server running on your own machine, read the raw request, and replay it without placing another call.

Your handler runs on http://localhost:3000; OnCore delivers over the public internet and cannot reach it. A tunnel closes that gap: it gives your machine a public HTTPS address and forwards every request straight to your local port, which also puts you close enough to read each delivery byte for byte.

Overview#

You will publish your local port behind a public HTTPS URL, set that URL as one assistant's serverUrl, and place a call to prove the round trip. Reading the raw request, replaying it, and putting the assistant back afterwards all hang off that loop.

A delivery does not change because it travelled through a tunnel. It is the same POST, the same JSON body, and the same headers described in the webhooks overview. The handler you get working this way is therefore the handler you deploy.

Prerequisites#

  • Something listening on a local port. Any HTTP server that answers POST with a 2xx will do; Receive call results on your server builds one from scratch if you have none.
  • An API key, to change the assistant's configuration. Authentication covers how keys are sent.
  • Your client server secret, from the API Keys page. This is the inbound credential: every delivery carries it and your handler compares against it. It is not the key you authenticate API calls with.
  • An assistant you can repoint, and a way to call it: a phone number attached to it, or browser calling from a web page. Repointing sends that assistant's live deliveries to your machine, so use one you are testing with, and expect to collide with any teammate tunnelling the same assistant.

Tunnel a delivery to your machine#

Start your receiver#

Run whatever server you are developing, and note two things: the port it binds, and the path it answers POST on.

Terminal
node server.mjs
# listening on :3000

Everything below assumes port 3000 and the path /oncore/webhooks. Substitute yours throughout.

Open a public tunnel to that port#

A tunnel runs on your machine, dials out to a public host, and forwards requests back down that connection to your port; nothing has to be opened on your firewall or router.

Terminal
ngrok http 3000

It prints a forwarding line like https://a1b2c3d4.ngrok.app -> http://localhost:3000. That https:// address is now your public base URL.

Any tunnel works. Use whichever your team already has. Each has its own one-time setup (an account and an auth token for ngrok, a login for Cloudflare Tunnel, Funnel enabled in your tailnet policy for Tailscale), so follow the vendor's own getting-started page before the command below will run:

ToolCommand
ngrokngrok http 3000
Cloudflare Tunnelcloudflared tunnel --url http://localhost:3000
localtunnelnpx localtunnel --port 3000
Tailscale Funneltailscale funnel 3000

Free tunnel hostnames change on restart

Most tunnels mint a new hostname every time the process restarts, and the assistant keeps pointing at the dead one. Either re-run the next step after each restart, or reserve a stable domain with your tunnel provider.

Point one assistant at the tunnel#

serverUrl is a per-assistant override: set it, and this assistant's deliveries go to your machine while every other assistant keeps using the default server URL for your account.

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

Include the path your handler listens on: the tunnel hostname alone delivers to /, which most servers answer with a 404. The response body is the updated assistant, serverUrl included; check that field in the response rather than assuming the write landed. Point webhooks at your server covers the field, including how to clear it.

Webhook URL requirements

serverUrl must be a valid https:// URL no longer than 1,024 characters. Its hostname must resolve in public DNS and must not resolve to a private, loopback, or link-local address. The PATCH validates the URL and DNS result but does not make a test webhook request. A tunnel URL is valid when it meets those rules; test delivery yourself before placing a real call.

Trigger a delivery#

Call the number attached to that assistant, speak briefly, then hang up. Your terminal should log an assistant request as the call starts and an end-of-call report once it finishes, plus a request for every tool the assistant invokes mid-call. The end-of-call report needs a call that actually connected; an unanswered or failed call may not produce one.

Answering 2xx with an empty body is safe: the call proceeds regardless of what you reply. The assistant-request response is the one that can optionally carry overrides for the call that is starting; its own page documents those fields.

Confirm verification still passes#

Log the delivery before you verify it, so a rejection is distinguishable from a no-show:

server.mjs
app.post("/oncore/webhooks", express.json(), (req, res) => {
  console.log("[webhook] hit", {
    type: req.body?.type,
    secret: req.header("x-sadie-core-secret") ? "present" : "MISSING",
  });
  // …verify, then respond
});

A tunnel forwards the request to your port with OnCore's headers and body intact: it adds its own forwarding headers, and may rewrite Host, but it does not touch the shared secret header or the body bytes. The signature on a signed delivery is computed over the raw body, not the host or the URL, so it verifies the same at your tunnel as at your production domain.

Verification failing here would therefore fail in production too. Webhooks overview has the check for each delivery model (including which events use which), and the three ways signature checks usually break are in Webhooks not arriving.

Read the raw delivery#

The tunnel sees the request before your framework touches it, so an inspector there shows what OnCore actually sent rather than what your parser made of it. ngrok runs one at http://127.0.0.1:4040, listing the method, every header, and the body of each forwarded request; check your own tunnel's documentation for the equivalent.

Capturing the request yourself works with any tunnel and leaves you a file to replay. This listens on the same port as your handler, so run one or the other, not both:

Terminal
npm install express
node capture.mjs
capture.mjs
import { writeFileSync } from "node:fs";
import express from "express";

const app = express();

// Raw body, not express.json(): a signature can only be checked against the
// exact bytes, and re-serialising the parsed object changes them.
app.post("/oncore/webhooks", express.raw({ type: "*/*" }), (req, res) => {
  if (!Buffer.isBuffer(req.body)) return res.status(200).end();

  const stamp = Date.now();
  writeFileSync(`delivery-${stamp}.headers.json`, JSON.stringify(req.headers, null, 2));
  writeFileSync(`delivery-${stamp}.body.json`, req.body);
  console.log("[webhook] captured", stamp, req.body.length, "bytes");
  res.status(200).end();
});

app.listen(3000, () => console.log("listening on :3000"));

The field tables on each event page tell you what the body should contain; end-of-call report is the one most integrations start with.

A captured delivery is a secret

The headers file contains your client server secret in plaintext, and the body contains the transcript of a real call. Keep both out of version control and delete them when you are finished.

Replay a delivery instead of placing another call#

Placing a call for every edit is a slow loop, and OnCore's public API exposes no endpoint that re-sends a past delivery. Replay is therefore something you drive locally, in one of two ways:

  1. Your tunnel's own replay, if it has one: ngrok's inspector re-sends any captured request to your port with the same method, headers, and body.
  2. curl straight at your local port, bypassing the tunnel entirely. This is the fastest loop and it works with the tunnel switched off.

For a delivery carrying the plaintext secret (voice, messaging, and tool events), the secret header is the only one your handler cares about:

Terminal
curl -X POST http://localhost:3000/oncore/webhooks \
  -H "Content-Type: application/json" \
  -H "x-sadie-core-secret: YOUR_CLIENT_SERVER_SECRET" \
  --data-binary @delivery-1735689600000.body.json

For a signed WhatsApp event, pass the captured signature and delivery headers through unchanged, and send the original bytes unedited; the signature covers exactly those bytes, so reformatting the JSON, even only its whitespace, invalidates it:

Terminal
curl -X POST http://localhost:3000/oncore/whatsapp-webhooks \
  -H "Content-Type: application/json" \
  -H "X-OnCore-Signature: sha256=CAPTURED_SIGNATURE_HEX" \
  -H "X-OnCore-Delivery: 0195f1e2-0000-0000-0000-000000000000" \
  --data-binary @delivery-1735689600000.body.json

Signed events are the retried ones, so a handler for them should be deduplicating on X-OnCore-Delivery, and a replay reuses that id, which is exactly what such a handler is built to ignore. Clear the store between replays, or you will conclude your handler is broken when it is working.

When nothing arrives#

Rule out the local-only causes first, then work through Webhooks not arriving for the rest.

CheckWhat to look for
The tunnel is still up, on the same hostnameA restart usually mints a new URL while the assistant still points at the old one. Re-read the assistant with GET https://core-api.heysadie.ai/assistants/{id} and compare serverUrl against what the tunnel is printing now.
The URL carries your handler's pathThe hostname alone delivers to /. Confirm the stored serverUrl ends in /oncore/webhooks, or whatever your path is.
The tunnel forwards to the port you are onPOST a dummy body to http://localhost:3000/oncore/webhooks, then the same to your tunnel URL. Whichever fails tells you which half is broken.
You repointed the assistant that took the callserverUrl is per-assistant. A call to a number owned by a different assistant delivers wherever that assistant points.
Your handler answered in timeVoice, messaging, and tool deliveries are not retried. A breakpoint paused at the top of the handler can outlast the delivery, and nothing comes back to try again.

No published delivery timeout

Voice and messaging webhooks have no public response-time budget. Do not hold the response open for a debugger, database transaction, or downstream API call. Verify the request, persist or enqueue it, return 2xx, and do the work asynchronously.

Put the assistant back#

An assistant left pointing at a tunnel that is no longer running is worse than one never repointed: voice and messaging deliveries are not retried, so those webhooks are never seen. Clear the override when you finish, which returns the assistant to the default server URL for your account:

Terminal
curl -X PATCH https://core-api.heysadie.ai/assistants/0195f1e2-0000-0000-0000-000000000000 \
  -H "Authorization: ApiKey YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "serverUrl": null }'

The calls themselves are not lost with the webhooks. Every call you placed while testing (and every one that went to a dead tunnel) is still readable from the calls API, transcript and summary included, so you can backfill whatever your handler missed.

Next steps#