LoguDocs
Open Logu

Webhooks

A webhook endpoint is a URL on your server that Logu POSTs to when something happens in a workspace: an ask is created or settles, an agent sends an event, a threshold alert fires. It's how a backend (not an agent waiting on /wait or the MCP tools) hears about everything agents send.

Webhook endpoints are the recommended path. Compared with an ask's callbackUrl (the legacy, per-ask callback, below):

  • One setup per workspace: no URL to pass on every ask.
  • Every event type: ask.created, ask.settled, event.created, metric.alert, not only a settled ask.
  • A signing secret of its own (whsec_…), independent of API keys: rotating or revoking an agent's API key never breaks verification, and the secret itself rotates with a 24-hour overlap.
  • A delivery log: every webhook delivery and each attempt (status code, duration, the start of the response), kept 30 days.
  • Test sends and redelivery from the app or the API.
  • Standard Webhooks signing: @logu/sdk, pylogu or any Standard Webhooks library verifies it.

Creating one#

In the app: Settings → Webhooks (Owners only). Add the URL, pick the event types, and copy the signing secret: it's shown once. From there you can send a test, read the delivery log, redeliver, turn the endpoint off and on, and rotate the secret.

Only Owners manage webhook endpoints: an endpoint receives everything agents send to the workspace, so choosing where it goes is an Owner's call. Deciders, Viewers and agents (API keys) get 403 forbidden. A workspace can have up to 10 endpoints.

Through the API (a signed-in Owner's session, with Logu-Workspace for a shared workspace):

Route
GET /v1/webhooks The workspace's endpoints (never their secrets)
POST /v1/webhooks Create one: { url, description?, eventTypes, enabled? }; returns it with secret, once
GET /v1/webhooks/:id One endpoint
PATCH /v1/webhooks/:id Change url, description, eventTypes or enabled
DELETE /v1/webhooks/:id Delete it and its delivery log
POST /v1/webhooks/:id/rotate-secret A new secret, returned once; the previous one keeps signing for 24 hours
POST /v1/webhooks/:id/test Send a webhook.test now (no retries); returns the webhook delivery with its attempt
GET /v1/webhooks/:id/deliveries The delivery log, newest first (limit, before)
GET /v1/webhooks/:id/deliveries/:deliveryId One webhook delivery: its payload and every attempt
POST /v1/webhooks/:id/deliveries/:deliveryId/redeliver Send it again, with the same webhook-id
curl -s https://api.logu.dev/v1/webhooks -H "Authorization: Bearer $LOGU_SESSION" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/logu","eventTypes":["ask.settled","event.created"]}'

The full contract (schemas and examples) is in the OpenAPI document: GET /openapi.json, GET /docs.

Event types and payloads#

Every webhook delivery's body is the same envelope (Standard Webhooks' recommended shape):

{ "type": "ask.settled", "timestamp": "2026-10-13T14:02:11.000Z", "data": { … } }

timestamp is when it happened; data depends on the type. The schemas are WebhookPayload and WebhookEventType in @logu/protocol (re-exported by @logu/sdk).

Type When data
ask.created An agent sent an ask { ask } (waiting)
ask.settled The ask was decided, timed out or withdrawn { ask } (with decision when decided)
event.created An agent sent an event { event }
metric.alert A metric crossed a threshold alert { metric, alert }
webhook.test A test send (no endpoint subscribes to it) { endpointId, message }

ask.created:

{
  "type": "ask.created",
  "timestamp": "2026-10-13T14:00:00.000Z",
  "data": {
    "ask": {
      "id": "ask_7Hq2…",
      "project": "infra",
      "channel": "deploys",
      "title": "Run the database migration in production?",
      "body": "Adds the refunded_at column. No downtime expected.",
      "icon": null,
      "url": "https://github.com/acme/shop/pull/42",
      "options": [
        { "id": "approve", "label": "Approve", "style": "primary" },
        { "id": "decline", "label": "Decline", "style": "danger" }
      ],
      "allowText": false,
      "status": "waiting",
      "decision": null,
      "defaultOption": "decline",
      "expiresAt": "2026-10-13T16:00:00.000Z",
      "createdAt": "2026-10-13T14:00:00.000Z",
      "metadata": null,
      "agent": { "id": "agt_3kP…", "name": "deploy-bot" },
      "attachments": []
    }
  }
}

