# 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](https://www.standardwebhooks.com/) 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](https://clitrail.com/docs/webhooks#verify-signatures) below, then choose **Send test** to receive a signed `test.ping`.
4. 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` |

```json
{
  "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](https://clitrail.com/docs/webhook-events).

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

**Node.js**

```js
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);
}
```

```js
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`.

**Python**

```py
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")
```

```py
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.
