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:
{
"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
- Open the App Management Console and create or select your app.
- Copy the app's Client Secret and store it server-side, for example as
TODOIST_CLIENT_SECRET. - In the app's webhook settings, enter your callback URL. It must be HTTPS and must not include a port.
- Choose the events and the webhook version, then save.
- 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 aPOSTfrom 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.
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:
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 verifies the
X-Todoist-Hmac-SHA256header with your client secret, answers Todoist with the200it 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_ondata_ extra.update_ intent item:updated, and treatitem_completedas a completion. - Compare with
event_when you need to know exactly what changed.data_ extra.old_ item
How Hookdeck Can Help: Hookdeck filters can route on event_, 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.
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.
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 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 for free and handle Todoist webhooks reliably in minutes.