ask.settled (the same ask, now decided):

{
  "type": "ask.settled",
  "timestamp": "2026-10-13T14:02:11.000Z",
  "data": {
    "ask": {
      "id": "ask_7Hq2…",
      "status": "decided",
      "decision": {
        "optionId": "approve",
        "text": null,
        "decidedVia": "app",
        "decidedAt": "2026-10-13T14:02:11.000Z",
        "decidedBy": { "id": "u9LmQ2…", "name": "Ana" }
      },
      "…": "every other field of the ask, as in ask.created"
    }
  }
}

decision.decidedVia says where it was decided (app, link, telegram, slack, whatsapp, rule, or timeout). Consumers must check it: timeout means nobody answered and the ask's default option was applied, which is never an approval, even if optionId is approve (agents can't set an approving default on new asks, but older asks may carry one). Approve only on a decision that is not timeout.

event.created:

{
  "type": "event.created",
  "timestamp": "2026-10-13T14:05:00.000Z",
  "data": {
    "event": {
      "id": "evt_Q81…",
      "project": "shop",
      "channel": null,
      "title": "Deploy finished",
      "body": null,
      "icon": "🚀",
      "url": null,
      "metadata": null,
      "agent": { "id": "agt_3kP…", "name": "deploy-bot" },
      "attachments": [],
      "createdAt": "2026-10-13T14:05:00.000Z"
    }
  }
}

metric.alert (metric is the value that crossed the threshold; alert is the threshold alert):

{
  "type": "metric.alert",
  "timestamp": "2026-10-13T14:10:00.000Z",
  "data": {
    "metric": {
      "id": "met_x2V…",
      "project": "shop",
      "metric": "error_rate",
      "label": "Error rate",
      "unit": "%",
      "value": 7.5,
      "at": "2026-10-13T14:10:00.000Z"
    },
    "alert": { "id": "alr_4Rt…", "condition": "above", "threshold": 5, "triggered": true }
  }
}

webhook.test:

{
  "type": "webhook.test",
  "timestamp": "2026-10-13T14:15:00.000Z",
  "data": {
    "endpointId": "whk_8Yb…",
    "message": "A test from Logu: this webhook endpoint is set up."
  }
}

Attachment urls in a payload are signed links that work for about an hour; read the ask or event again (GET /v1/asks/:id, with an API key) for fresh ones.

Verifying a webhook delivery#

Each webhook delivery carries three headers (Standard Webhooks):

Header
webhook-id The webhook delivery's id (whd_…): the same on every retry and redelivery
webhook-timestamp Unix seconds when this attempt was signed
webhook-signature v1,<base64>: HMAC-SHA256 of <webhook-id>.<webhook-timestamp>.<body>, keyed with the base64-decoded bytes of the secret after whsec_. During a rotation's overlap, two signatures separated by a space

Verify against the raw body, exactly as received: parsing the JSON and serializing it again changes the bytes and the signature won't match. Reject timestamps more than 5 minutes away (replays). Both helpers do both.

TypeScript (@logu/sdk, or @logu/protocol), with Hono:

import { verifyStandardWebhook, type WebhookPayload } from "@logu/sdk";

app.post("/logu", async (c) => {
  const body = await c.req.text(); // raw body
  const ok = await verifyStandardWebhook({
    secret: c.env.LOGU_WEBHOOK_SECRET, // whsec_…
    body,
    headers: c.req.raw.headers, // a Headers, or a plain object (Express's req.headers)
  });
  if (!ok) return c.text("Invalid signature", 401);

  const payload = JSON.parse(body) as WebhookPayload;
  const deliveryId = c.req.header("webhook-id")!;
  if (await alreadyHandled(deliveryId)) return c.body(null, 204);
  await queue.send({ deliveryId, payload }); // do the work later; answer fast
  return c.body(null, 204);
});

With Express, read the raw body (express.raw({ type: "application/json" })) and pass body: req.body.toString("utf8") and headers: req.headers.

Python (pylogu), with FastAPI:

from pylogu import verify_standard_webhook

