darvin webhooks — integrator guide (M2.3.6)

Outbound events are POSTed from darvin to your endpoint with an
HMAC-SHA256 signature. The same secret verifies every event; rotating
the secret (via the dashboard) yields a new plaintext value and stashes
the previous one for a 24-hour grace period so in-flight deliveries
don't fail during rotation.

Events

Name Fires on
link.created Every successful createLink
link.updated Every successful updateUserLink
link.deleted Every soft-delete via the dashboard
link.threshold_hit Click counter crosses a configured threshold (M2.1 thresholdAlerts)
profile.view Public view on a linkinbio profile (M2.9 — future)

Subscribe per-webhook via the dashboard at /app/settings/webhooks.

Payload shape

{
  "event": "link.created",
  "data": {
    "id": 1234,
    "shortUrl": "abc123",
    "longUrl": "https://example.com/page",
    "isCustom": false,
    "createdAt": "2026-04-22T05:00:00.000Z"
  },
  "deliveredAt": "2026-04-22T05:00:01.234Z"
}

Headers

Every delivery carries:

Header Meaning
Content-Type: application/json
User-Agent: darvin-webhooks/1.0
X-Darvin-Event event name, matches payload event
X-Darvin-Delivery UUID per attempt (idempotency key)
X-Darvin-Timestamp Unix seconds — used to sign + reject stale
X-Darvin-Signature-256 sha256=<hex> of hmac_sha256(secret, "${timestamp}.${body}")
X-Darvin-Attempt Integer — starts at 1, bumped on each retry

Retry + DLQ

Failed deliveries retry on exponential backoff: 0 / 1m / 5m / 30m /
2h / 12h
(6 total attempts). After the final attempt the delivery is
marked failed_terminal in the log. 15 consecutive failures
auto-disable the webhook
— re-enable from the dashboard after fixing
the endpoint.

Verifying signatures

Reject requests older than 5 minutes (clock drift window).

Node.js

import crypto from 'node:crypto';
import express from 'express';

const app = express();
app.post('/webhooks/darvin',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const secret = process.env.DARVIN_WEBHOOK_SECRET;
    const timestamp = req.header('x-darvin-timestamp');
    const signatureHeader = req.header('x-darvin-signature-256') || '';
    const body = req.body.toString('utf8');

    // Freshness — reject > 5 min drift.
    const now = Math.floor(Date.now() / 1000);
    if (!timestamp || Math.abs(now - Number(timestamp)) > 300) {
      return res.status(400).send('stale');
    }

    const expected = crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${body}`, 'utf8')
      .digest('hex');
    const provided = signatureHeader.replace(/^sha256=/, '');

    const ok =
      expected.length === provided.length &&
      crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(provided, 'hex'));
    if (!ok) return res.status(401).send('bad signature');

    const payload = JSON.parse(body);
    console.log('event:', payload.event, 'data:', payload.data);
    res.status(200).end();
  },
);

Python (Flask)

import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["DARVIN_WEBHOOK_SECRET"].encode("utf-8")

@app.post("/webhooks/darvin")
def darvin_webhook():
    timestamp = request.headers.get("X-Darvin-Timestamp")
    signature_header = request.headers.get("X-Darvin-Signature-256", "")
    body = request.get_data(as_text=True)

    # Freshness — reject > 5 min drift.
    now = int(time.time())
    if not timestamp or abs(now - int(timestamp)) > 300:
        abort(400, description="stale")

    expected = hmac.new(
        SECRET,
        f"{timestamp}.{body}".encode("utf-8"),
        hashlib.sha256,
    ).hexdigest()
    provided = signature_header.removeprefix("sha256=")

    if not hmac.compare_digest(expected, provided):
        abort(401, description="bad signature")

    payload = request.get_json(force=True)
    print("event:", payload["event"], "data:", payload["data"])
    return "", 200

Responses darvin expects

Return your 2xx fast (< 10 s or darvin aborts the attempt). Do the
actual work async in your queue.

Replay protection

X-Darvin-Delivery is unique per attempt. Persist the last N
delivery IDs per webhook + event kind and ignore duplicates — retries
re-use the same event body but rotate this header.

Testing locally

Use the dashboard's Create webhook with a temporary URL (e.g. an
webhook.site bin) to see the exact payload +
headers before wiring your app.