Dovilo Webhooks
Have an AI agent scaffold the receiverDovilo can POST a signed JSON event to your endpoint whenever a task in one of your projects changes. Use it to bridge Dovilo into Slack, Linear, your own automation, an LLM agent — anything that can accept an HTTPS request.
This document is the public reference for integrating with Dovilo webhooks. It covers events, payload schema, signature verification, retries, and the operational guarantees you should design your receiver around.
Integration prompt #
Paste this into Claude Code, Cursor, Windsurf, or any other AI coding agent and it will build you a webhook receiver in your project's stack, set up signature verification correctly, and end with a checklist for the Dovilo-side setup.
I want to receive task events from Dovilo (https://dovilo.app — a gamified
todo app) into this project and act on them. Please build a webhook
receiver here, then walk me through the Dovilo-side setup.
DO THIS IN ORDER:
────────────────────────────────────────────────────────────────────
STEP 1 — ASK ME FIRST
────────────────────────────────────────────────────────────────────
- What should happen when an event arrives? (Slack notification?
Linear sync? CI trigger? Database log? Hand off to an LLM?)
- Which stack should the receiver use? Match this project if obvious;
otherwise ask. (Node/Express, FastAPI, Go net/http, Cloudflare
Workers, Vercel function, etc.)
- Where will it be deployed and what's the public URL going to be?
For local dev, suggest ngrok / cloudflared / tailscale funnel.
────────────────────────────────────────────────────────────────────
STEP 2 — WRITE THE RECEIVER
────────────────────────────────────────────────────────────────────
Route: POST /webhooks/dovilo (or fit the project's conventions)
CRITICAL RULES:
- Read the RAW request body before any JSON parsing. The HMAC is
computed over the raw bytes; re-serializing breaks the signature.
- Verify `X-Dovilo-Signature` (format: `t=<unix-ms>,v1=<hex>`):
1. Parse `t` and `v1`.
2. Reject if `|Date.now() - t|` > 5 minutes (replay protection).
3. Compute `HMAC_SHA256(secret, `${t}.${rawBody}`)`, hex-encode.
4. Compare to `v1` in CONSTANT TIME (timingSafeEqual /
hmac.compare_digest / hmac.Equal). Never use plain `==`.
- Use `X-Dovilo-Delivery` (also `id` in the body) as the idempotency
key — store it, drop duplicates. Same id is reused across retries.
- Respond 2xx on success, 401 on bad signature. Respond 410 ONLY if
the integration is permanently dead — Dovilo will disable the
webhook on its side when it sees 410.
- Read the signing secret from env var `DOVILO_WEBHOOK_SECRET`. Add
a placeholder to `.env.example`. NEVER commit the real secret.
────────────────────────────────────────────────────────────────────
STEP 3 — HANDLE THESE EVENT TYPES
────────────────────────────────────────────────────────────────────
- `task.created` — new task created.
- `task.status_changed` — body has `previousStatus` + `task.status`.
Status values: queued, running, awaiting-user,
done, failed, cancelled.
- `webhook.ping` — test event from Dovilo's "Send test" button.
Acknowledge with 2xx; don't trigger anything.
Payload shape (slim — these are ALL the fields you get):
{
"id": "evt_...", // also in X-Dovilo-Delivery
"type": "task.status_changed",
"createdAt": 1715900000000, // unix ms when queued
"projectId": "list_...",
"projectName": "Marketing site", // may be omitted
"previousStatus": "running" | null, // status_changed only
"task": {
"id": "tsk_...",
"text": "Draft launch announcement",
"status": "done",
"projectId": "list_...",
"createdAt": 1715800000000,
"updatedAt": 1715900000000, // USE THIS to order, not delivery time
"priority": "high", // optional: none|low|medium|high|urgent
"importance": true, // optional
"dueAt": 1716000000000, // optional, unix ms
"startedAt": 1715890000000, // optional
"completedAt": 1715900000000, // optional
"error": { "message": "...", "stepId": "..." }, // optional, on failed
"claimedByAgentId": "agent_...", // optional
"claimedByAgentName": "Claude Code", // optional
"telemetry": { // only on terminal transitions
"entries": 14, "tokensIn": 12483, "tokensOut": 3201,
"durationMs": 84120, "costEstimate": 0.0731, "truncated": false
}
}
}
Ordering: same task = FIFO. Different tasks = parallel, no order
guarantee. Sort by `task.updatedAt` if you need a total order.
────────────────────────────────────────────────────────────────────
STEP 4 — TEST LOCALLY
────────────────────────────────────────────────────────────────────
Show me how to:
- Run the receiver locally.
- Expose it on a public URL if needed (ngrok / cloudflared).
- What logs to watch.
────────────────────────────────────────────────────────────────────
STEP 5 — END WITH THIS CHECKLIST (substitute the real URL)
────────────────────────────────────────────────────────────────────
Print this checklist VERBATIM at the end, replacing `<YOUR_URL>` with
the actual public URL of the receiver you just built:
─────────────────────────────────────────────────────────────
FINAL STEP — set this up on the Dovilo side:
1. Open the Dovilo desktop app and open the project you want
events from.
2. Open the project editor and expand the "Webhooks" section.
3. Paste this URL into the URL field:
<YOUR_URL>
4. Click "Generate signing secret". The plaintext secret is
shown EXACTLY ONCE — copy it immediately.
5. Put the secret in your `.env` (or your host's secret manager):
DOVILO_WEBHOOK_SECRET=<paste-here>
Then restart the receiver so it picks up the env var.
6. Tick the events you want (task.created and/or
task.status_changed).
7. Toggle the project's webhook to "Enabled".
8. Click "Send test". You should see a 2xx in your receiver logs
within a second.
─────────────────────────────────────────────────────────────
────────────────────────────────────────────────────────────────────
GUARDRAILS
────────────────────────────────────────────────────────────────────
- Don't invent Dovilo features. If I ask for something this prompt
doesn't cover (inbound API, unsubscribe webhook, batch deliveries,
task.deleted events), say "not currently supported by Dovilo
webhooks — check https://dovilo.app/docs/webhooks" rather than
guessing or stubbing.
- The webhook payload is intentionally slim. If I need the full task
(description, agent notes, full telemetry, steps), tell me to
fetch it via Dovilo's MCP server using `get_task` / `list_tasks`.
Tip — for the fastest result, run it inside the project that will host the receiver, so the agent picks up your stack, conventions, and lint/test scripts.
Quick start #
- Open a project in Dovilo (desktop app) and expand the Webhooks section in the project editor.
- Paste your receiver URL (must be
https://for anything that isn'tlocalhost). - Click Generate signing secret. The plaintext is shown once — copy it into your receiver's secret store immediately.
- Pick which events you want (
task.created,task.status_changed). - Click Enable, then Send test to fire a
webhook.pingagainst your endpoint.
Once enabled, every matching task event in that project is delivered as a signed POST to the configured URL.
Transport #
| Property | Value |
|---|---|
| Method | POST |
| Content-Type | application/json |
| User-Agent | Dovilo-Webhook/1.0 |
| URL schemes | http:// (localhost only), https:// |
| Body encoding | UTF-8 JSON |
| Per-attempt timeout | 10 seconds |
Anything outside http(s):// is rejected before a request is made. Plain http:// to a non-localhost host is allowed but flagged with a warning in the UI — don't ship a production integration over cleartext.
Events #
Dovilo currently emits three event types. The set is intentionally small; we'd rather add events later than walk back a half-baked one.
| Event type | When it fires |
|---|---|
task.created | A new task is created in the project. |
task.status_changed | A task's status changes (e.g. queued → running → done). |
webhook.ping | Synthetic event sent by the Send test button. Single attempt, no retry. |
You select which events to subscribe to per project. webhook.ping is always delivered when the test button is pressed, regardless of subscription.
Task lifecycle reference #
task.status_changed carries a previousStatus field. The status values you will see in task.status and previousStatus:
queued → running → awaiting-user → running → done
→ failed
→ cancelled
awaiting-user is entered when an agent pauses for human input (plan approval, mid-task question). It transitions back to running once the user responds.
Headers #
Every delivery includes three Dovilo-specific headers in addition to the standard ones.
| Header | Example | Purpose |
|---|---|---|
X-Dovilo-Signature | t=1715900000000,v1=4b2a...c7 | HMAC signature + timestamp (see below) |
X-Dovilo-Event | task.status_changed | Event type — route without parsing body |
X-Dovilo-Delivery | evt_4f0b8a3e-7d2a-4c92-9b13-3e8a1f6c4d11 | Unique delivery id — use for deduplication |
The delivery id is stable across retries of the same delivery, so it is safe to use as an idempotency key in your receiver.
Payload schema #
The body is a JSON object. Field set is deliberately slim — just enough to route and decide whether to fetch more. Large or sensitive fields (description, agentNotes, context, steps, full telemetry) are not included; fetch the full task via MCP (get_task) if you need them.
Top-level #
{
"id": "evt_4f0b8a3e-7d2a-4c92-9b13-3e8a1f6c4d11",
"type": "task.status_changed",
"createdAt": 1715900000000,
"projectId": "list_2hP9...",
"projectName": "Marketing site",
"previousStatus": "running",
"task": { ... }
}
| Field | Type | Notes |
|---|---|---|
id | string | Same value as X-Dovilo-Delivery. Idempotency key. |
type | string | One of task.created, task.status_changed, webhook.ping. |
createdAt | number (unix ms) | Time the event was queued for delivery — not the task time. |
projectId | string | Dovilo project (custom list) id. |
projectName | string | undefined | Display label resolved at fire time. Omitted if project was deleted. |
previousStatus | string | null | Only present for task.status_changed. null on first transition. |
task | object | Slim task — see below. Absent on webhook.ping. |
task object #
{
"id": "tsk_8a1c...",
"text": "Draft launch announcement",
"status": "done",
"projectId": "list_2hP9...",
"createdAt": 1715800000000,
"updatedAt": 1715900000000,
"priority": "high",
"importance": true,
"dueAt": 1716000000000,
"startedAt": 1715890000000,
"completedAt": 1715900000000,
"error": { "message": "...", "stepId": "step_..." },
"claimedByAgentId": "agent_4d...",
"claimedByAgentName": "Claude Code",
"telemetry": {
"entries": 14,
"tokensIn": 12483,
"tokensOut": 3201,
"durationMs": 84120,
"costEstimate": 0.0731,
"truncated": false
}
}
| Field | Type | Notes |
|---|---|---|
id | string | Task id. |
text | string | Task title. |
status | string | Current status (see lifecycle reference). |
projectId | string | Mirrors top-level projectId. |
createdAt | number (unix ms) | Task creation time. |
updatedAt | number (unix ms) | Use this — not the event arrival time — to order events for the same task. |
priority | string | omitted | none | low | medium | high | urgent. |
importance | boolean | omitted | "Starred" flag. |
dueAt | number | omitted | Due date, unix ms. |
startedAt | number | omitted | When the task first entered running. |
completedAt | number | omitted | When the task reached a terminal status. |
error | object | omitted | { message, stepId? }. Present on failed, sometimes carried after recovery. |
claimedByAgentId | string | omitted | Id of the agent that picked up the task. |
claimedByAgentName | string | omitted | Display name resolved at fire time. |
telemetry | object | omitted | Only on terminal transitions (done, failed, cancelled). Aggregated totals. |
Fields with omitted mean: the key is absent from the JSON, not present with a null value. Your receiver should treat missing keys as "not set."
webhook.ping payload #
The test delivery uses a stripped-down body:
{
"id": "evt_...",
"type": "webhook.ping",
"createdAt": 1715900000000,
"projectId": "list_2hP9..."
}
No task, no signature differences — same headers, same HMAC scheme.
Signature verification #
Every delivery is signed with an HMAC-SHA256 of {timestamp}.{rawBody} using the per-project signing secret you generated in the UI. The format is modelled after Stripe's so it should look familiar.
Algorithm #
- Read
X-Dovilo-Signature. It looks liket=1715900000000,v1=4b2a...c7. - Parse out the timestamp (
t, unix ms) and the v1 signature (v1, hex). - Reject if
|now - t|exceeds the 5-minute replay window. - Compute
HMAC_SHA256(secret, "{t}.{rawBody}")and hex-encode. - Compare the computed hex to
v1in constant time.
Node.js (Express) #
const crypto = require('node:crypto');
const express = require('express');
const app = express();
// Keep the raw body around — JSON.parse re-serialization breaks the HMAC.
app.use(
'/webhooks/dovilo',
express.raw({ type: 'application/json' }),
);
const SECRET = process.env.DOVILO_WEBHOOK_SECRET;
const REPLAY_WINDOW_MS = 5 * 60 * 1000;
function verifyDoviloSignature(req) {
const header = req.get('x-dovilo-signature');
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1)];
}),
);
const t = Number(parts.t);
const v1 = parts.v1;
if (!Number.isFinite(t) || !v1) return false;
if (Math.abs(Date.now() - t) > REPLAY_WINDOW_MS) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${t}.${req.body.toString('utf8')}`)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(v1, 'utf8');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
app.post('/webhooks/dovilo', (req, res) => {
if (!verifyDoviloSignature(req)) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// ... handle event ...
res.status(204).end();
});
Python (Flask) #
import hmac
import hashlib
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["DOVILO_WEBHOOK_SECRET"].encode("utf-8")
REPLAY_WINDOW_MS = 5 * 60 * 1000
def verify_dovilo_signature(req) -> bool:
header = req.headers.get("X-Dovilo-Signature", "")
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
v1 = parts["v1"]
except (KeyError, ValueError):
return False
if abs(int(time.time() * 1000) - t) > REPLAY_WINDOW_MS:
return False
raw = req.get_data() # bytes — do NOT use request.json
mac = hmac.new(SECRET, f"{t}.".encode("utf-8") + raw, hashlib.sha256)
return hmac.compare_digest(mac.hexdigest(), v1)
@app.post("/webhooks/dovilo")
def dovilo_webhook():
if not verify_dovilo_signature(request):
abort(401)
event = request.get_json(force=True)
# ... handle event ...
return "", 204
Go #
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const replayWindow = 5 * time.Minute
func verify(r *http.Request, secret []byte, body []byte) bool {
header := r.Header.Get("X-Dovilo-Signature")
var t int64
var v1 string
for _, p := range strings.Split(header, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) != 2 { continue }
switch kv[0] {
case "t":
n, err := strconv.ParseInt(kv[1], 10, 64)
if err != nil { return false }
t = n
case "v1":
v1 = kv[1]
}
}
if t == 0 || v1 == "" { return false }
now := time.Now().UnixMilli()
if abs(now-t) > replayWindow.Milliseconds() { return false }
mac := hmac.New(sha256.New, secret)
mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
mac.Write(body)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(v1))
}
func handle(w http.ResponseWriter, r *http.Request) {
secret := []byte(os.Getenv("DOVILO_WEBHOOK_SECRET"))
body, _ := io.ReadAll(r.Body)
if !verify(r, secret, body) {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
// ... handle event ...
w.WriteHeader(http.StatusNoContent)
}
func abs(x int64) int64 { if x < 0 { return -x }; return x }
Common verification mistakes #
- Re-serializing the body. If you
JSON.parse → JSON.stringifybefore hashing, byte-for-byte differences (key order, whitespace) will break the signature. Hash the raw bytes. - String comparison instead of constant time. Use
timingSafeEqual/hmac.compare_digest/hmac.Equal. Plain==leaks timing info. - Skipping the timestamp check. Without it, a captured payload can be replayed forever. Enforce the 5-minute window.
- Trusting the body before verifying. Verify the signature on the raw bytes first, then parse the JSON.
Responses Dovilo expects #
Your receiver's HTTP status determines what happens next.
| Status range | Dovilo's behavior |
|---|---|
2xx | Delivered. Done. |
429 Too Many Requests | Treated as transient. Retried per the schedule below. Retry-After is surfaced in failure logs but the next attempt still comes from the standard backoff. |
4xx (other than 429) | Permanent failure. No retry. Recorded in the failures buffer. |
410 Gone | Permanent failure and Dovilo disables the webhook for this project. |
5xx | Treated as transient. Retried per the schedule below. |
| Connection error / DNS / TLS | Treated as transient. Retried. |
| Timeout (>10s) | Treated as transient. Retried. |
Respond quickly — under a few hundred ms is ideal. If your processing is slow, accept the delivery (202) and queue the work asynchronously rather than holding the request open.
Use 410 Gone deliberately. It is the documented "this endpoint is dead, stop sending" signal — Dovilo will flip the project's webhook to disabled on receipt.
Retry policy #
Failed transient deliveries are retried up to 5 times on this schedule:
| Attempt | Wait before attempt |
|---|---|
| 1 | 0 |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 2 minutes |
| 5 | 10 minutes |
| 6 | 1 hour |
Total delivery window is therefore approximately 1 hour 15 minutes. After the final attempt, the delivery is dropped and recorded in the failures ring buffer visible in the project editor (last 100 entries per project).
The buffer is in-memory on the desktop client — it is not persisted across app restarts. Treat it as a debugging aid, not an audit log.
Ordering and concurrency #
- Same task: events are delivered in FIFO order. A retry of an older event will not pass a newer event for the same task.
- Different tasks: events run in parallel. There is no ordering guarantee across tasks.
- Receivers should not rely on delivery time. Use
task.updatedAt(andpreviousStatusfor transitions) to reconstruct order.
Idempotency #
Use X-Dovilo-Delivery (also available as id in the body) as your idempotency key. The same delivery id is reused across retries of the same event, so deduplication on this key is both safe and correct.
Recommended pattern: insert the delivery id into a unique-keyed table inside the same transaction as your side effects. On retry, the duplicate key collision becomes a no-op.
Security #
- The signing secret is the only thing standing between your endpoint and a spoofed Dovilo request. Treat it like a password.
- Anyone who learns the secret can forge requests that pass verification.
- Rotate the secret if you suspect compromise — generating a new one in the UI immediately invalidates the previous secret.
- Plaintext is revealed exactly once, when generated. There is no way to retrieve it later — generate, copy into your secret store, move on.
- The secret is stored locally by the desktop client using OS-level secure storage (Keychain / Credential Manager / libsecret). It is not written to Firestore and is not synced across devices.
Operational notes #
- Desktop-bound delivery. Webhooks fire from the Dovilo desktop app process — they will not deliver when the app is closed. Events that occur while the app is closed are not queued for later delivery; they are simply not sent. Tasks already present in Firestore at app launch are treated as historical state, not new events, and do not fire.
- Mobile- and agent-originated changes fire too. While the desktop is running, any task created or transitioned by mobile or an MCP agent in another process is observed via Firestore sync and fires the same events as a local write. The desktop persists per-task dedup markers (
webhookCreatedFiredAt,webhookLastSentStatus) inside the same Firestore write, so a snapshot echo or a second connected desktop observing the same change will not double-fire. - Multi-desktop dedup. When two desktops are signed in to the same account they race for each cloud-driven event via a Firestore transactional compare-and-swap on the dedup markers. Only the winner delivers — receivers do not need to dedup across desktops on top of
X-Dovilo-Delivery. - Slim payloads on purpose. Bulky and sensitive fields are excluded to reduce both bandwidth and the chance of leaking prompt content into third-party integrations. Use MCP (
get_task,list_tasks) to fetch the full task when you need it. - No webhook events for deleted tasks. A
task.deletedevent is not currently emitted. If you need to know about deletions, reconcile periodically through MCP.
Changelog #
- v0.6.0 — Initial outbound webhook support. Events:
task.created,task.status_changed,webhook.ping. HMAC-SHA256 signing. 5-attempt retry. Per-task FIFO. - v0.6.x — Mobile- and agent-driven changes now fire via Firestore sync diff (previously only desktop-initiated writes fired). Persisted per-task dedup markers prevent snapshot-echo refire and double-fire across multiple connected desktops.