Optional signed callbacks for the two services that produce results after the call returns: Research (one terminal event per job) and Monitors (one event per check that clears your threshold). Same HMAC-SHA256 claim-check envelope pointing at result_url, same account secret, same at-least-once delivery with up to 8 retries over ~24h. The one page where getting signature verification wrong fails silently.
Webhooks exist for the two services whose results arrive after the call: Research (POST /v1/research/run accepts webhook; one terminal event per job) and Monitors (POST /v1/monitors/create or PATCH /v1/monitors/{id} accepts webhook; one event per check that clears notify_threshold, plus the baseline). The URL must be https — anything else is rejected with a 400 before anything is created. Search, Content, and Answers are synchronous (one request, one response), so there is nothing to call back about; they have no webhook parameter at all.
Over MCP, Research never needs a webhook — the hosted MCP server autopolls the job to completion and hands back the report inline. monitor_create DOES accept webhook (a monitor outlives any agent session); otherwise the agent reads checks back with monitor_get.
Treat the event as a claim-check, not the result. STELQ POSTs a small signed signal the moment a job reaches a terminal state — it carries data.{job_id, status, result_url} and never the report body. The receiver reads result_url and re-fetches the finished job over the authenticated API. This is deliberate: the signal stays tiny and tamper-evident, and you always read the freshest result rather than a payload that could be stale or oversized.
{
"id": "evt_01JX7Z9C2QH4",
"type": "research.run.completed",
"api_version": "2026-06-01",
"occurred_at": "2026-06-14T10:32:04Z",
"attempt": 1,
"data": {
"job_id": "rsch_8K2mQ9vX3pLw",
"status": "completed",
"result_url": "https://api.stelq.com/v1/research/run/rsch_8K2mQ9vX3pLw"
}
}Research has exactly two terminal event types, and exactly one fires per job — never both, never a non-terminal one. Monitors have two as well: the baseline (once) and a change (per qualifying check):
| type | Fires | Meaning |
|---|---|---|
| research.run.completed | once per job | The job finished; re-fetch result_url for outputText, reportMarkdown, citations, and the dossier. |
| research.run.failed | once per job | The job ended in a non-success terminal state (failed / cancelled / error). Re-fetch result_url to read the error. |
| monitor.baseline | once per monitor | The first check wrote the baseline. Always sent when a webhook is set — verify your integration on it. |
| monitor.change | per qualifying check | A check found something at or above notify_threshold. data carries monitor_id, event_id, significance, headline, new_count; re-fetch result_url for summary_md + new_items. |
{
"id": "evt_01JX9A4M2KQ7",
"type": "monitor.change",
"api_version": "2026-06-01",
"occurred_at": "2026-08-28T14:07:12Z",
"attempt": 1,
"data": {
"monitor_id": "a01aede0-…",
"monitor_name": "Regulation E watch",
"event_id": "5c1b7e2a-…",
"significance": "notable",
"headline": "CFPB issues proposed rule expanding Reg E error-resolution to P2P fraud",
"new_count": 3,
"result_url": "https://api.stelq.com/v1/monitors/a01aede0-…/events/5c1b7e2a-…"
}
}Reading the API stays the source of truth. The webhook is layered on top of GET /v1/research/run/{id} and GET /v1/monitors/{id}/events — never a replacement. A dropped or rejected delivery is never data loss: the result stays available, so you can always fall back to fetching result_url.
This is the page where a mistake fails silently. A wrong signature check still returns 200 and still processes events — it just leaves your endpoint forging-open. Verify before you trust the body, and verify against the RAW request bytes, not re-serialized JSON. Re-encoding the payload (key reordering, whitespace, escaping) changes the bytes and breaks the HMAC even when your code looks correct.
Every delivery carries three lowercase headers (Standard Webhooks convention):
| Header | Value |
|---|---|
| webhook-id | The delivery id (also the event's id, e.g. evt_…). Use it for dedupe and as part of the signed string. |
| webhook-timestamp | Unix time in seconds when the delivery was signed. |
| webhook-signature | v1,<base64> — the HMAC. The header may carry space-separated values; a match on any one passes. |
The scheme, exactly: compute HMAC-SHA256, base64-encoded, over the string {webhook-id}.{webhook-timestamp}.{rawBody}. The key is your account signing secret (whsec_…, shown once in the console) with the whsec_ prefix stripped and the remaining characters base64-decoded to bytes. Constant-time compare the result against the value in webhook-signature (after the v1, prefix), and reject any delivery whose webhook-timestamp is older than ~5 minutes to defeat replays.
import { createHmac, timingSafeEqual } from "node:crypto";
// secret = your account signing secret, e.g. process.env.STELQ_WEBHOOK_SECRET ("whsec_…")
function verify(secret: string, headers: Headers, rawBody: string): boolean {
const id = headers.get("webhook-id") ?? "";
const ts = headers.get("webhook-timestamp") ?? "";
const header = headers.get("webhook-signature") ?? "";
// reject stale deliveries (~5 min window)
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;
// key = whsec_ prefix stripped, remainder base64-decoded
const key = secret.startsWith("whsec_")
? Buffer.from(secret.slice(6), "base64")
: Buffer.from(secret);
// HMAC-SHA256 (base64) over `{id}.{timestamp}.{rawBody}`
const expected = "v1," + createHmac("sha256", key)
.update(`${id}.${ts}.${rawBody}`)
.digest("base64");
// header may carry space-separated values; any constant-time match passes
return header.split(/\s+/).some(
(p) => p.length === expected.length &&
timingSafeEqual(Buffer.from(p), Buffer.from(expected)),
);
}Your signing secret is per-account — ONE whsec_… for Research and Monitors alike — and snapshotted when the webhook is registered (at Research submit; when a monitor's webhook is set). A delivery verifies against the secret that was live then, so rotating your account secret never breaks jobs in flight or monitors already running. PATCH a monitor's webhook to move it onto a new secret.
The delivery contract is short and strict:
Research completion detection is PULL-based, not an engine push: a cron sweep polls the research engine for your in-flight jobs and, when one reaches a terminal state, enqueues an outbox row (idempotent on job_id+event). Monitor events are enqueued by the check itself the moment it completes, onto the SAME outbox — same delivery loop, same retry ladder. Either way the engine never calls the gateway directly, which is exactly why reading the API remains the canonical path and the webhook is the optimization.
Outbound deliveries are SSRF-hardened. Targets must be https; the host is DNS-resolved and any private, loopback, link-local, CGNAT, or multicast address is blocked. That check runs at registration AND again immediately before every send (DNS-rebinding defense), and deliveries never follow redirects (redirect: "manual").
Idempotency on the receiver mirrors idempotency on submit: the same job_id should produce the same downstream effect whether it arrives once or three times. Make your handler idempotent and let the retries work for you.