@app.post("/logu")
async def logu_webhook(request: Request) -> Response:
    body = await request.body()  # raw bytes
    if not verify_standard_webhook(
        secret=os.environ["LOGU_WEBHOOK_SECRET"], body=body, headers=request.headers
    ):
        return Response(status_code=401)
    payload = json.loads(body)
    delivery_id = request.headers["webhook-id"]
    ...
    return Response(status_code=204)

Any Standard Webhooks library works too (standardwebhooks on npm and PyPI, and libraries for Go, Ruby, Java, PHP, Rust…): pass it the whsec_… secret.

Answering and retries#

  • Answer 2xx fast, then do the work. Logu waits 15 seconds; a slower answer counts as a failed attempt.
  • Dedupe on webhook-id. A webhook delivery can arrive more than once (a retry after your 2xx was lost, a redelivery): handle each webhook-id once.
  • A 2xx is success. Anything else, a timeout, a connection error or a redirect (redirects are not followed) is a failed attempt, retried on this schedule: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: 8 attempts over about 28 hours. After the last, the webhook delivery is failed in the delivery log; redeliver it when your server is back.
  • Webhook deliveries are independent, so they can arrive out of order. Use the payload's timestamp, or the ask's status, rather than arrival order (an ask.settled can arrive before its ask.created after a retry).

Delivery log#

GET /v1/webhooks/:id/deliveries lists an endpoint's webhook deliveries, newest first: type, status (pending while attempts remain, then succeeded or failed), attemptCount, lastStatusCode, lastAttemptAt and nextAttemptAt. GET …/deliveries/:deliveryId adds the exact payload sent and every attempt: time, status code, duration, the first 1 KB of the response, or the error ("Timed out after 15 s"). No secrets or request headers are stored.

The delivery log keeps 30 days. Redeliver (POST …/deliveries/:deliveryId/redeliver) runs a settled webhook delivery's attempts again with the same webhook-id; a webhook delivery still pending, or an endpoint that is turned off, is a 409 conflict.

Turned off after 3 days of failures#

An endpoint whose every attempt has failed for 3 days is turned off (enabled: false, disabledReason: "failing"). Logu records webhook.disabled in the activity log and sends an event (project logu, channel webhooks), so the workspace's members hear about it through their usual delivery. Turn it back on in the app or with PATCH /v1/webhooks/:id { "enabled": true }, which also clears the failure streak. While it is failing, failingSince says since when.

Rotating the signing secret#

POST /v1/webhooks/:id/rotate-secret (or Rotate signing secret in the app) returns a new secret, once. For the next 24 hours the previous secret keeps signing too: webhook-signature carries both signatures (v1,<new> v1,<old>), and previousSecretExpiresAt says when the old one stops. Deploy the new secret to your server at your own pace within that window; either secret verifies.

Secrets are stored encrypted, never logged, and never in the activity log.

URL rules#

  • https: only.
  • A public host: no localhost, *.localhost, *.local, *.internal, bare host names, and no IP addresses in private, loopback, link-local, CGNAT or unique-local ranges. Checked when the endpoint is saved and again before each attempt.
  • To receive webhooks on your laptop, use a tunnel with a public https URL.

Activity#

webhook.created, webhook.updated, webhook.deleted, webhook.secret_rotated, webhook.redelivered (by an Owner) and webhook.disabled (by Logu) are in the workspace's activity log, with the URL and event types; never the secret.

Legacy: an ask's callbackUrl#

An ask can still carry a callbackUrl (callback_url in Python and MCP): an https: URL that gets one signed POST when that ask settles. It works as before:

  • Body: { "type": "ask.settled", "ask": { … } } (not the envelope above).
  • Header logu-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">, keyed with the SHA-256 hex digest of the API key that created the ask. Verify with verifyWebhook({ apiKey, body, signature }) (@logu/sdk) or verify_webhook(api_key=…, body=…, signature=…) (pylogu).
  • New: a webhook-id: cbk_<ask id> header, so receivers can dedupe its retries.

Prefer a webhook endpoint for anything new. The callback's signature depends on the agent's API key, so rotating or revoking that API key breaks verification of every ask still in flight; it covers only settled asks; and it has no delivery log, test send or redelivery.