Outbound webhook format
What Willo sends to your webhook endpoints: events, the payload, headers, the X-Willo-Signature scheme, retries, caps and URL rules.
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
| Event | When |
|---|---|
content.published | A 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.unpublished | A published content item was taken back to a draft. |
content.deleted | A published content item was deleted, taking it off the site. title is null. Deleting a draft sends nothing. |
site.deployed | A new version of the site went live. actor is always {"kind": "system", "id": "willo:system"}. |
ping | Sent 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"
}
}
| Field | What it is |
|---|---|
id | The event id. Every endpoint that receives this event gets the same one. |
event | The event name, also in the X-Willo-Event header. |
created_at | When the event happened, UTC, ISO 8601. |
business | id, site_url. site_url is the site's public address. |
actor | Who caused it: kind, id. Kinds are listed below. |
data | Depends on the event, below. |
Content events
data holds item_id, collection, slug, title, status, url, visibility, published_at.
| Field | Values |
|---|---|
status | published, draft (after unpublish) or deleted. |
url | The item's public address on the site. |
visibility | live: 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_at | When 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
kind | Who |
|---|---|
agent_program | An Agent Program run. |
chat | Willo's chat, for the person in the conversation. |
growth_engine | The Growth Engine working through the roadmap. |
import | An import. |
mcp | A connected AI agent, through its token or sign-in. |
schedule | A scheduled publish reaching its time. |
staff | Willo support. No person's id is sent. |
system | Willo itself, with no person behind it (a deploy). |
user | A person on the business, in the dashboard. |
webhook | An event from a connected service, such as Stripe, Shopify or inbound email. |
Headers
| Header | Value |
|---|---|
X-Willo-Event | The event name. |
X-Willo-Delivery | The delivery id. It stays the same across retries of one delivery: use it to ignore repeats. |
X-Willo-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256>, below. |
User-Agent | Willo-Webhooks/1 |
Content-Type | application/json |
Signature
| Part | Value |
|---|---|
| Signed message | <t>.<raw body> |
| Key | the endpoint's whole signing secret, starting willo_whs_. It is shown once, when you register the endpoint or rotate its secret. |
| Algorithm | HMAC-SHA256, lowercase hex |
| Timestamp | Each 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
| Rule | Value |
|---|---|
| Method | POST |
| Timeout | 10 seconds |
| Success | any 2xx status |
| Redirects | Never followed. A 3xx is a failed try. |
| Response | The first 1 KB is read; the rest is ignored. |
| Retries | After 1 min, 5 min, 30 min, 2 h, 12 h: 6 tries in all. Then the delivery is failed. |
| Order | Not 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
| Limit | Value |
|---|---|
| Endpoints per business | 5 |
| Deliveries per business | 2,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 history | 30 days |
URL rules
| Rule | Value |
|---|---|
| Scheme and port | https on port 443 |
| Length | Up to 2,048 characters |
| Address | Must resolve to a public internet address, checked when you register and on every try. |
| Not allowed | A 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.
| Error | Meaning |
|---|---|
address_not_public | The host no longer resolves to a public internet address. |
connect_error | Willo could not connect to the receiver. |
endpoint_disabled | The endpoint was disabled after failing for too long, so its waiting deliveries were dropped. |
endpoint_removed | The 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_url | The registered URL is no longer valid. |
redirect_not_followed | The receiver answered with a redirect. Willo never follows one: register the final URL. |
request_error | The request failed before an answer arrived. The kind of failure follows. |
secret_unreadable | Willo could not sign the delivery. Rotate the endpoint's secret. |
timeout | The receiver did not answer in time, or its host name did not resolve in time. |