Saltar al contenido principal

Webhooks y streaming

Whet emite eventos del backend a través de dos canales:

  1. 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.
  2. Receivers de webhooks entrantes en whet-app (POST /api/webhooks/*) — usados por el backend interno y el scraper monitor opcional para empujar eventos a whet-app. Vienen firmados con HMAC; vos los verificás solo si estás hosteando whet-app contra 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_ROOT es 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

typeCuándodata lleva
scrape.completedUn scrape de un handle de X finalizó.operator_id, run_id
tweet.readySe ingestó un tweet nuevo asociado a un operator del workspace.post_id, operator_id
webpage.readyUna tracked webpage fue refetcheada y cambió.webpage_id, snapshot_id
decode.readyEl decode de un post terminó.post_id, decode_id
pattern.detectedEl decoder identificó un pattern nuevo o reincidente.pattern_id
riff.readyUn riff pasó a status=ready.riff_id, source_post_id
riff.refinedUn refinement de riff produjo un hijo.riff_id, parent_id
brief.readyUn brief multi-source pasó a status=ready.brief_id
brief.refinedUn refinement de brief produjo un hijo.brief_id, parent_id
operator.errorUn 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:

PathSourceAuth
POST /api/webhooks/backendBackend Bun → whet-appWEBHOOK_SIGNING_SECRET
POST /api/webhooks/scraper-monitorScraper monitor externo → whet-appPICKO_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

HeaderValor
Content-Typeapplication/json
X-Whet-Signaturet=<unix_seconds>,v1=<hmac_sha256_hex>
X-Whet-Event-Idevt_<uuid> — idempotency key, usar para dedupe
X-Whet-Event-TypeTipo del evento (espeja type en el body)
X-Whet-Delivery-Idwd_<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 (o X-Whet-Event-Id).
  • Status de respuesta:
    • 2xx → éxito, no se reintenta.
    • 429 o 5xx → 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:

IntentoDelay tras el anterior
260 s
35 min
430 min
52 h
66 h
724 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.