LizzyDocs
Docs navigation Webhooks

Event integration

Handle Lizzy webhooks

Register an HTTPS endpoint, verify signatures against the raw request body, deduplicate retries, and replay failed deliveries.

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.

Production default

Managed endpoint

Full event envelope, per-endpoint secret, fixed retries, attempt history, and redelivery.

/v1/webhook_endpoints
Read fallback

Events API

List the same event stream with an events:read token and cursor pagination.

/v1/events
Convenience only

Chat callback

One small reply payload, one attempt, and no manageable per-workspace signing secret.

POST /v1/chat webhook field
GET/v1/event_types

Public 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.

cURLexample
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"
jsonexample create response
{
  "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.

jsonevent body
{
  "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"
    }
  }
}
Content-Typeapplication/json
User-AgentLizzy-Webhooks/1.0
Lizzy-Signaturet=<unix>,v1=<hex HMAC-SHA256>; more than one v1 can appear during rotation.
Lizzy-Event-TypeThe event type, duplicated from the body for routing.
Lizzy-Event-IdStable event identity. Use it as the consumer idempotency key.
Lizzy-Delivery-IdThe endpoint-specific delivery whose attempts appear in diagnostics.

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.

pythonsignature verifier
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)
typescriptNode.js signature verifier
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.

  1. 1
    Immediate attempt

    The first signed POST runs after the creating transaction commits.

  2. 2
    +5 seconds

    First retry after a failed initial attempt.

  3. 3
    +30 seconds

    Second retry.

  4. 4
    +120 seconds

    Third retry.

  5. 5
    +600 seconds

    Fourth retry.

  6. 6
    +1800 seconds

    Sixth and final total attempt, then dead-letter on failure.

pendingNo attempt has completed yet, or a manual redelivery was queued.
failedThe last attempt failed and next_retry_at names a scheduled retry.
succeededThe receiver returned any HTTP status from 200 through 299.
dead_letteredAutomatic attempts ended. Inspect the failure, then redeliver deliberately.

Inspect before pinging or redelivering

  1. 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. 2

    Read recent delivery summaries

    Filter by status, event_type, or creation time. Checkattempt_count, next_retry_at, and last_response.

  3. 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. 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.

cURLexample
: "${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}'
jsonexample failed delivery
{
  "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.

BodyA small reply object with text, status, conversation, session, author, handoff URL, and latency.
AttemptsOne five-second attempt. There is no automatic retry.
HeadersSignature and delivery ID only; no managed event ID or event-type header.
SecretCurrently uses an application-level placeholder secret that the workspace cannot retrieve.
URL validationDoes not currently apply the managed endpoint’s type and HTTPS validation path.
jsonchat callback body
{
  "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
}