# Guide to Todoist Webhooks: Features and Best Practices

Todoist webhooks tell your app when your users' work changes: a task is added, completed or rescheduled, a comment is posted, a project is archived, or a reminder fires. If you build an integration on Todoist, the Doist task manager, webhooks are how you hear about those changes without repeatedly syncing each user's account.

This guide covers how Todoist webhooks are configured and activated, how they are signed, the events and payloads they send, the retry policy and Todoist's own caveats, and the practices that keep a Todoist receiver reliable.

## What are Todoist webhooks?

Todoist webhooks belong to an app. You declare the app in the App Management Console, give it a callback URL and a set of events, and Todoist then sends a JSON `POST` whenever one of those events happens for any user who has authorised the app.

That last condition matters. A user's webhooks are only activated when that user completes your app's OAuth flow. Todoist says plainly that webhooks don't fire by default for the user who created the app, so if you are building something for your own account, you have to authorise your own app first.

Every request is signed with your app's `client_secret`: the `X-Todoist-Hmac-SHA256` header holds a base64 HMAC-SHA256 of the whole request body. There is no separate webhook secret.

Todoist is also unusually direct about delivery. Its documentation says webhooks "could arrive delayed, out of order, or could even fail to arrive at all", and that they should be used as notifications, not as your primary source of Todoist data.

## Todoist webhook features

| Feature | Details |
| --- | --- |
| Configuration | The App Management Console, per app |
| Activation | Per user, when they complete the app's OAuth flow |
| URL rules | HTTPS only, and no port in the URL |
| Event identity | `event_name` in the body |
| Signature header | `X-Todoist-Hmac-SHA256` |
| Scheme | HMAC-SHA256, base64 |
| Signed content | The whole raw request body |
| Secret | The app's OAuth `client_secret` |
| Delivery ID | `X-Todoist-Delivery-ID`, reused when a delivery is retried |
| Versioning | `version` in the body, set per app in the console |
| Success | HTTP `200` only |
| Retries | After 15 minutes, at most three times |
| Ordering | Not guaranteed |
| User agent | `Todoist-Webhooks` |

Todoist doesn't publish a response timeout or a source IP list for webhooks.

## Common events

Todoist documents 25 events. Tasks are called `item` and comments are called `note`, after the Sync API's names for them:

| Area | Events |
| --- | --- |
| Tasks | `item:added`, `item:updated`, `item:deleted`, `item:completed`, `item:uncompleted` |
| Comments | `note:added`, `note:updated`, `note:deleted` |
| Projects | `project:added`, `project:updated`, `project:deleted`, `project:archived`, `project:unarchived` |
| Sections | `section:added`, `section:updated`, `section:deleted`, `section:archived`, `section:unarchived` |
| Labels | `label:added`, `label:updated`, `label:deleted` |
| Filters | `filter:added`, `filter:updated`, `filter:deleted` |
| Reminders | `reminder:fired` |

Here is Todoist's `item:added` example:

```json
{
  "event_name": "item:added",
  "user_id": "2671355",
  "event_data": {
    "added_by_uid": "2671355",
    "assigned_by_uid": null,
    "checked": false,
    "child_order": 3,
    "collapsed": false,
    "content": "Buy Milk",
    "description": "",
    "added_at": "2025-02-10T10:33:38.000000Z",
    "completed_at": null,
    "due": null,
    "deadline": null,
    "id": "6XR4GqQQCW6Gv9h4",
    "is_deleted": false,
    "labels": [],
    "parent_id": null,
    "priority": 1,
    "project_id": "6XR4H993xv8H5qCR",
    "responsible_uid": null,
    "section_id": null,
    "url": "https://app.todoist.com/app/task/6XR4GqQQCW6Gv9h4",
    "user_id": "2671355"
  },
  "initiator": {
    "email": "alice@example.com",
    "full_name": "Alice",
    "id": "2671355",
    "image_id": "ad38375bdb094286af59f1eab36d8f20",
    "is_premium": true
  },
  "triggered_at": "2025-02-10T10:39:38.000000Z",
  "version": "10"
}

```

