Webhooks
A webhook receives every install as signed JSON, whatever its resolution, so you can feed your own warehouse, CRM or alerts. Signatures follow the Standard Webhooks specification.
#Set it up
#Add the destination
Enter your endpoint's https URL. It must resolve to a public address: private, loopback and link-local targets are refused, including after DNS resolution. Tick Include captured identifiers only if your endpoint needs click IDs and cookies.
#Save the signing secret
CLItrail generates a
whsec_…signing secret and shows it once. Store it in your endpoint's secret manager. Rotate secret replaces it and shows the new one once. For 24 hours deliveries carry two signatures, one with the new secret and one with the previous, so your endpoint can switch without rejecting events.#Check your endpoint
Add the verification code below, then choose Send test to receive a signed
test.ping.#Go live
Webhooks need no terms confirmation or domain verification.
#Request
| Header | Value |
|---|---|
webhook-id | The event ID, such as clt_f399d635…. The same on every retry: deduplicate on it. |
webhook-timestamp | Unix seconds when this attempt was signed. |
webhook-signature | v1, followed by the base64 HMAC-SHA256 of <id>.<timestamp>.<body>. For 24 hours after a rotation, a second v1, signature with the previous secret follows, separated by a space. |
Content-Type | application/json |
User-Agent | CLItrail-Webhooks/1 |
{
"id": "clt_f399d6357f61bcf15f9370c69a41662457de8a05",
"type": "cli.install_completed",
"timestamp": "2026-09-23T14:02:11.000Z",
"data": {
"website_id": "p_04de14934e8005ed97dd",
"event_id": "6f2da9610246dec311f2b793f7c32902",
"installation":
"a68e7a64d91bfe305bd5c2a9f285ca38dec72a8a0d4036ee28a92bc72fb773ac",
"install_path": "ip_4c1f0a9e3b7d2c6e8f10",
"attribution": {
"resolution": "matched",
"method": "receipt",
"confidence": null,
"policy": "latest_visit",
"unattributed_reason": null,
"touch": {
"page_url": "https://acme.dev/docs/install",
"referrer": "https://acme.dev/",
"source_referrer": "https://www.google.com/",
"channel": "Google Ads",
"utm": {
"source": "google",
"medium": "cpc",
"campaign": "cli-launch"
},
"visited_at": "2026-09-23T13:58:40.120Z",
"gpc": false
},
"journey": [
{
"page_url": "https://acme.dev/",
"referrer": "https://news.ycombinator.com/item",
"source_referrer": "https://news.ycombinator.com/item",
"channel": "Social",
"utm": null,
"visited_at": "2026-09-21T09:14:03.512Z",
"gpc": false
},
{
"page_url": "https://acme.dev/docs/install",
"referrer": "https://acme.dev/",
"source_referrer": "https://www.google.com/",
"channel": "Google Ads",
"utm": {
"source": "google",
"medium": "cpc",
"campaign": "cli-launch"
},
"visited_at": "2026-09-23T13:58:40.120Z",
"gpc": false
}
]
}
}
}
Every field is described in Webhook event types.
#Verify signatures
Verify against the raw request body, before any JSON parsing: re-serialised JSON will not match. Reject timestamps more than five minutes from your clock, compare signatures in constant time, and accept any v1 signature in the space-separated header.
import { createHmac, timingSafeEqual } from 'node:crypto';
// secret: the whsec_… value shown when you created the webhook.
// rawBody: the request body exactly as received (Buffer or string).
// tolerance: the allowed clock difference, in seconds.
export function verifyWebhook(secret, headers, rawBody, tolerance = 300) {
const id = headers['webhook-id'];
const timestamp = headers['webhook-timestamp'];
const signatures = headers['webhook-signature'];
if (!id || !timestamp || !signatures) {
throw new Error('missing webhook headers');
}
const age = Math.abs(Date.now() / 1000 - Number(timestamp));
if (!/^\d+$/.test(timestamp) || age > tolerance) {
throw new Error('timestamp outside tolerance');
}
const key = Buffer.from(secret.replace(/^whsec_/, ''), 'base64');
const expected = createHmac('sha256', key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest();
const valid = signatures.split(' ').some(entry => {
const [version, value = ''] = entry.split(',');
const given = Buffer.from(value, 'base64');
return version === 'v1'
&& given.length === expected.length
&& timingSafeEqual(given, expected);
});
if (!valid) throw new Error('invalid signature');
return JSON.parse(rawBody);
}
import { createServer } from 'node:http';
import { verifyWebhook } from './verify.mjs';
const secret = process.env.CLITRAIL_WEBHOOK_SECRET;
const seen = new Set(); // Use your database: webhook-id repeats on retries.
createServer(async (req, res) => {
const chunks = [];
for await (const chunk of req) chunks.push(chunk);
let event;
try { event = verifyWebhook(secret, req.headers, Buffer.concat(chunks)); }
catch { return res.writeHead(400).end(); }
const id = req.headers['webhook-id'];
if (!seen.has(id)) {
seen.add(id);
// event.type: cli.install_completed, cli.first_run,
// cli.install_started or test.ping
console.log(id, event.type);
}
res.writeHead(204).end();
}).listen(Number(process.env.PORT) || 3000);
No dependencies; Node.js 18 or later. With Express, read the body with express.raw({ type: 'application/json' }) and pass req.body.
import base64
import hashlib
import hmac
import json
import time
def verify_webhook(secret, headers, body, tolerance=300):
"""Return the event or raise ValueError. body: raw request bytes."""
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures):
raise ValueError("missing webhook headers")
now = time.time()
if not timestamp.isdigit() or abs(now - int(timestamp)) > tolerance:
raise ValueError("timestamp outside tolerance")
key = base64.b64decode(secret.removeprefix("whsec_"))
signed = f"{msg_id}.{timestamp}.".encode() + body
digest = hmac.new(key, signed, hashlib.sha256).digest()
expected = base64.b64encode(digest)
for entry in signatures.split(" "):
version, _, sig = entry.partition(",")
if version == "v1" and hmac.compare_digest(sig.encode(), expected):
return json.loads(body)
raise ValueError("invalid signature")
import os
from http.server import BaseHTTPRequestHandler, HTTPServer
from verify import verify_webhook
SECRET = os.environ["CLITRAIL_WEBHOOK_SECRET"]
seen = set() # Use your database: webhook-id repeats on retries.
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
try:
event = verify_webhook(SECRET, self.headers, body)
except ValueError:
self.send_response(400)
self.end_headers()
return
msg_id = self.headers["webhook-id"]
if msg_id not in seen:
seen.add(msg_id)
print(msg_id, event["type"])
self.send_response(204)
self.end_headers()
PORT = int(os.environ.get("PORT", "3000"))
HTTPServer(("", PORT), Handler).serve_forever()
Standard library only; Python 3.9 or later. With Flask, call verify_webhook(SECRET, request.headers, request.get_data()).
#Responses and retries
| Your endpoint answers | CLItrail |
|---|---|
| 2xx | Marks the event submitted. |
| 401, 403, 404, 408, 429, 5xx, or no answer | Retries with backoff, honouring Retry-After, for up to 10 attempts within 3 days of the install. With no answer at all, the rest of the queue waits a minute too. |
| 410 | Marks the event rejected and pauses the destination. |
| Any other status, including redirects | Marks the event rejected. |
Answer quickly with 2xx and do slow work afterwards; a request is abandoned after 10 seconds. Events are sent one per request, and the same webhook-id can arrive more than once.