Use managed event webhooks for integrations
A managed endpoint subscribes to immutable workspace events. Lizzy stores an event and a delivery record, signs the exact bytes it sends, retries failures, and exposes delivery history. Endpoint resources are isolated by workspace and token mode.
Managed endpoint
Full event envelope, per-endpoint secret, fixed retries, attempt history, and redelivery.
/v1/webhook_endpointsEvents API
List the same event stream with an events:read token and cursor pagination.
/v1/eventsChat callback
One small reply payload, one attempt, and no manageable per-workspace signing secret.
POST /v1/chat webhook field/v1/event_typesPublic catalog of event names and domains. It does not require authentication.
Create one endpoint per mode and concern
Endpoint creation requires webhooks:write. The caller’s token determines whether the endpoint receives live or test events. Live-mode URLs must use HTTPS; test mode also permits HTTP on localhost and 127.0.0.1.
enabled_events is a non-empty list. Use an exact event, * for all events, or a trailing wildcard such as distill.run.*. Start narrowly so a new unrelated event type cannot surprise the receiver.
request_body='{"url":"https://example.com/lizzy/events","description":"Distill run lifecycle","enabled_events":["distill.run.*"],"api_version":"2026-07-01"}'
curl --fail-with-body --silent --show-error --max-time 20 \
-X POST "https://lizzy.albinilabs.com/v1/webhook_endpoints" \
-H "Authorization: Bearer $LIZZY_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: webhook-endpoint-example-001" \
--data-binary "$request_body"
{
"id": "wh_01EXAMPLE",
"object": "webhook_endpoint",
"secret": "whsec_<shown-to-the-authorized-caller>",
"url": "https://example.com/lizzy/events",
"description": "Distill run lifecycle",
"enabled_events": ["distill.run.*"],
"api_version": "2026-07-01",
"status": "enabled",
"livemode": false,
"previous_secret_expires_at": null,
"delivery_stats": {
"success_rate_24h": null,
"pending": 0,
"dead_lettered": 0
}
}
Verify before acknowledging a delivery
Capture the exact request bytes, verify the timestamped HMAC with the endpoint secret, reject stale or invalid signatures, and only then parse JSON. Enqueue the event usingLizzy-Event-Id as a deduplication key before returning a 2xx response.
Managed deliveries carry a dated event envelope
The body describes the event, not an HTTP attempt. One event can create deliveries to several matching endpoints; the event ID remains the same while each endpoint gets its own delivery ID.
{
"id": "evt_01EXAMPLE",
"object": "event",
"type": "distill.run.succeeded",
"created_at": "2026-08-15T17:30:00Z",
"workspace_id": "ws_example",
"livemode": false,
"api_version": "2026-07-01",
"data": {
"object": {
"id": "run_01EXAMPLE",
"object": "distill_run",
"status": "succeeded"
}
}
}
application/jsonLizzy-Webhooks/1.0t=<unix>,v1=<hex HMAC-SHA256>; more than one v1 can appear during rotation.Verify the exact raw body before parsing JSON
Split Lizzy-Signature into one timestamp and one or morev1 digests. Reject timestamps more than 300 seconds from the receiver clock. Compute HMAC-SHA256 over <timestamp>.<raw body bytes> with the endpoint secret, then compare digests in constant time.
import hashlib
import hmac
import time
def verify_lizzy(raw_body: bytes, signature: str, secret: str, tolerance: int = 300) -> bool:
pairs = [part.split("=", 1) for part in signature.split(",") if "=" in part]
try:
timestamp = int(next(value for key, value in pairs if key == "t"))
except (StopIteration, ValueError):
return False
if abs(time.time() - timestamp) > tolerance:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
candidates = [value for key, value in pairs if key == "v1"]
return any(hmac.compare_digest(candidate, expected) for candidate in candidates)
# raw_body must come directly from the HTTP request, before json.loads(...).
# if not verify_lizzy(raw_body, request.headers["Lizzy-Signature"], WEBHOOK_SECRET):
# return Response(status=400)
import { createHmac, timingSafeEqual } from "node:crypto";
export function verifyLizzy(
rawBody: Buffer,
signature: string,
secret: string,
toleranceSeconds = 300,
): boolean {
const pairs = signature
.split(",")
.map((part) => part.split("=", 2) as [string, string]);
const timestamp = Number(pairs.find(([key]) => key === "t")?.[1]);
if (!Number.isInteger(timestamp)) return false;
if (Math.abs(Date.now() / 1_000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret)
.update(Buffer.from(String(timestamp) + "."))
.update(rawBody)
.digest();
return pairs
.filter(([key]) => key === "v1")
.some(([, hex]) => {
const candidate = Buffer.from(hex, "hex");
return candidate.length === expected.length && timingSafeEqual(candidate, expected);
});
}
// rawBody must be captured before JSON middleware parses or normalizes it.
Delivery state tells you whether Lizzy will try again
Lizzy gives each attempt five seconds. Any 2xx response marks the delivery succeeded. A non-2xx response or transport failure records the attempt and moves through a fixed retry schedule.
- 1Immediate attempt
The first signed POST runs after the creating transaction commits.
- 2+5 seconds
First retry after a failed initial attempt.
- 3+30 seconds
Second retry.
- 4+120 seconds
Third retry.
- 5+600 seconds
Fourth retry.
- 6+1800 seconds
Sixth and final total attempt, then dead-letter on failure.
next_retry_at names a scheduled retry.Inspect before pinging or redelivering
- 1
Confirm endpoint mode and status
List endpoints with the credential for the expected mode. A live token cannot see a test endpoint, and a disabled endpoint receives no new deliveries.
- 2
Read recent delivery summaries
Filter by
status,event_type, or creation time. Checkattempt_count,next_retry_at, andlast_response. - 3
Open one delivery’s attempt history
The detail endpoint includes all recorded attempts. Correlate its event and delivery IDs with receiver logs without copying an endpoint secret into a ticket or chat.
- 4
Fix the receiver, then choose a write
Manual redelivery is available for 30 days. Ping is a separate signed outbound POST; use either only after the receiver is ready.
: "${LIZZY_WEBHOOK_ID:?Set the wh_ endpoint ID}"
curl --fail-with-body --silent --show-error --get \
-H "Authorization: Bearer $LIZZY_API_TOKEN" \
--data-urlencode "limit=20" \
--data-urlencode "status=failed" \
"https://lizzy.albinilabs.com/v1/webhook_endpoints/$LIZZY_WEBHOOK_ID/deliveries" \
| jq '.data[] | {id, event_id, event_type, status, attempt_count, next_retry_at, last_response}'
{
"id": "del_01EXAMPLE",
"object": "webhook_delivery",
"webhook_endpoint_id": "wh_01EXAMPLE",
"event_id": "evt_01EXAMPLE",
"event_type": "distill.run.succeeded",
"status": "failed",
"attempt_count": 2,
"last_response": {
"status_code": 503,
"body_snippet": "temporarily unavailable",
"duration_ms": 143,
"error": null
},
"next_retry_at": "2026-08-15T17:32:30Z"
}
The per-request chat callback is a different contract
POST /v1/chat accepts a webhook URL for a reply-specific callback. That callback is convenient for a short-lived integration, but it is not a managed event endpoint and should not be presented as one.
{
"id": "msg_01EXAMPLE",
"text": "Your refund was issued.",
"status": "resolved",
"conversation": "conv_01EXAMPLE",
"session": "customer-4821",
"agent": null,
"author": "agent",
"handoff_url": null,
"latency_ms": 1840
}