# Chesly API webhooks

> Chesly posts each event to your URL, signed under Standard Webhooks, and retries for about 28 hours. Start a trial and make keys at https://web.chesly.app/api. Agent setup: https://agents.chesly.app/agents.md

## Create a webhook

Give Chesly a public HTTPS URL, and it posts each new event there as JSON:

```bash
curl https://api.chesly.app/v1/webhooks \
  -H "Authorization: Bearer $CHESLY_API_KEY" \
  -H "Idempotency-Key: 6e9865aa-b31e-43b1-9a29-57e5a7ff4b6b" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/hooks/chesly", "event_types": ["message.received"]}'
```

The answer is the webhook with its `secret`, which starts with `whsec_`. It's shown only this once, so keep it with your server's other secrets. `chesly webhooks create <url>` does the same from the CLI.

- `event_types` picks the events to send. Leave it out for every event.
- `networks` picks the networks. Leave it out for every network but email.
- `payload` is `full`, the default, or `ids`.
- `description` is a note for you.

You can have 25 webhooks on Personal and 100 on Pro, and deliveries never count as calls. The events are the ones in the [catalog](https://agents.chesly.app/docs#events).

## The request

Each delivery is a `POST` to your URL with `Content-Type: application/json`, `User-Agent: Chesly-Webhooks/1.0`, and the three [Standard Webhooks](https://www.standardwebhooks.com/) headers:

| Header | Value |
|---|---|
| `webhook-id` | The event's ID, the same on every retry of it. Skip an ID you've already handled. |
| `webhook-timestamp` | When this attempt was signed, in Unix seconds |
| `webhook-signature` | `v1,` and the base64 HMAC-SHA256 of `<webhook-id>.<webhook-timestamp>.<body>`, keyed with your secret. For 24 hours after a rotation there are two, separated by a space. |

```http
POST /hooks/chesly HTTP/1.1
content-type: application/json
user-agent: Chesly-Webhooks/1.0
webhook-id: evt_LOBiK493MDUR1TH599lfcIHZ
webhook-timestamp: 1793095328
webhook-signature: v1,cE1wrSmMd09Me9szLF82KycDZ7wfRwR7xntFy6WOD/c=
```

The body is the event:

```json
{
  "id": "evt_LOBiK493MDUR1TH599lfcIHZ",
  "type": "message.received",
  "timestamp": "2026-10-27T10:02:07.518Z",
  "data": {
    "message": {
      "id": "msg_UXw90uk2Q0SkBgQdA7uY.JGNXcGE1THJaY3JiMnFwRGIxM3ZjZXBaYS1tbjBEc2l3ZnA5d2Uxek9RVnc",
      "chat_id": "chat_UXw90uk2Q0SkBgQdA7uY",
      "sender": { "name": "Priya Patel", "is_me": false },
      "text": "Got the deck, thank you! Looks great.",
      "truncated": false,
      "type": "text",
      "media": null,
      "sent_at": "2026-10-27T10:02:07.518Z",
      "edited": false,
      "deleted": false,
      "reply_to": null,
      "delivery": null
    },
    "chat": {
      "id": "chat_UXw90uk2Q0SkBgQdA7uY",
      "name": "Priya Patel",
      "network": "slack",
      "type": "dm"
    }
  }
}
```

With `payload` set to `ids`, `data` holds only the event's IDs, such as `message_id`, `chat_id` and `account_id`, and your code fetches the rest with its key. Use it when message text shouldn't pass through the service that receives your webhooks.

## Verify the signature

Check every delivery before you trust it, against the raw body as it arrived: parse the JSON after. A Standard Webhooks library does the check, and refuses a timestamp more than 5 minutes off, so an old delivery can't be replayed.

In TypeScript, with `npm install standardwebhooks`:

```ts
import { Webhook } from "standardwebhooks";

const webhook = new Webhook(process.env.CHESLY_WEBHOOK_SECRET!); // whsec_…

export async function POST(request: Request) {
  const payload = await request.text();
  let event;
  try {
    event = webhook.verify(payload, Object.fromEntries(request.headers));
  } catch {
    return new Response("Invalid signature", { status: 400 });
  }
  // Handle the event once per event.id, then answer quickly.
  return new Response(null, { status: 204 });
}
```

In Python, with `pip install standardwebhooks`:

```python
import os
from flask import Flask, request
from standardwebhooks import Webhook, WebhookVerificationError

app = Flask(__name__)
webhook = Webhook(os.environ["CHESLY_WEBHOOK_SECRET"])  # whsec_…

@app.post("/hooks/chesly")
def chesly_webhook():
    try:
        event = webhook.verify(request.get_data(), dict(request.headers))
    except WebhookVerificationError:
        return "Invalid signature", 400
    # Handle the event once per event["id"], then answer quickly.
    return "", 204
```

## Retries

Any `2xx` answer is a success. Anything else is a failure: another status, no answer within 15 seconds, or a redirect, which Chesly never follows. After a failure, Chesly tries again in 5 seconds, then 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours: 8 attempts over about 28 hours. Waits of 5 minutes or more vary by up to 10%. When you answer `429`, `502`, `503` or `504` with a longer `Retry-After`, Chesly waits that long instead, up to 10 hours.

Answer within 15 seconds and do slow work afterwards. A retried event can arrive after newer ones, so order events by their `timestamp`, and skip a `webhook-id` you've already handled.

## When a webhook turns off

A webhook that's off has `status: disabled`, and `disabled_reason` says why:

- `gone`: it answered `410 Gone`, which turns it off at once.
- `failing`: it kept failing for 3 days. Any success in between starts the count again.
- `user`: you turned it off, with `PATCH /v1/webhooks/{id}` and `{"enabled": false}`, or in the dashboard.
- `key_revoked`: an API key that made it, changed it or set its URL was revoked because it leaked, so it was turned off with the key. It comes back on only in the dashboard: an API key that tries gets `403` `forbidden`.

While a webhook is off, its pending deliveries are dropped. Turn it back on with `PATCH /v1/webhooks/{id}` and `{"enabled": true}`, or in the dashboard. While your account has no API plan, deliveries are dropped instead of sent.

## Where a webhook can point

- `https://` only, in at most 2,048 characters, with no user name or password in the URL.
- The host must not resolve to a private, loopback, link-local, shared (`100.64.0.0/10`), multicast or cloud metadata address. Chesly checks when you create the webhook, and again on every delivery, against the address it connects to.
- Redirects are never followed.

## Rotate the secret

`POST /v1/webhooks/{id}/rotate_secret`, or `chesly webhooks rotate <id>`, answers with a new secret and `previous_secret_expires_at`. For the next 24 hours, each delivery is signed with both secrets, so you can switch your server to the new one without missing a delivery. A Standard Webhooks library accepts a delivery when any one of its signatures matches.

## Test and debug

- `POST /v1/webhooks/{id}/test`, or `chesly webhooks test <id>`, sends a test delivery now. Its `webhook-id` starts with `evt_test_`.
- `GET /v1/webhooks/{id}/deliveries`, or `chesly webhooks deliveries <id>`, lists the webhook's recent deliveries: each one's status, attempts, last status code and the first 1,024 characters of your answer. The dashboard shows the same log.
