← Dovilo

Dovilo Webhooks

Have an AI agent scaffold the receiver

Dovilo 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.

AI integration prompt
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 #

  1. Open a project in Dovilo (desktop app) and expand the Webhooks section in the project editor.
  2. Paste your receiver URL (must be https:// for anything that isn't localhost).
  3. Click Generate signing secret. The plaintext is shown once — copy it into your receiver's secret store immediately.
  4. Pick which events you want (task.created, task.status_changed).
  5. Click Enable, then Send test to fire a webhook.ping against your endpoint.

Once enabled, every matching task event in that project is delivered as a signed POST to the configured URL.

Transport #

PropertyValue
MethodPOST
Content-Typeapplication/json
User-AgentDovilo-Webhook/1.0
URL schemeshttp:// (localhost only), https://
Body encodingUTF-8 JSON
Per-attempt timeout10 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 typeWhen it fires
task.createdA new task is created in the project.
task.status_changedA task's status changes (e.g. queuedrunningdone).
webhook.pingSynthetic 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.

HeaderExamplePurpose
X-Dovilo-Signaturet=1715900000000,v1=4b2a...c7HMAC signature + timestamp (see below)
X-Dovilo-Eventtask.status_changedEvent type — route without parsing body
X-Dovilo-Deliveryevt_4f0b8a3e-7d2a-4c92-9b13-3e8a1f6c4d11Unique 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": { ... }
}
FieldTypeNotes
idstringSame value as X-Dovilo-Delivery. Idempotency key.
typestringOne of task.created, task.status_changed, webhook.ping.
createdAtnumber (unix ms)Time the event was queued for delivery — not the task time.
projectIdstringDovilo project (custom list) id.
projectNamestring | undefinedDisplay label resolved at fire time. Omitted if project was deleted.
previousStatusstring | nullOnly present for task.status_changed. null on first transition.
taskobjectSlim 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
  }
}
FieldTypeNotes
idstringTask id.
textstringTask title.
statusstringCurrent status (see lifecycle reference).
projectIdstringMirrors top-level projectId.
createdAtnumber (unix ms)Task creation time.
updatedAtnumber (unix ms)Use this — not the event arrival time — to order events for the same task.
prioritystring | omittednone | low | medium | high | urgent.
importanceboolean | omitted"Starred" flag.
dueAtnumber | omittedDue date, unix ms.
startedAtnumber | omittedWhen the task first entered running.
completedAtnumber | omittedWhen the task reached a terminal status.
errorobject | omitted{ message, stepId? }. Present on failed, sometimes carried after recovery.
claimedByAgentIdstring | omittedId of the agent that picked up the task.
claimedByAgentNamestring | omittedDisplay name resolved at fire time.
telemetryobject | omittedOnly 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 #

  1. Read X-Dovilo-Signature. It looks like t=1715900000000,v1=4b2a...c7.
  2. Parse out the timestamp (t, unix ms) and the v1 signature (v1, hex).
  3. Reject if |now - t| exceeds the 5-minute replay window.
  4. Compute HMAC_SHA256(secret, "{t}.{rawBody}") and hex-encode.
  5. Compare the computed hex to v1 in constant time.
Critical: Use the raw, unparsed request body bytes. Frameworks that auto-parse JSON often discard the original bytes; re-serializing the parsed object will produce a different signature.

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 #

Responses Dovilo expects #

Your receiver's HTTP status determines what happens next.

Status rangeDovilo's behavior
2xxDelivered. Done.
429 Too Many RequestsTreated 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 GonePermanent failure and Dovilo disables the webhook for this project.
5xxTreated as transient. Retried per the schedule below.
Connection error / DNS / TLSTreated 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:

AttemptWait before attempt
10
25 seconds
330 seconds
42 minutes
510 minutes
61 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 #

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 #

Operational notes #

Changelog #