# 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 code at each release.

Source: https://www.willo.ai/docs/reference/outbound-webhooks

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](https://www.willo.ai/docs/ai-agents/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.

```json
{
  "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

```js
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

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