> ## Documentation Index
> Fetch the complete documentation index at: https://docs.whirl.sh/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks and events

> Get a signed request at your URL when tasks are created or change status, agent runs start or finish, or something needs you.

Whirl can call your server when something happens, so you don't have to poll. Subscribe with the API's webhook operations, or with MCP Events from an MCP client. Both send the same signed requests, and subscribing needs `workspace:read`.

## Events

| Event | When it's sent | Sent for |
| - | - | - |
| `task.created` | A task is created, including by an integration, an automation or an agent. | Every workspace the connection can access |
| `task.status_changed` | A task changes status, including when it's done, archived or reopened. | Every workspace the connection can access |
| `agent_run.started` | An agent starts a run on a task. | Every workspace the connection can access |
| `agent_run.finished` | An agent run on a task succeeds, fails or is cancelled, including runs that end waiting for approval. | Every workspace the connection can access |
| `approval.required` | A task you follow needs an approval or an answer from you. | Your notifications |
| `task.assigned` | Someone assigns you a task. | Your notifications |
| `task.mentioned` | Someone mentions you in a task note. | Your notifications |

Run events are sent whatever your notification settings, and whether or not you follow the task. Each retry is a separate run, with its own run ID and its own `agent_run.finished`.

A subscription belongs to the app connection that created it. Other connections, even the same user's, can't see or change it, and disconnecting the app deletes its subscriptions. **Settings → API & MCP** shows which events each connected app subscribes to.

## Create a signing secret

Generate a secret and keep it where your server can read it. It's `whsec_` followed by 24 to 64 random bytes in standard base64. 32 bytes is a good choice:

```bash theme={null}
echo "whsec_$(openssl rand -base64 32)"
```

## Subscribe with the API

