Skip to main content

Webhooks and streaming

Whet emits backend events through two channels:

  1. SSE stream (GET /api/stream) — recommended for any consumer that can hold an HTTP connection open. Bearer-authenticated; no signature verification needed.
  2. Inbound webhook receivers on whet-app (POST /api/webhooks/*) — used by the internal backend and the optional ingestion monitor to push events to whet-app. They are HMAC-signed; you verify them only if you're hosting whet-app against an external backend or ingestion monitor.

There is no public "subscribe-your-URL" surface yet — that's roadmap.

Streaming (SSE)

GET /api/stream returns a text/event-stream connection scoped to the active organization. Heartbeats every 15s; reconnect with backoff on error.

curl -N "$WHET_BASE_URL_ROOT/api/stream?events=riff.ready,brief.ready" \
-H "Authorization: Bearer $WHET_TOKEN"

$WHET_BASE_URL_ROOT is the workbench root (no /api/agent/v1 suffix). The stream lives one level up at /api/stream.

Each data: line is a JSON envelope:

{
"id": "evt_01HXAMPLE...",
"type": "riff.ready",
"created_at": "2026-05-17T12:34:56Z",
"data": { "riff_id": "rf_a1b2...", "pipeline_id": "pp_c3d4..." }
}

Filter the events you care about with ?events= (comma-separated). When omitted, every event for the org is delivered.

Event types

typeWhendata carries
ingest.completedAn ingest run of a social handle finished.operator_id, run_id
post.readyA new social post associated with a workspace operator was ingested.post_id, operator_id
webpage.readyA tracked webpage was refetched and changed.webpage_id, snapshot_id
decode.readyThe decode of a post finished.post_id, decode_id
pattern.detectedThe decoder identified a new or recurring pattern.pattern_id
riff.readyA riff moved to status=ready.riff_id, source_post_id
riff.refinedA riff refinement produced a child.riff_id, parent_id
brief.readyA multi-source brief moved to status=ready.brief_id
brief.refinedA brief refinement produced a child.brief_id, parent_id
operator.errorAn ingest run failed (e.g. dead credentials).operator_id, error.code, error.message

Inbound webhook receivers (internal channel)

These two routes live on whet-app and accept HMAC-signed events from internal services:

PathSourceAuth
POST /api/webhooks/backendBun backend → whet-appWEBHOOK_SIGNING_SECRET
POST /api/webhooks/scraper-monitorExternal ingestion monitor → whet-app (route name pending the U10c rename)PICKO_SCRAPER_SIGNING_SECRET (returns 503 when unset)

They are not part of the public API — they exist because whet-app is the public surface and the backend talks to it over the same network. If you're plumbing your own ingestion monitor against /api/webhooks/scraper-monitor, the verification scheme below applies.

HMAC verification

Both receivers verify signatures with the same scheme:

Headers

HeaderValue
Content-Typeapplication/json
X-Whet-Signaturet=<unix_seconds>,v1=<hmac_sha256_hex>
X-Whet-Event-Idevt_<uuid> — idempotency key, use to dedupe
X-Whet-Event-TypeEvent type (mirrors type in the body)
X-Whet-Delivery-Idwd_<uuid> — individual delivery attempt id

Canonical payload

v1:{timestamp}.POST.{path_with_query}.{sha256_hex(body)}

Algorithm: HMAC-SHA256 with the relevant secret. Timestamp window: ±300s. Reject signatures outside the window without processing the body.

Body envelope

{
"id": "evt_<uuid>",
"type": "riff.ready",
"created_at": "2026-05-17T12:34:56Z",
"data": { ... }
}

Node example

import { createHmac, createHash, timingSafeEqual } from "node:crypto";

const MAX_SKEW_SECONDS = 300;

export function verifyWhetWebhook({
rawBody,
signatureHeader,
signingSecret,
path,
}: {
rawBody: Buffer;
signatureHeader: string;
signingSecret: string;
path: string;
}): boolean {
const parts = Object.fromEntries(
signatureHeader.split(",").map((kv) => kv.split("=", 2) as [string, string]),
);
const timestamp = Number(parts.t);
const provided = parts.v1;
if (!Number.isFinite(timestamp) || !provided) return false;
if (Math.abs(Date.now() / 1000 - timestamp) > MAX_SKEW_SECONDS) return false;

const bodyHash = createHash("sha256").update(rawBody).digest("hex");
const canonical = `v1:${timestamp}.POST.${path}.${bodyHash}`;
const expected = createHmac("sha256", signingSecret).update(canonical).digest("hex");

return (
expected.length === provided.length &&
timingSafeEqual(Buffer.from(expected), Buffer.from(provided))
);
}

Operational rules

  • Receiver idempotency: delivery is at-least-once. Dedupe by id (or X-Whet-Event-Id).
  • Response status:
    • 2xx → success, no retry.
    • 429 or 5xx → transient, retry.
    • other 3xx/4xx → fatal, no retry.
  • Response body: capped at 8 KB for audit. { "ok": true } is enough.

Retry policy (when emitted from the backend)

Staggered backoff, max 7 attempts, total ~4 days:

AttemptDelay since previous
260 s
35 min
430 min
52 h
66 h
724 h

After the 7th attempt the delivery is left in failed. There is no public retry API today; the backend's queue UI exposes a manual retry button.

Choosing between SSE and webhooks

You want…Use
Live in-process consumption of events on a long-running service.SSE (GET /api/stream).
Decoupled, durable delivery to an external endpoint you control end-to-end (and you can deploy the ingestion monitor side).The inbound webhook receivers, with PICKO_SCRAPER_* vars wired up.
External, no-infra consumption with retry + dedup.Not supported today — track the roadmap.

See Quickstart for the full client → API → events return flow.