Skip to content
view raw .md

React to Plan changes with webhooks

Receive Dailybot Plan events on your own endpoint: the 25 tasks.* events, the payload envelope without task titles, verifying X-BEARER, and fetching details with your own credential.

Beta

Plan is in beta. Everything under /plan in the web app, the CLI and agent skill commands for projects, goals, boards and tasks, and the /v1/plan/ public API may change before general availability. Want to try it with your team? Write to [email protected].

Polling is right for a screen someone is looking at. For a server that must react to every change (sync to another tool, notify a channel, update a report), subscribe to Plan events and let Dailybot call you.

1. Subscribe through your organization's webhooks

Plan events are delivered through the same outgoing webhooks as every other Dailybot event. There is no separate webhook endpoint under /v1/plan/. Create the subscription in the web app or with the Webhooks API, choose the tasks.* events you need, and set a secret for the X-BEARER header. See Webhooks & events for the setup.

2. The 25 Plan events

Object Events
Task tasks.task.created · tasks.task.updated · tasks.task.state_changed · tasks.task.owner_changed · tasks.task.moved · tasks.task.comment_created · tasks.task.archived · tasks.task.participant_added · tasks.task.participant_removed
Board tasks.board.created · tasks.board.updated · tasks.board.archived · tasks.board.restored · tasks.board.member_added · tasks.board.member_removed
Project tasks.project.created · tasks.project.updated · tasks.project.member_added · tasks.project.member_removed · tasks.project.archived · tasks.project.restored
Goal tasks.goal.created · tasks.goal.updated · tasks.goal.archived · tasks.goal.restored
  • There is no tasks.task.deleted: archive is the delete, so listen for tasks.task.archived.
  • New events are added over time. Ignore events you do not recognise instead of failing.
  • Some changes appear only in the activity feed, not as webhooks (for example a retired milestone, recorded as project.milestone_deleted).

3. What arrives: identifiers, never titles

Every delivery is a JSON POST with the standard envelope. For Plan, body is the event record:

{
  "event": "tasks.task.state_changed",
  "event_timestamp": "2026-09-25T10:14:02Z",
  "hook": { "id": "wh-1234-abcd", "name": "Tasks sync" },
  "body": {
    "event": "tasks.task.state_changed",
    "occurred_at": "2026-09-25T10:14:02.113954Z",
    "observed_at": "2026-09-25T10:14:02.113954Z",
    "organization_uuid": "00000000-0000-4000-8000-000000000101",
    "actor": { "kind": "user", "uuid": "00000000-0000-4000-8000-00000000000c" },
    "correlation_id": "00000000-0000-4000-8000-000000000102",
    "entity": { "type": "task", "uuid": "00000000-0000-4000-8000-000000000005", "key": "ENG-142" },
    "data": {
      "from_state_uuid": "00000000-0000-4000-8000-000000000003",
      "to_state_uuid": "00000000-0000-4000-8000-000000000004"
    }
  }
}

No title, description, comment text or label name is ever in a payload. That is deliberate: a webhook URL is the least trusted place an event can go. The task key is the only human-readable field. When you need the content, fetch it with a credential of your own, which is refused if that credential may not see it:

curl -sS "https://api.dailybot.com/v1/plan/tasks/ENG-142/" \
  -H "X-API-KEY: $DAILYBOT_API_KEY" | jq '{key, title, state: .state.name}'

4. Verify every delivery

Deliveries are not signed. Instead, each request carries an X-BEARER header with the secret you set on the subscription. Reject anything else, compare in constant time, and only accept HTTPS:

import { timingSafeEqual } from 'node:crypto';

function isFromDailybot(req) {
  const received = Buffer.from(req.headers['x-bearer'] ?? '');
  const expected = Buffer.from(process.env.DAILYBOT_WEBHOOK_SECRET);
  return received.length === expected.length && timingSafeEqual(received, expected);
}

OAuth 2.0 is also available for webhook authentication; see Webhooks & events.

5. Handle it well

  • Answer quickly with a 2xx and do the work asynchronously.
  • Make processing idempotent: key it on the event record (entity.uuid, event, occurred_at) so a repeated delivery or your own retry does no harm.
  • Order by occurred_at, not by arrival time.
  • Re-read before acting when the decision depends on the current state; the event says what changed, the API says what is true now.

Reference