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,pyloguor 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 eachwebhook-idonce. - 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
failedin 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'sstatus, rather than arrival order (anask.settledcan arrive before itsask.createdafter 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 withverifyWebhook({ apiKey, body, signature })(@logu/sdk) orverify_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.