Skip to content

Webhooks

Webhooks push events out of Conatus as they happen. Register endpoints in Settings → Webhooks.

The URL must use HTTPS, or be on localhost for local development. Anything else is rejected.

When the endpoint is created you are shown a secret, once. Copy it. Only its stored copy remains, and every payload is signed with it. If you lose it, delete the endpoint and create a new one.

Each endpoint shows its status (Active or Disabled) and its consecutive failure count. A disabled endpoint has a Re-enable button, which also resets it.

Event Fires when
task.created A task is created
task.completed A task is completed
task.uncompleted A completed task is reopened
task.deleted A task is moved to Trash
comment.added A comment is posted on a task or a project
project.created A project is created
project.archived A project is archived
project.deleted A project is moved to Trash

These are exactly the events the activity log records. Field edits do not fire webhooks: renaming, re-prioritising, rescheduling, reordering, label changes.

Events are scoped to you: your endpoints receive events from your actions, not from a collaborator working in a project you share.

Every delivery is a POST with this body:

{
"type": "task.completed",
"taskContent": "Renew passport",
"projectId": "9c1f…",
"projectName": "Admin",
"occurredAt": "2026-03-03T09:14:22.104Z"
}

taskContent and projectName are snapshots taken at the time of the event, so a task.deleted payload still names what was deleted. For a project-level event, taskContent carries the project name. projectId may be null.

Each request carries X-Webhook-Signature: the HMAC-SHA256 of the raw request body, keyed with the endpoint’s secret. Compute it over the bytes you received, before any JSON parsing or re-serialisation. A re-encoded body will not match.

Node.js:

import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody, header, secret) {
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header ?? "");
return a.length === b.length && timingSafeEqual(a, b);
}

Python:

import hashlib, hmac
def verify(raw_body: bytes, header: str, secret: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")

Always compare in constant time, and reject anything that does not match.

  • Deliveries go through a durable job queue in PostgreSQL, so a restart mid-flight does not lose them.
  • Each attempt times out after 10 seconds.
  • Any non-2xx response, or a timeout, counts as a failure and is retried up to 5 times with backoff.
  • A successful delivery resets the endpoint’s consecutive failure count to zero.
  • After 20 consecutive failures the endpoint is disabled automatically. It stops receiving deliveries until you re-enable it in Settings.

Failures are also reported through ERROR_WEBHOOK_URL if you have configured one.

Ordering is not guaranteed. Use occurredAt if you need to sequence events, and make your handler idempotent. A retry can deliver the same event twice.

localhost URLs are accepted, so you can point an endpoint at a local listener while Conatus runs in Compose on the same machine. From inside the container the host is reachable as host.docker.internal on Docker Desktop; on Linux, add the host-gateway mapping or use the host’s LAN address.