Reference

Outbound webhook format

What Willo sends to your webhook endpoints: events, the payload, headers, the X-Willo-Signature scheme, retries, caps and URL rules.

Generated from Willo's codeOpen in ClaudeOpen in ChatGPT
On this page

Willo POSTs each event to every endpoint on the business subscribed to it. An owner or admin registers endpoints on Settings → AI Agents → Webhooks. See Webhooks for setup and recipes.

Events

EventWhen
content.publishedA content item was published, or scheduled and its time came. Also sent for each draft item a publish took live with it (a referenced author or category).
content.unpublishedA published content item was taken back to a draft.
content.deletedA published content item was deleted, taking it off the site. title is null. Deleting a draft sends nothing.
site.deployedA new version of the site went live. actor is always {"kind": "system", "id": "willo:system"}.
pingSent only by Send test event on the dashboard, to that one endpoint. Never subscribed to, never retried.

Payload

Every event has the same envelope. Payloads carry ids, names and links, never an item's body or field values: fetch the item when you need them.

{
  "id": "5d0c7f1e-9a51-4f3e-8c1b-2a7e6f0b9c34",
  "event": "content.published",
  "created_at": "2026-11-10T14:03:22.418Z",
  "business": {
    "id": "b7e2…",
    "site_url": "https://www.example.com"
  },
  "actor": {
    "kind": "user",
    "id": "willo:user:3f1c…"
  },
  "data": {
    "item_id": 412,
    "collection": "blog",
    "slug": "spring-opening-hours",
    "title": "Spring opening hours",
    "status": "published",
    "url": "https://www.example.com/blog/spring-opening-hours/",
    "visibility": "live",
    "published_at": "2026-11-10T14:03:21.907Z"
  }
}
FieldWhat it is
idThe event id. Every endpoint that receives this event gets the same one.
eventThe event name, also in the X-Willo-Event header.
created_atWhen the event happened, UTC, ISO 8601.
businessid, site_url. site_url is the site's public address.
actorWho caused it: kind, id. Kinds are listed below.
dataDepends on the event, below.

Content events

data holds item_id, collection, slug, title, status, url, visibility, published_at.

FieldValues
statuspublished, draft (after unpublish) or deleted.
urlThe item's public address on the site.
visibilitylive: visitors can see the change now. awaiting_site_publish: the business is drafting its site, so the change goes live with the next site publish, which sends site.deployed.
published_atWhen it was published. Null for unpublish and delete.

site.deployed

data holds deploy_id, commit, site_url, source. source is deploy.

ping

data holds endpoint_id, message.

Actor kinds

kindWho
agent_programAn Agent Program run.
chatWillo's chat, for the person in the conversation.
growth_engineThe Growth Engine working through the roadmap.
importAn import.
mcpA connected AI agent, through its token or sign-in.
scheduleA scheduled publish reaching its time.
staffWillo support. No person's id is sent.
systemWillo itself, with no person behind it (a deploy).
userA person on the business, in the dashboard.
webhookAn event from a connected service, such as Stripe, Shopify or inbound email.

Headers

HeaderValue
X-Willo-EventThe event name.
X-Willo-DeliveryThe delivery id. It stays the same across retries of one delivery: use it to ignore repeats.
X-Willo-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256>, below.
User-AgentWillo-Webhooks/1
Content-Typeapplication/json

Signature

PartValue
Signed message<t>.<raw body>
Keythe endpoint's whole signing secret, starting willo_whs_. It is shown once, when you register the endpoint or rotate its secret.
AlgorithmHMAC-SHA256, lowercase hex
TimestampEach try is signed when it is sent. Reject a timestamp more than five minutes from your clock.

It is the scheme Agent Program webhook triggers accept, so one verifier serves both directions. Verify against the raw body, before parsing it.

Node

import crypto from "node:crypto";

// rawBody: the request body exactly as received (a Buffer), before JSON parsing.
function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex");
  const given = Buffer.from(parts.v1 ?? "", "hex");
  const want = Buffer.from(expected, "hex");
  return given.length === want.length && crypto.timingSafeEqual(given, want);
}

Python

import hashlib, hmac, time

def verify(raw_body: bytes, header: str, secret: str) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        t = int(parts["t"])
    except (KeyError, ValueError):
        return False
    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.encode(), parts.get("v1", "").encode())

Delivery and retries

RuleValue
MethodPOST
Timeout10 seconds
Successany 2xx status
RedirectsNever followed. A 3xx is a failed try.
ResponseThe first 1 KB is read; the rest is ignored.
RetriesAfter 1 min, 5 min, 30 min, 2 h, 12 h: 6 tries in all. Then the delivery is failed.
OrderNot guaranteed. Order by created_at and ignore a X-Willo-Delivery you have already handled.

Answer quickly with a 2xx and do slow work afterwards. Delivery statuses on the dashboard: pending, delivered, failed, capped. A capped delivery was over the daily limit and was not sent.

Disabled endpoints

An endpoint that fails 25 tries in a row, over at least 72 hours, is disabled. Its waiting deliveries are dropped, and the business's owners and admins get a notification and an email. Resume on the dashboard turns it back on. Pause is the owner's own switch: while an endpoint is paused nothing is queued for it.

Limits

LimitValue
Endpoints per business5
Deliveries per business2,000 in any 24 hours. Past it, events are not sent: the first of them show as capped on the deliveries list, and admins get one notice a day.
Delivery history30 days

URL rules

RuleValue
Scheme and porthttps on port 443
LengthUp to 2,048 characters
AddressMust resolve to a public internet address, checked when you register and on every try.
Not allowedA username or password in the URL, or a #fragment.

Delivery errors

The deliveries list on the dashboard and the willo_webhooks MCP tool show why a try failed.

ErrorMeaning
address_not_publicThe host no longer resolves to a public internet address.
connect_errorWillo could not connect to the receiver.
endpoint_disabledThe endpoint was disabled after failing for too long, so its waiting deliveries were dropped.
endpoint_removedThe endpoint was removed, so its waiting deliveries were dropped.
http_<status>The receiver answered with a status that is not 2xx. The first part of its response follows, when it sent one.
invalid_urlThe registered URL is no longer valid.
redirect_not_followedThe receiver answered with a redirect. Willo never follows one: register the final URL.
request_errorThe request failed before an answer arrived. The kind of failure follows.
secret_unreadableWillo could not sign the delivery. Rotate the endpoint's secret.
timeoutThe receiver did not answer in time, or its host name did not resolve in time.