CLItrail Docs

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

  1. #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.

  2. #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.

  3. #Check your endpoint

    Add the verification code below, then choose Send test to receive a signed test.ping.

  4. #Go live

    Webhooks need no terms confirmation or domain verification.

#Request

HeaderValue
webhook-idThe event ID, such as clt_f399d635…. The same on every retry: deduplicate on it.
webhook-timestampUnix seconds when this attempt was signed.
webhook-signaturev1, 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-Typeapplication/json
User-AgentCLItrail-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 answersCLItrail
2xxMarks the event submitted.
401, 403, 404, 408, 429, 5xx, or no answerRetries 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.
410Marks the event rejected and pauses the destination.
Any other status, including redirectsMarks 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.