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
- 2xx: delivery succeeds,
consecutive_failuresresets to 0. - any other status or network error: retry per schedule.
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.