# Webhooks

> Receive a signed HTTP POST when a task, project, or comment changes in a workspace.

A webhook endpoint is a URL Done Bear POSTs to whenever something in a workspace changes. Every change counts, whichever surface made it: the web app, iOS, the CLI, the MCP server, the chat agent, or the server's own repeat scheduler. The stream comes from the same server-sequenced log that keeps the clients in sync, so an event is never skipped and never sent out of order.

## Create an endpoint

Endpoints are managed by workspace owners and admins. Use a Supabase JWT or an API key with the `admin` scope.

```bash
curl -X POST https://api.donebear.com/api/webhooks \
  -H "Authorization: Bearer $DONEBEAR_TOKEN" \
  -H "Content-Type: application/json" \
  --data '{
    "workspaceId": "<workspace-id>",
    "url": "https://example.com/hooks/donebear",
    "description": "Sync completions to the CRM",
    "eventTypes": ["task.completed", "task.deleted"]
  }'
```

The response carries the endpoint and a `secret` beginning with `whsec_`. Store it: it is shown once, and the only way to get another is to rotate.

The URL has to be public HTTPS. Loopback, private, and link-local addresses are refused, and redirects are not followed. A workspace can have ten endpoints. Omit `eventTypes`, or send an empty list, to receive everything.

## Event types

| Type                | When                                                        |
| ------------------- | ----------------------------------------------------------- |
| `task.created`      | A task was added.                                           |
| `task.updated`      | Any other change to a task.                                 |
| `task.completed`    | A task went from open to done.                              |
| `task.archived`     | A task was moved to Trash.                                  |
| `task.deleted`      | A task was removed for good.                                |
| `project.created`   | A project was added.                                        |
| `project.updated`   | Any other change to a project.                              |
| `project.completed` | A project was marked done.                                  |
| `project.archived`  | A project was archived.                                     |
| `project.deleted`   | A project was removed for good.                             |
| `comment.created`   | A comment was posted.                                       |
| `comment.updated`   | A comment was edited.                                       |
| `comment.deleted`   | A comment was removed.                                      |
| `endpoint.test`     | You asked for a test delivery. Always sent, never filtered. |

Tasks inside a private project are not published. Their sync group is the project rather than the workspace, so only that project's members can see them, and a workspace-level endpoint is not one of those members.

`GET /api/webhooks/event-types` returns the list a machine can subscribe to.

## The payload

```json
{
  "id": "5d2d1c8e-2c6f-4c1a-9c2f-2b7d9a6c1e00",
  "type": "task.completed",
  "createdAt": "2026-09-14T01:02:03.000Z",
  "apiVersion": "1",
  "workspaceId": "3f2b6c0e-1b2c-4d5e-8f90-0a1b2c3d4e5f",
  "syncActionId": "918273",
  "data": {
    "object": {
      "id": "7b1e2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d",
      "number": 42,
      "title": "Ship the webhook docs",
      "completedAt": "2026-09-14T01:02:03.000Z",
      "deadlineAt": "2026-09-20",
      "workspaceId": "3f2b6c0e-1b2c-4d5e-8f90-0a1b2c3d4e5f"
    },
    "changes": ["completedAt", "updatedAt"]
  }
}
```

- `id` is the event id. It is the same on every retry and for every endpoint that receives the event, so it is the key to deduplicate on.
- `data.object` is the whole record after the change. For a `*.deleted` event it is the record as it was before.
- `data.changes` lists the fields whose value differs from the previous state. It is empty on creates, deletes, and tests.
- Timestamps are ISO 8601 in UTC. Date-only fields such as `startDate` and `deadlineAt` are `YYYY-MM-DD`.
- `syncActionId` is the position in the sync log, as a string. Events for one workspace arrive in ascending order of it, retries aside.

## Verify the signature

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com) specification, so any of its libraries verifies them. Three headers arrive with each POST:

| Header              | Value                                                  |
| ------------------- | ------------------------------------------------------ |
| `webhook-id`        | The event id.                                          |
| `webhook-timestamp` | Unix seconds when this attempt was signed.             |
| `webhook-signature` | One or more `v1,<base64>` entries separated by spaces. |

The signed content is `${webhook-id}.${webhook-timestamp}.${raw body}` under HMAC-SHA256, keyed with the bytes of your secret after the `whsec_` prefix, base64 decoded. Compare against each entry in the header, reject a timestamp more than five minutes from your clock, and use a constant-time comparison.

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export const verify = (
  secret: string,
  headers: Record<string, string>,
  rawBody: string
): boolean => {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!id || !timestamp || age > 300) {
    return false;
  }
  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();
  return (headers["webhook-signature"] ?? "").split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) {
      return false;
    }
    const presented = Buffer.from(value, "base64");
    return (
      presented.length === expected.length &&
      timingSafeEqual(presented, expected)
    );
  });
};
```

Verify the raw request body, not a re-serialised copy. Answer with any 2xx within ten seconds; do the work after you have answered.

## Retries and disabling

A delivery that does not get a 2xx is tried again on a fixed schedule: after 5 seconds, then 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours. That is eight attempts over a little more than a day, after which the delivery is marked `failed`.

An endpoint that fails many times in a row, about three days of a host that never answers, is switched off with `disabledReason: "consecutive_failures"`. Answer `410 Gone` to switch it off at once. Re-enable with a `PATCH` setting `enabled: true`, which also clears the failure count.

`GET /api/webhooks/{id}/deliveries` lists attempts with their status code, a snippet of the response, and the payload. `POST /api/webhooks/{id}/deliveries/{deliveryId}/retry` queues one more attempt for any delivery.

## Rotate the secret

`POST /api/webhooks/{id}/rotate-secret` returns a new secret. For the next 24 hours every delivery carries two signatures, one under each secret, so you can switch your handler at any point in that window without dropping an event.

## Test an endpoint

`POST /api/webhooks/{id}/test` queues an `endpoint.test` event. It goes through the same queue and signing as a real event, so a green test means the whole path works.

## Order and duplicates

Events are queued in commit order and one worker sends each delivery at a time, but a slow endpoint and a retry can put two events out of order at your side. If order matters, compare `syncActionId` and keep the larger. A delivery can also arrive twice when your handler answered after Done Bear had already given up waiting, so treat `webhook-id` as an idempotency key.
