Guides

Webhooks

Instead of polling a run until it finishes, register a webhook endpoint and LoopLlama will POST a signed JSON event to you the moment a run changes state.

Overview#

A webhook endpoint is a URL you own plus a list of event types you care about. You can scope an endpoint to a single workflow or receive events for all of them. Manage endpoints from Dashboard → Webhooks or the Webhooks API.

When you create an endpoint you receive a signing secret (whsec_…) exactly once. Every delivery is signed with it so you can reject forged requests.

Event types#

run.startedeventoptional
A run left the queue and its first agent began executing.
run.completedeventoptional
A run finished successfully. data.run.output holds the final agent's output.
run.failedeventoptional
A run failed — an agent errored, the run was cancelled, or the process died. data.run.error explains why.
run.waiting_inputeventoptional
A run paused for a human. data.run.pending describes the question or the action awaiting approval; answer it via Respond to a paused run.
webhook.testeventoptional
Sent only when you click Send test in the dashboard. Useful for confirming connectivity and signature verification.

Payload#

Every delivery is a JSON body with a stable envelope. For run.* events, data.run has the same fields as the run object (without steps) plus workflow_name.

json
{
  "id": "evt_cm1x9q2…",
  "type": "run.completed",
  "created_at": "2026-09-02T14:03:11.412Z",
  "data": {
    "run": {
      "id": "cm1x9pz…",
      "workflow_id": "cm1x8aa…",
      "workflow_name": "Brief writer",
      "status": "completed",
      "input": "Draft a 200-word brief on a new feature-flag system.",
      "output": "# Feature flags: a brief\n…",
      "error": null,
      "trigger": "api",
      "total_steps": 2,
      "tokens_in": 1840,
      "tokens_out": 612,
      "started_at": "2026-09-02T14:02:58.001Z",
      "finished_at": "2026-09-02T14:03:11.380Z",
      "created_at": "2026-09-02T14:02:57.900Z",
      "pending": null
    }
  }
}

Headers

LoopLlama-Eventheaderoptional
The event type, e.g. run.completed.
LoopLlama-Deliveryheaderoptional
Unique id for this delivery. Retries of the same event reuse it — use it to deduplicate.
LoopLlama-Signatureheaderoptional
t=<unix seconds>,v1=<hex HMAC-SHA256>. See verification below.
User-Agentheaderoptional
LoopLlama-Webhooks/1.0

Verifying signatures#

The signature is an HMAC-SHA256 over the string `${t}.${rawBody}` using your endpoint secret, hex-encoded. Verify it against the raw request body — re-serializing parsed JSON changes whitespace and breaks the check. Reject timestamps more than five minutes old to block replays.

Node (with the SDK)
import { constructWebhookEvent } from "@loopllama/sdk";

const event = constructWebhookEvent(
  rawBody,                              // string, exactly as received
  req.headers["loopllama-signature"],   // "t=…,v1=…"
  process.env.LOOPLLAMA_WEBHOOK_SECRET!,
); // throws LoopLlamaError(400) if invalid
Node (manual)
import { createHmac, timingSafeEqual } from "node:crypto";

function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(",").map((kv) => kv.split("=")));
  const t = Number(parts.t);
  if (!parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
  return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parts.v1, "hex"));
}
Python (manual)
import hmac, hashlib, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(kv.split("=", 1) for kv in header.split(","))
    t = int(parts["t"])
    if abs(time.time() - t) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Responding#

Return any 2xx status within 10 seconds. Do the real work after responding — queue it, or respond first and process asynchronously. Redirects are not followed.

Retries & auto-disable#

  • A non-2xx response, a timeout, or a network error is retried with the same LoopLlama-Delivery id after 30 s, 2 min, and 10 min (4 attempts total).
  • After 10 consecutive events fail all of their attempts, the endpoint is disabled automatically. Re-enable it from the dashboard once the receiver is fixed; the failure counter resets.
  • Every attempt is recorded — the dashboard shows status code, latency, and the response body (first 1 KB) so you can debug your receiver, and lets you redeliver any event by hand.
Idempotency
Treat LoopLlama-Delivery (or the event id) as an idempotency key. Retries and manual redeliveries carry the same id, so a receiver that stores processed ids will never double-handle an event.

Local development#

In production, endpoint URLs must be https:// and publicly reachable. To receive events on your laptop, expose a local port with a tunnel (ngrok, Cloudflare Tunnel, or similar) and register the tunnel URL. A self-hosted LoopLlama running in development mode also accepts http://localhost URLs.