Webhooks y streaming
Whet emite eventos del backend a través de dos canales:
- SSE stream (
GET /api/stream) — recomendado para cualquier consumidor que pueda mantener una conexión HTTP abierta. Autenticado por Bearer; no requiere verificación de firma. - Receivers de webhooks entrantes en
whet-app(POST /api/webhooks/*) — usados por elbackendinterno y el scraper monitor opcional para empujar eventos awhet-app. Vienen firmados con HMAC; vos los verificás solo si estás hosteandowhet-appcontra un backend externo o un scraper monitor.
Todavía no hay una superficie pública del estilo "suscribí-tu-URL" — eso es roadmap.
Streaming (SSE)
GET /api/stream devuelve una conexión text/event-stream scopeada a la organización activa. Heartbeats cada 15s; reconectá con backoff si hay error.
curl -N "$WHET_BASE_URL_ROOT/api/stream?events=riff.ready,brief.ready" \
-H "Authorization: Bearer $WHET_TOKEN"
$WHET_BASE_URL_ROOTes el root del workbench (sin sufijo/api/agent/v1). El stream vive un nivel más arriba en/api/stream.
Cada línea data: es un envelope JSON:
{
"id": "evt_01HXAMPLE...",
"type": "riff.ready",
"created_at": "2026-05-17T12:34:56Z",
"data": { "riff_id": "rf_a1b2...", "pipeline_id": "pp_c3d4..." }
}
Filtrá los eventos que te importan con ?events= (separados por coma). Si lo omitís, se entregan todos los eventos de la org.
Tipos de evento
type | Cuándo | data lleva |
|---|---|---|
scrape.completed | Un scrape de un handle de X finalizó. | operator_id, run_id |
tweet.ready | Se ingestó un tweet nuevo asociado a un operator del workspace. | post_id, operator_id |
webpage.ready | Una tracked webpage fue refetcheada y cambió. | webpage_id, snapshot_id |
decode.ready | El decode de un post terminó. | post_id, decode_id |
pattern.detected | El decoder identificó un pattern nuevo o reincidente. | pattern_id |
riff.ready | Un riff pasó a status=ready. | riff_id, source_post_id |
riff.refined | Un refinement de riff produjo un hijo. | riff_id, parent_id |
brief.ready | Un brief multi-source pasó a status=ready. | brief_id |
brief.refined | Un refinement de brief produjo un hijo. | brief_id, parent_id |
operator.error | Un scrape o ingest falló (ej. credenciales muertas). | operator_id, error.code, error.message |
Receivers de webhooks entrantes (canal interno)
Estas dos rutas viven en whet-app y aceptan eventos firmados con HMAC desde servicios internos:
| Path | Source | Auth |
|---|---|---|
POST /api/webhooks/backend | Backend Bun → whet-app | WEBHOOK_SIGNING_SECRET |
POST /api/webhooks/scraper-monitor | Scraper monitor externo → whet-app | PICKO_SCRAPER_SIGNING_SECRET (devuelve 503 cuando no está seteado) |
No son parte del API público — existen porque whet-app es la superficie pública y el backend le habla por la misma red. Si estás cableando tu propio scraper monitor contra /api/webhooks/scraper-monitor, aplica el esquema de verificación de abajo.
Verificación HMAC
Los dos receivers verifican firmas con el mismo esquema:
Headers
| Header | Valor |
|---|---|
Content-Type | application/json |
X-Whet-Signature | t=<unix_seconds>,v1=<hmac_sha256_hex> |
X-Whet-Event-Id | evt_<uuid> — idempotency key, usar para dedupe |
X-Whet-Event-Type | Tipo del evento (espeja type en el body) |
X-Whet-Delivery-Id | wd_<uuid> — id del intento individual de entrega |
Payload canónico
v1:{timestamp}.POST.{path_with_query}.{sha256_hex(body)}
Algoritmo: HMAC-SHA256 con el secreto relevante. Ventana de timestamp: ±300s. Rechazá firmas fuera de la ventana sin procesar el body.
Envelope del body
{
"id": "evt_<uuid>",
"type": "riff.ready",
"created_at": "2026-05-17T12:34:56Z",
"data": { ... }
}
Ejemplo en Node
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))
);
}
Reglas operativas
- Idempotencia del receiver: la entrega es at-least-once. Dedupeá por
id(oX-Whet-Event-Id). - Status de respuesta:
2xx→ éxito, no se reintenta.429o5xx→ transitorio, se reintenta.- otros
3xx/4xx→ fatal, no se reintenta.
- Body de respuesta: capado a 8 KB para auditoría. Con
{ "ok": true }alcanza.
Política de retry (cuando se emite desde el backend)
Backoff escalonado, máximo 7 intentos, total ~4 días:
| Intento | Delay tras el anterior |
|---|---|
| 2 | 60 s |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
| 6 | 6 h |
| 7 | 24 h |
Tras el 7º intento, la entrega queda en failed. Hoy no hay API público de retry; la UI de queues del backend expone un botón de retry manual.
Elegir entre SSE y webhooks
| Querés… | Usá |
|---|---|
| Consumo en vivo de eventos in-process sobre un servicio long-running. | SSE (GET /api/stream). |
| Entrega desacoplada y durable a un endpoint externo que controlás end-to-end (y podés deployar el scraper monitor side). | Los receivers de webhooks entrantes, con las vars PICKO_SCRAPER_* cableadas. |
| Consumo externo, sin infra, con retry + dedup. | No soportado hoy — trackeable por el roadmap. |
Ver Quickstart para el flujo completo cliente → API → vuelta de eventos.