`event_data` is the full Sync API object for the thing that changed: a task, comment, project, section, label, filter or reminder. `user_id` is the user the event is for, which is the user who authorised your app. `initiator` is whoever made the change, which can be a collaborator on a shared project rather than your user. `version` is the webhook version you chose in the console.

Some events add `event_data_extra`. For `item:updated` made directly by a user, it holds `old_item`, the task before the change, and `update_intent`, which is `item_updated`, `item_completed` or `item_uncompleted`. That is how you tell a real edit from the update Todoist sends when a recurring task is completed and moves to its next date.

## Setting up Todoist webhooks

1. Open the [App Management Console](https://app.todoist.com/app/settings/integrations/app-management) and create or select your app.
2. Copy the app's **Client Secret** and store it server-side, for example as `TODOIST_CLIENT_SECRET`.
3. In the app's webhook settings, enter your callback URL. It must be HTTPS and must not include a port.
4. Choose the events and the webhook version, then save.
5. Have each user complete your app's OAuth flow. For your own account, run the authorisation request in a browser to get a `code`, then exchange it for a token with a `POST` from a tool such as Postman.

If you use Hookdeck, create a Todoist source, paste the app's client secret into its **Client Secret** field, and use the source URL as the webhook callback URL. Hookdeck source URLs have no port, so they meet Todoist's URL rule.

## Securing Todoist webhooks

Compute an HMAC-SHA256 of the whole raw body with the `client_secret` as the key, base64-encode the raw digest, and compare it with `X-Todoist-Hmac-SHA256` in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyTodoistSignature(rawBody, signatureHeader, clientSecret) {
  if (!signatureHeader || !clientSecret) return false;

  // base64 of the raw digest. Base64 is case-sensitive: don't lowercase it.
  const expected = crypto.createHmac('sha256', clientSecret).update(rawBody).digest('base64');

  const received = Buffer.from(signatureHeader.trim(), 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

const app = express();

app.post('/webhooks/todoist', express.raw({ type: '*/*' }), (req, res) => {
  const client_secret = process.env.TODOIST_CLIENT_SECRET;
  if (!client_secret) return res.sendStatus(500); // fail closed

  if (!verifyTodoistSignature(req.body, req.get('x-todoist-hmac-sha256'), client_secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  const delivery_id = req.get('x-todoist-delivery-id'); // reused on retries: dedupe on it
  // Route on event.event_name. Persist, then process async.
  res.sendStatus(200); // exactly 200: anything else is a failed delivery
});

```

The same check in Python:

```python
import base64
import hashlib
import hmac

def verify_todoist_signature(raw_body: bytes, signature_header, client_secret: str) -> bool:
    if not signature_header or not client_secret:
        return False
    digest = hmac.new(client_secret.encode("utf-8"), raw_body, hashlib.sha256).digest()
    expected = base64.b64encode(digest)
    return hmac.compare_digest(signature_header.strip().encode("utf-8"), expected)

# In FastAPI: raw_body = await request.body()
# verify_todoist_signature(raw_body, request.headers.get("x-todoist-hmac-sha256"),
#                          os.environ["TODOIST_CLIENT_SECRET"])

```

To check your verifier, use the client secret `todoist-client-secret` and this exact body:

```
{"event_name":"item:added","user_id":"2671355","version":"10"}

```

The signature should be:

```
OCpfe/GjPPyukuRSIFbv8jy4RTZ9OJ+uv3t4ShdbI4g=

```

> **Make Todoist webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `X-Todoist-Hmac-SHA256` header with your client secret, answers Todoist with the `200` it requires, and durably queues every task and project event before your service sees it.

## Todoist webhook limitations and pain points

### Webhooks that never fire

**The Problem:** You configure a webhook, change a task, and nothing arrives.

**Why It Happens:** Webhooks are activated per user, when that user completes your app's OAuth flow. They don't fire for the app's creator by default, so testing with your own account sends nothing until you authorise your own app.

**Workarounds:**

* Complete the OAuth flow with your test account before you test.
* Use a separate test user who installs the app the way a real user would.

**How Hookdeck Can Help:** Hookdeck shows every request that reaches your source, so you can tell at a glance whether Todoist is sending anything at all or whether the problem is in your handler.

### One secret for two jobs

**The Problem:** Rotating the OAuth client secret breaks webhook verification, or a leaked webhook verifier exposes your OAuth credentials.

**Why It Happens:** Todoist signs webhooks with the app's `client_secret`, the same value used to exchange OAuth codes for tokens. There is no separate webhook secret.

**Workarounds:**

* Treat every service that verifies webhooks as holding your OAuth credentials, and keep that set small.
* When you rotate the client secret, update the token exchange and every webhook verifier together.

**How Hookdeck Can Help:** With Hookdeck verifying Todoist webhooks, the client secret only needs to live in your OAuth service and your Hookdeck source, not in every service that consumes events.

### Three retries, then nothing

**The Problem:** Events are lost after an outage of an hour or so.

**Why It Happens:** Todoist retries a failed delivery after 15 minutes, at most three times. Anything other than a `200` counts as a failure, including `201` and `204`. Todoist also warns that deliveries can be late, out of order or missing even when nothing fails.

**Workarounds:**

* Return exactly `200`, quickly.
* Treat webhooks as hints. Re-read the changed objects through the API, and run a periodic sync for each user to catch anything missed.

**How Hookdeck Can Help:** Hookdeck answers Todoist with `200` as soon as it accepts a delivery, stores every event, and retries delivery to your service for as long as you configure, so an outage on your side no longer runs out Todoist's three retries.

### Updates that are really completions

**The Problem:** A recurring task's completion shows up as an edit, and your app reports the wrong thing.

**Why It Happens:** Completing a recurring task moves it to its next date, which Todoist reports as `item:updated`.

**Workarounds:**

* Read `event_data_extra.update_intent` on `item:updated`, and treat `item_completed` as a completion.
* Compare with `event_data_extra.old_item` when you need to know exactly what changed.

**How Hookdeck Can Help:** Hookdeck filters can route on `event_data_extra.update_intent`, so completions of recurring tasks can go to the same destination as `item:completed`.

## Best practices

### Verify against the raw body

The HMAC covers the whole request body exactly as sent. In Express, use `express.raw({ type: '*/*' })` and parse after verification. In Next.js, read `await request.text()`. In FastAPI, read `await request.body()`.

### Deduplicate on `X-Todoist-Delivery-ID`

Each notification has a unique delivery ID, and retries reuse it. Store it before processing. See our [guide to webhook idempotency](/webhooks/guides/implement-webhook-idempotency).

### Don't rely on order

Use `triggered_at` and the object's own fields to decide what is current, and fetch from the API when two events conflict.

### Pin the webhook version

The `version` field reflects the version set in the console. Check it in your handler, and plan a migration when you change it, because payloads can differ between versions.

### Acknowledge fast and process asynchronously

Todoist doesn't publish its timeout, so keep the request path short: verify, store, return `200`. See [why to process webhooks asynchronously](/webhooks/guides/why-implement-asynchronous-processing-webhooks).

## Conclusion

Todoist webhooks belong to your app, fire only for users who have authorised it, and are signed with a base64 HMAC-SHA256 of the raw body keyed with the app's `client_secret`. Authorise your own app before testing, verify against the raw bytes, route on `event_name`, deduplicate on `X-Todoist-Delivery-ID`, return exactly `200`, and treat each webhook as a notification to reconcile against the API, as Todoist advises.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks the Todoist signature, and holds each event until your service has processed it, so three retries are no longer all you get.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Todoist webhooks reliably in minutes.