Docs
Sign inStart free
Documentation/Webhooks (Research completion · Monitor checks)

Webhooks (Research completion · Monitor checks)

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.

6 min readHumans & agentsopen as text

01Two services, optional, claim-check

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.

event · POST to your URL
{
  "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):

typeFiresMeaning
research.run.completedonce per jobThe job finished; re-fetch result_url for outputText, reportMarkdown, citations, and the dossier.
research.run.failedonce per jobThe job ended in a non-success terminal state (failed / cancelled / error). Re-fetch result_url to read the error.
monitor.baselineonce per monitorThe first check wrote the baseline. Always sent when a webhook is set — verify your integration on it.
monitor.changeper qualifying checkA 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.
monitor event · POST to your URL
{
  "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.

02Verify the signature

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):

HeaderValue
webhook-idThe delivery id (also the event's id, e.g. evt_…). Use it for dedupe and as part of the signed string.
webhook-timestampUnix time in seconds when the delivery was signed.
webhook-signaturev1,<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.

verify · Node (raw bytes)
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.

03Delivery, retries, dedupe

The delivery contract is short and strict:

  • Success = your endpoint returns an HTTP 2xx within a 10-second timeout. Acknowledge fast, then do slow work asynchronously — past 10s we abort and treat it as a failed attempt.
  • Delivery is at-least-once, so you MUST dedupe. The outbox holds at most one row per (job_id, event), but a delivered row can still be re-sent on a timeout or transient error — dedupe on data.job_id (Research) or data.event_id (Monitors), paired with type, and process each event exactly once.
  • On any non-2xx or timeout we retry with exponential backoff: up to 8 attempts spaced 0s, 30s, 2m, 10m, 30m, 2h, 6h, 12h (with jitter) from completion — about 24h total. After the 8th failed attempt the delivery is dead-lettered (marked failed_permanent).
  • If your endpoint is down for the whole window, nothing is lost: fetch result_url and read the report (or the check) directly. For a monitor, GET /v1/monitors/{id}/events/{event_id} also reports that delivery's state, attempts and last status code.

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.

04Related