--- title: Webhooks summary: Events, payload, signature checks, retries and recipes for Home Assistant, n8n and Telegram. audience: developer section: Integrations specs: [webhooks] screens: [] order: 30 --- # Webhooks OpenTodo can notify other systems when your tasks and projects change: switch on a light when "Water plants" is completed, post to a chat when a task is created, start an n8n flow when a project is archived. Webhooks are configured per account in **Settings → Integrations → Webhooks** (choose **Add webhook**, then pick the target URL, events and an optional project) or through the [API](api.md#webhooks). Each webhook's **⋯** menu has **Send test**, **Delivery log**, **Edit**, **Disable** or **Enable**, and **Delete**, which asks you to confirm. Nothing is sent until you create one, and requests only ever go to the URLs you configured. ## Events | Event | Fires when | | --- | --- | | `task.created` | a task appears for the first time (`created_at` is set) | | `task.updated` | any other field of a task changes (title, notes, due date, priority, project, labels, position, undelete…) | | `task.completed` | `completed_at` goes from empty to a time | | `task.reopened` | `completed_at` goes from a time back to empty | | `task.deleted` | the task gets a tombstone (`deleted_at` is set) | | `project.created` | a project appears for the first time | | `project.updated` | any other field of a project changes, including un-archiving | | `project.archived` | `archived` goes from false to true | | `project.deleted` | the project gets a tombstone | | `import.completed` | an [import](import.md) job finished (done or failed): one summary for the whole job, see [Imports](#imports) | | `import.undone` | an import was undone: one summary, see [Imports](#imports) | | `rule.fired` | an [automation rule](automations.md) with a webhook action changed a task: sent only to the webhook the rule names, with `data.task`, `data.rule` (`id`, `name` as stored, ciphertext for an encrypted account) and the rule's changes | | `ping` | you pressed **Send test** (not subscribable, always sent on request) | Task and project events are derived on the server from the operation log, whatever wrote the change: a phone or browser syncing, the REST API, the `ot` CLI and CalDAV. Imports are the exception: they are reported once per job (see [Imports](#imports)). Rules: - **One event per entity per batch.** A sync batch that creates a task and sets six fields produces one `task.created`, not seven events. When several rules apply the first wins, in this order: created, deleted, completed, reopened, archived, updated. - **Only real changes.** An edit that loses under last-writer-wins (an older offline change arriving after a newer one), a duplicate retry, or a write of the value the field already had produces no event. - **Offline edits fire when they sync.** A task completed on a phone in airplane mode fires `task.completed` when the phone reconnects. `time` in the payload is the time the server saw the change; the task's own `completed_at` is when you did it. - **Project filter.** A webhook can be limited to one project. Task events then fire only for tasks in that project (or moved into or out of it); project events only for that project. - Webhooks only see your own data. ## Payload (version 1) Every delivery is a `POST` with a JSON body: ```json { "version": 1, "id": "6c4f2f0a-8a8e-4d7e-9d7f-3c1b2a0e9f11", "event": "task.completed", "time": 1760060001000, "webhook_id": "0b9a2e4c-…", "source": "device", "device": "e4656b83-…", "data": { "task": { "id": "…", "title": "Water plants", "description": "", "project_id": null, "parent_id": null, "priority": 4, "due": {"date": "2026-10-10"}, "labels": [], "position": 1760000000000, "created_at": 1760000000000, "completed_at": 1760060000000, "deleted_at": null, "origin": null } }, "changes": { "completed_at": { "from": null, "to": 1760060000000 } } } ``` | Field | Meaning | | --- | --- | | `version` | payload format version; additive changes keep `1` | | `id` | unique delivery id; also in `X-OpenTodo-Delivery`. Use it to de-duplicate | | `event` | event name, also in `X-OpenTodo-Event` | | `time` | when the server derived the event, Unix milliseconds | | `webhook_id` | the webhook that matched, also in `X-OpenTodo-Webhook` | | `source` | `device` (a syncing app), `server` (REST API / CLI), `import`, `caldav` or `rule` (an [automation rule](automations.md)). Lets a receiver ignore changes it caused itself | | `device` | the device id when `source` is `device` | | `data.task` / `data.project` | the entity's current state with the same fields as the [REST API](api.md#task-fields), except the opaque note document `notes_doc` (the note's text is `description`). `ping` carries `data: {}` | | `changes` | the fields that changed in this batch with their previous (`from`) and new (`to`) values; `from` is `null` when the field had no value before. `notes_doc` is never listed, and a change that only touched it (a merged note republished with the same text) sends no event | A task's `origin` (where it came from, `null` when unset; see [Where a task came from](capture.md#where-a-task-came-from)) travels like every field: a task created from a shared GitHub issue page arrives as `task.created` with `"origin": {"kind": "issue", "provider": "github", "id": "o/r#7", "url": "https://github.com/o/r/issues/7", "title": "…"}` in `data.task` and `"origin": {"from": null, "to": {…}}` in `changes`, so a receiver can link the task back to the issue without a second request. Projects and tasks carry `workspace_id`, the [workspace](workspaces.md) they belong to (`null` for the default workspace). A task with a project belongs to its project's workspace, so look the project up for those. Subscriptions keep their project filter; there is no workspace filter. A task's [custom field](custom-fields.md) values are in `data.task.fields`, an object keyed by field definition id like the REST API's; a changed value is listed in `changes` as `fields.` (for example `"fields.f1": {"from": 120, "to": 150}`), with the `task.updated` event. ### Encrypted accounts For an account with [end-to-end encryption](e2ee.md), content fields in payloads (`title`, `description`, `name`, a task's `origin`) carry the stored ciphertext (`"enc:v1:…"`); the server cannot decrypt them. Dates, priority and completion stay plaintext. ### Imports An import can write tens of thousands of tasks; one delivery per task would flood a receiver and the delivery queue. So the changes an import (or its undo) writes never produce `task.*` or `project.*` events. Instead each finished job produces **one** `import.completed` delivery, and each undo **one** `import.undone`, per webhook subscribed to that event. Subscribe to them explicitly; a webhook that only lists `task.created` sees nothing from imports. Changes you make afterwards to imported tasks fire the usual events. ```json { "version": 1, "id": "…", "event": "import.completed", "time": 1760060001000, "webhook_id": "…", "source": "import", "data": { "import": { "id": "", "format": "csv", "status": "done", "totals": { "rows": 120, "create": 120, "written": 121, "ops": 360, "...": 0 }, "counts": { "task": { "created": 120 }, "project": { "created": 1 } }, "project_ids": [""] } }, "changes": {} } ``` | Field | Meaning | | --- | --- | | `data.import.id` | the import job (`GET /api/v1/import/jobs/{id}`) | | `status` | `done` or `failed` for `import.completed` (`message` then says why), `undone` for `import.undone` | | `totals` | the job's totals as the import API reports them | | `counts` | entities written by this run, per kind (`task`, `project`, `label`, `section`, `reminder`, `view`) and action: `created` and `updated` for an import, `deleted` for an undo | | `project_ids` | the projects the run created or wrote tasks into, sorted | The importing user's webhooks receive every touched project. When an import writes into a shared project, each other member's subscribed webhooks receive the same summary with `project_ids` limited to the shared projects they belong to. A webhook with a project filter receives the summary only when its project is in `project_ids`. Summaries are queued, signed, retried and logged like every other delivery. ## Headers and signature | Header | Example | | --- | --- | | `Content-Type` | `application/json` | | `User-Agent` | `OpenTodo/` | | `X-OpenTodo-Event` | `task.completed` | | `X-OpenTodo-Delivery` | delivery id (same as `id` in the body) | | `X-OpenTodo-Webhook` | webhook id | | `X-OpenTodo-Timestamp` | Unix **seconds** when this attempt was sent | | `X-OpenTodo-Signature` | `v1=` | The signature is `hex(HMAC-SHA256(key = secret, message = "."))`, prefixed with `v1=`. The key is the whole secret exactly as shown when you created the webhook, including the `whsec_` prefix. To verify: 1. Read the raw body bytes before parsing JSON (re-serialising changes the bytes). 2. Recompute the HMAC over `timestamp + "." + body` and compare it to the header with a constant-time comparison. 3. Reject the request if the timestamp is too old (five minutes is a good tolerance) to stop replays. Each retry and each redelivery is signed again with a fresh timestamp, so signatures differ between attempts while the body stays the same. ### Test vector Use this to check your implementation: | Input | Value | | --- | --- | | secret | `whsec_opentodo-test-vector-secret-do-not-use` | | timestamp | `1760060001` | | body | `{"version":1,"id":"7d3c1f0e-0000-4000-8000-000000000001","event":"ping","time":1760060001000,"webhook_id":"0b9a2e4c-0000-4000-8000-000000000002","source":"server","data":{},"changes":{}}` | | signature | `v1=a049099332920d6f2b44a294b46fc5260f87dfc36294f77069b5ab0058589b98` | The server's own test suite (`TestSignatureMatchesReferenceVector` in `internal/webhook`) checks the same vector. **Shell** (OpenSSL): ```bash secret='whsec_opentodo-test-vector-secret-do-not-use' timestamp='1760060001' body='{"version":1,"id":"7d3c1f0e-0000-4000-8000-000000000001","event":"ping","time":1760060001000,"webhook_id":"0b9a2e4c-0000-4000-8000-000000000002","source":"server","data":{},"changes":{}}' printf '%s' "$timestamp.$body" | openssl dgst -sha256 -hmac "$secret" -hex | sed 's/^.*= /v1=/' # v1=a049099332920d6f2b44a294b46fc5260f87dfc36294f77069b5ab0058589b98 ``` **Python** (standard library): ```python import hashlib, hmac, time def verify(secret: str, timestamp: str, body: bytes, signature: str, tolerance: int = 300) -> bool: if abs(time.time() - int(timestamp)) > tolerance: return False # too old: possible replay expected = "v1=" + hmac.new(secret.encode(), timestamp.encode() + b"." + body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, signature) # Test vector (tolerance disabled because the vector is old): body = b'{"version":1,"id":"7d3c1f0e-0000-4000-8000-000000000001","event":"ping","time":1760060001000,"webhook_id":"0b9a2e4c-0000-4000-8000-000000000002","source":"server","data":{},"changes":{}}' print(verify("whsec_opentodo-test-vector-secret-do-not-use", "1760060001", body, "v1=a049099332920d6f2b44a294b46fc5260f87dfc36294f77069b5ab0058589b98", tolerance=10**10)) # True ``` **JavaScript** (Node.js 18+, no dependencies): ```js const crypto = require('node:crypto') function verify(secret, timestamp, rawBody, signature, toleranceSec = 300) { if (Math.abs(Date.now() / 1000 - Number(timestamp)) > toleranceSec) return false // replay const expected = 'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.`).update(rawBody).digest('hex') const a = Buffer.from(expected), b = Buffer.from(signature) return a.length === b.length && crypto.timingSafeEqual(a, b) } // Test vector (tolerance disabled because the vector is old): const body = '{"version":1,"id":"7d3c1f0e-0000-4000-8000-000000000001","event":"ping","time":1760060001000,"webhook_id":"0b9a2e4c-0000-4000-8000-000000000002","source":"server","data":{},"changes":{}}' console.log(verify('whsec_opentodo-test-vector-secret-do-not-use', '1760060001', body, 'v1=a049099332920d6f2b44a294b46fc5260f87dfc36294f77069b5ab0058589b98', Infinity)) // true ``` In an Express app use `express.raw({ type: 'application/json' })` for the webhook route so `req.body` is the raw `Buffer`. ## Delivery, retries and the log - Deliveries are queued in the database, so a restart loses nothing: anything pending is sent after the server starts again. - Each attempt times out after **10 seconds**. Any `2xx` answer is success; everything else (other status codes, timeouts, connection errors, redirects) is a failure. - A failed delivery is retried up to **six times** after the first attempt, waiting 30 s, 5 min, 30 min, 2 h, 8 h and 16 h (each ±20 % jitter), about 27 hours in total. After the last retry the delivery is marked `failed`. - Delivery is **at least once** and **not ordered**. A receiver that answers slowly or crashes after processing may see the same delivery again; de-duplicate on `id`. - After **20 consecutive failed deliveries** (each having used all its retries) the webhook is disabled automatically. Settings shows "Disabled after repeated failures" (`disabledReason: "too_many_failures"`); nothing more is queued until you enable it again, which also resets the counter. A successful delivery resets the counter too. - **Send test** sends a `ping` right away (one attempt, no retries, not counted toward auto-disable) and shows the status code and duration. - The **delivery log** keeps the last 100 deliveries per webhook with status (`pending`, `retry`, `delivered`, `failed`), number of attempts, last status code, last error, duration and the first kilobyte of the response body. Entries older than 30 days are removed. **Redeliver** queues the same body again with a new delivery id. ## Private network protection A server that sends HTTP requests on behalf of its users could be abused to reach things that are not meant to be reachable: the router's admin page, other containers, a cloud metadata endpoint. By default OpenTodo therefore refuses webhook targets on: - loopback (`127.0.0.0/8`, `::1`), unspecified (`0.0.0.0`, `::`) and "this network" (`0.0.0.0/8`), - private ranges (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), carrier-grade NAT and Tailscale (`100.64.0.0/10`), unique-local IPv6 (`fc00::/7`, including `fd00::/8`), - link-local (`169.254.0.0/16`, `fe80::/10`), which includes the cloud metadata address `169.254.169.254`, - multicast, broadcast and reserved ranges, and IPv4 addresses embedded in IPv6 (`::ffff:10.0.0.1`, NAT64, 6to4). How it is enforced: - Only `http://` and `https://` URLs, at most 2,048 characters. - The check runs when a webhook is created or its URL is changed (400 `private_target`) and again on **every delivery**: the server resolves the host name itself, drops denied addresses and connects to the address it checked, so a DNS answer that changes later (DNS rebinding) cannot sneak in a private address. If every address is denied, the delivery fails with `private_target` and no connection is made. - Redirects are never followed; a `3xx` answer counts as a failure. - The server ignores `HTTP_PROXY`/`HTTPS_PROXY` for webhook deliveries, because a proxy would make the connection on its behalf and bypass the check. ### Allowing receivers on your LAN Home Assistant, n8n or Node-RED often run on the same network as OpenTodo. The **owner** can switch on **Settings → Integrations → Webhooks → Allow private network targets** (or `PUT /api/v1/settings/webhooks` with `{"allowPrivate": true}`). This applies to every account on the server, and to [calendar subscriptions](api.md#calendar-feeds) and [issue connections](issue-import.md) too, so leave it off on a public multi-user server. The scheme restriction and the no-redirect rule stay in force, and so does the refusal of unspecified and "this network" addresses (`0.0.0.0/8`, `::`), link-local addresses (`169.254.0.0/16`, `fe80::/10`) and cloud metadata addresses (`169.254.169.254`, `fd00:ec2::254`, `100.100.100.200`): no LAN receiver lives there. **Test** and **Redeliver** share a limit of 30 per account per hour; beyond it they answer `429 rate_limited`. For deployments managed as code, the optional `OPENTODO_WEBHOOKS_ALLOW_PRIVATE=true` presets the toggle on the first start. After that the value in Settings wins, so the owner can still turn it off. ### Secrets The secret (`whsec_…`) is shown once, in the dialog right after the webhook is created, with a **Copy** button. It is stored in the server database in plaintext because the server needs it to sign each request; the database file is the trust boundary, just as for sessions and API tokens. If a secret leaks, delete the webhook and create a new one. Webhooks are server configuration: they are not part of the JSON export and not synced to devices. Settings warns when a target uses plain `http://` outside this machine: the payload contains task content and would cross the network unencrypted. ## Receiver recipes ### Home Assistant The [Home Assistant integration](home-assistant.md) sets up a signed webhook like this for you, checks the signature, and turns every change into an `opentodo_task_*` event, next to to-do lists, counts and a calendar. The recipe below is for a single automation without the integration. 1. In Home Assistant, create an automation with a **Webhook** trigger. Note the webhook id, for example `opentodo-plants`. Leave "Only accessible from the local network" on if OpenTodo runs on your LAN. 2. As the owner, enable **Allow private network targets** in OpenTodo (Home Assistant usually lives at `http://192.168.x.x:8123`). 3. In OpenTodo, add a webhook to `http://:8123/api/webhook/opentodo-plants` with the event `task.completed`, optionally limited to a "Home" project. 4. In the automation, add a condition on the payload and an action: ```yaml alias: Plants watered triggers: - trigger: webhook webhook_id: opentodo-plants allowed_methods: [POST] local_only: true conditions: - condition: template value_template: "{{ trigger.json.event == 'task.completed' and 'plants' in trigger.json.data.task.title | lower }}" actions: - action: light.turn_on target: entity_id: light.kitchen ``` Home Assistant's webhook trigger does not check signatures; the long random webhook id acts as the shared secret, so keep it unguessable and keep `local_only` on. Use n8n (below) in front of Home Assistant if you want signature verification. ### n8n The [OpenTodo nodes for n8n](n8n.md) include a trigger that creates a signed webhook like this for you, checks the signature and removes the webhook when the workflow is turned off, next to nodes that create and change tasks. The recipe below uses n8n's plain Webhook node instead. 1. Add a **Webhook** node, method `POST`, and under *Options* enable **Raw Body** so the exact bytes are available for verification. Copy the production URL. 2. In OpenTodo, add a webhook with that URL and the events you need. If n8n runs on your LAN, the owner enables private network targets first. 3. Add a **Code** node after the webhook to verify the signature (set the secret as an n8n credential or environment variable rather than in the code): ```js const crypto = require('crypto') const secret = $env.OPENTODO_WEBHOOK_SECRET const item = $input.first() const headers = item.json.headers const raw = Buffer.from(item.binary.data.data, 'base64') // raw body from the Webhook node const expected = 'v1=' + crypto.createHmac('sha256', secret).update(headers['x-opentodo-timestamp'] + '.').update(raw).digest('hex') const fresh = Math.abs(Date.now() / 1000 - Number(headers['x-opentodo-timestamp'])) < 300 if (!fresh || expected !== headers['x-opentodo-signature']) throw new Error('bad OpenTodo signature') return [{ json: JSON.parse(raw.toString('utf8')) }] ``` On self-hosted n8n, allow the `crypto` module in the Code node (`NODE_FUNCTION_ALLOW_BUILTIN=crypto`). Then branch on `{{$json.event}}` with a **Switch** node. 4. Press **Send test** in OpenTodo: n8n receives a `ping`. ### Telegram Telegram's Bot API cannot consume an arbitrary JSON body, so put a small relay in between. The simplest is n8n: after the verification step above, add a **Telegram** node with the text `✅ {{$json.data.task.title}}` for `task.completed`. Without n8n, a few lines of Python on the same host do the job: ```python # telegram_relay.py: python3 telegram_relay.py (listens on 127.0.0.1:8099) import hashlib, hmac, http.server, json, os, time, urllib.parse, urllib.request SECRET = os.environ["OPENTODO_WEBHOOK_SECRET"] BOT, CHAT = os.environ["TELEGRAM_BOT_TOKEN"], os.environ["TELEGRAM_CHAT_ID"] class Relay(http.server.BaseHTTPRequestHandler): def do_POST(self): body = self.rfile.read(int(self.headers["Content-Length"])) ts, sig = self.headers["X-OpenTodo-Timestamp"], self.headers["X-OpenTodo-Signature"] expected = "v1=" + hmac.new(SECRET.encode(), ts.encode() + b"." + body, hashlib.sha256).hexdigest() if abs(time.time() - int(ts)) > 300 or not hmac.compare_digest(expected, sig): self.send_response(401); self.end_headers(); return event = json.loads(body) task = event.get("data", {}).get("task") or {} text = {"task.created": "🆕 ", "task.completed": "✅ "}.get(event["event"], event["event"] + ": ") + task.get("title", "") data = urllib.parse.urlencode({"chat_id": CHAT, "text": text}).encode() urllib.request.urlopen(f"https://api.telegram.org/bot{BOT}/sendMessage", data, timeout=5) self.send_response(204); self.end_headers() http.server.HTTPServer(("127.0.0.1", 8099), Relay).serve_forever() ``` Point an OpenTodo webhook at `http://127.0.0.1:8099/` (owner: allow private network targets). The relay, not OpenTodo, talks to Telegram.