Your server must be on a public HTTPS URL. Whirl [checks the URL](#verify-your-url) before it saves the subscription.

```bash theme={null}
curl --fail-with-body "https://api.whirl.sh/api/v1/webhooks" \
  -H "Authorization: Bearer $WHIRL_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "name": "task.created",
    "workspaceId": "<workspace ID>",
    "url": "https://example.com/whirl/events",
    "secret": "<your whsec_ secret>"
  }'
```

Each subscription is for one event. Leave out `workspaceId` to get the event from every workspace the connection can access.

The response has the subscription's `id` and `refreshBefore`. Subscriptions last 24 hours, or less if you ask with `ttlMs` (at least one minute). To keep one, send the same `name`, `url` and `workspaceId` again before `refreshBefore`. Each connection can have up to 25 subscriptions.

To rotate your secret, subscribe again with a new one. Whirl checks your URL with the new secret, and signs with the old one too for an hour so you can switch over.

`list_webhooks` shows this connection's subscriptions, including ones made with MCP Events, and when each last delivered or started failing. It never returns secrets. `delete_webhook` stops a subscription, and calling it again does nothing. See the [API reference](https://docs.whirl.sh/api/reference/create_webhook.md) for all the fields. These operations are also MCP tools.

## Subscribe from an MCP client

MCP clients on protocol version `2026-07-28` can use the draft [MCP Events extension](https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/main/docs/design-sketch-proposal.md) instead. `server/discover` advertises it, and `events/list`, `events/subscribe` and `events/unsubscribe` work as the extension describes. Whatever is subscribing, including another MCP server, passes its own URL and secret in `delivery`:

```json theme={null}
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "events/subscribe",
  "params": {
    "_meta": { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
    "name": "agent_run.finished",
    "arguments": { "workspaceId": "<workspace ID>" },
    "delivery": {
      "mode": "webhook",
      "url": "https://example.com/whirl/events",
      "secret": "<your whsec_ secret>"
    }
  }
}
```

The same rules apply as for the API: one event per subscription, renew before `refreshBefore`, and send a new secret to rotate it. To unsubscribe, call `events/unsubscribe` with the same `name`, `arguments` and `delivery.url`. Clients on older protocol versions can use the webhook tools instead.

Whirl only delivers events to webhooks. There's no event stream over SSE, and missed events can't be replayed. `cursor` is always `null`, and if you send a cursor to resume from, the response has `truncated: true`.

## Verify your URL

Before it saves a new subscription, Whirl sends your URL a signed POST request:

```json theme={null}
{ "type": "verification", "challenge": "<random string>" }
```

[Check the signature](#check-the-signature), then respond with a 2xx status and the challenge, either as JSON (`{"challenge": "<random string>"}`) or as plain text. Whirl waits up to five seconds for an answer, doesn't follow redirects, and won't call private network addresses.

If the check fails, `create_webhook` returns HTTP status 422 and `events/subscribe` returns an error. Once a URL passes, Whirl doesn't check it again for 24 hours for the same connection and secret.

## Receive events

Each event is a POST request with a JSON body:

```json theme={null}
{
  "eventId": "evt_...",
  "name": "agent_run.finished",
  "timestamp": "2026-11-02T12:00:00.000Z",
  "data": {
    "organizationId": "...",
    "workspaceId": "...",
    "taskId": "...",
    "title": "Reconcile invoices",
    "summary": "",
    "url": "https://whirl.sh/app/...",
    "runId": "...",
    "status": "succeeded"
  },
  "cursor": null
}
```

Every event has the task's `organizationId`, `workspaceId`, `taskId` and `title`, and a `url` that opens the task in Whirl. Some events add more:

| Event | Extra fields |
| - | - |
| `task.created` | `status` |
| `task.status_changed` | `previousStatus` and `status`, as the keys `list_workspace_statuses` returns |
| `agent_run.started` | `runId` |
| `agent_run.finished` | `runId`, and `status`: `succeeded`, `failed` or `cancelled` |
| `approval.required` | `runId` and `pendingActionId`, when there is one |

`summary` is filled in for `approval.required`, `task.assigned` and `task.mentioned`, and empty for the other events. Events only carry these fields, so read the rest with `get_task`, `get_run` or `get_pending_action`. `list_event_types` returns each event's full payload schema.

## Check the signature

Requests are signed with [Standard Webhooks](https://www.standardwebhooks.com), so you can use any Standard Webhooks library. They have these headers:

| Header | Value |
| - | - |
| `webhook-id` | The event's `eventId`. It's the same on every retry. |
| `webhook-timestamp` | When this attempt was sent, in Unix seconds. |
| `webhook-signature` | One or more space-separated `v1,<signature>` values. There's more than one while you rotate your secret. |
| `X-MCP-Subscription-Id` | The subscription's `id`. |

To check a request yourself, compute an HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>`, keyed with the bytes your secret encodes after `whsec_`. Base64-encode it and compare it with each `v1` signature in constant time. Use the raw request body, before you parse it, and reject old timestamps. For example, in Node.js:

```typescript theme={null}
import { createHmac, timingSafeEqual } from "node:crypto";

export function isFromWhirl(headers: Headers, rawBody: string, secret: string): boolean {
  const id = headers.get("webhook-id");
  const timestamp = headers.get("webhook-timestamp");
  const signatures = headers.get("webhook-signature");
  if (!id || !timestamp || !signatures) return false;

  // Reject requests more than five minutes old or in the future.
  const age = Date.now() / 1000 - Number(timestamp);
  if (!(Math.abs(age) <= 5 * 60)) return false;

  const key = Buffer.from(secret.slice("whsec_".length), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest();
  return signatures.split(" ").some((entry) => {
    const [version, signature = ""] = entry.split(",");
    const received = Buffer.from(signature, "base64");
    return version === "v1" && received.length === expected.length && timingSafeEqual(received, expected);
  });
}
```

## Respond and handle retries

Respond with a 2xx status straight away, then do the work. Whirl waits up to five seconds. Events can arrive more than once and out of order, so keep track of the subscription ID and `eventId` pairs you've handled.

| Your response | What Whirl does |
| - | - |
| 2xx | Nothing more. |
| 429, 5xx, a timeout or a network error | Tries again with exponential backoff, up to eight attempts in all. Each retry has the same body and `eventId`, with a new timestamp and signature. |
| 410 | Deletes the subscription. |
| Any other 4xx | Records the failure, and doesn't try again. |

Failed events can't be sent again. `list_webhooks` shows when each subscription last delivered (`lastDeliveryAt`), and when and why it started failing (`failedSince` and `lastError`).

## When events stop

Whirl checks access again before every attempt, so it doesn't send an event if:

* The subscription expired or was deleted, or the app was disconnected.
* The user lost access to the workspace, or the workspace was archived.
* The task was deleted or moved to another workspace.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.