Webhooks and streaming
Whet emits backend events through two channels:
- SSE stream (
GET /api/stream) — recommended for any consumer that can hold an HTTP connection open. Bearer-authenticated; no signature verification needed. - Inbound webhook receivers on
whet-app(POST /api/webhooks/*) — used by the internalbackendand the optional ingestion monitor to push events towhet-app. They are HMAC-signed; you verify them only if you're hostingwhet-appagainst 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_ROOTis the workbench root (no/api/agent/v1suffix). 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
type | When | data carries |
|---|---|---|
ingest.completed | An ingest run of a social handle finished. | operator_id, run_id |
post.ready | A new social post associated with a workspace operator was ingested. | post_id, operator_id |
webpage.ready | A tracked webpage was refetched and changed. | webpage_id, snapshot_id |
decode.ready | The decode of a post finished. | post_id, decode_id |
pattern.detected | The decoder identified a new or recurring pattern. | pattern_id |
riff.ready | A riff moved to status=ready. | riff_id, source_post_id |
riff.refined | A riff refinement produced a child. | riff_id, parent_id |
brief.ready | A multi-source brief moved to status=ready. | brief_id |
brief.refined | A brief refinement produced a child. | brief_id, parent_id |
operator.error | An 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:
| Path | Source | Auth |
|---|---|---|
POST /api/webhooks/backend | Bun backend → whet-app | WEBHOOK_SIGNING_SECRET |
POST /api/webhooks/scraper-monitor | External 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
| Header | Value |
|---|---|
Content-Type | application/json |
X-Whet-Signature | t=<unix_seconds>,v1=<hmac_sha256_hex> |
X-Whet-Event-Id | evt_<uuid> — idempotency key, use to dedupe |
X-Whet-Event-Type | Event type (mirrors type in the body) |
X-Whet-Delivery-Id | wd_<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(orX-Whet-Event-Id). - Response status:
2xx→ success, no retry.429or5xx→ 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:
| Attempt | Delay since previous |
|---|---|
| 2 | 60 s |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
| 6 | 6 h |
| 7 | 24 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.