> ## Documentation Index
> Fetch the complete documentation index at: https://technologychecker.io/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Alert webhooks

> The webhook payload alerts deliver, how to verify the HMAC-SHA256 signature, and the delivery guarantees you should design your receiver around.

When an alert matches, we `POST` to your `webhook_url`. Deliveries are signed, at-least-once, and never sent for an empty window.

## Headers

| Header             | Purpose                                   |
| ------------------ | ----------------------------------------- |
| `X-TC-Alert-Id`    | Which alert fired                         |
| `X-TC-Delivery-Id` | Idempotency key — **dedupe on this**      |
| `X-TC-Timestamp`   | Unix seconds; part of the signed material |
| `X-TC-Signature`   | `sha256=` followed by the hex HMAC        |
| `User-Agent`       | `TechnologyChecker-Alerts/1.0`            |

## Payload

```json theme={null}
{
  "alert": {
    "id": "alrt_9f2c4a1e",
    "name": "Who leaves Shopify (US, 51-200)",
    "signal_types": ["churn"],
    "scope": { "kind": "technology", "technology_id": 2184, "technology_name": "Shopify" },
    "filters": { "min_confidence": 0.5, "churn_reliability": ["high", "medium"] }
  },
  "window": { "from": "2026-06-10 16:39:39", "to": "2026-06-10 17:39:39" },
  "events": [
    {
      "domain": "example.com",
      "subdomain": "WWW.EXAMPLE.COM",
      "detection_url": "https://www.example.com",
      "technology_id": 2184,
      "technology_name": "Shopify",
      "event_at": "2026-06-10 14:02:11",
      "landed_at": "2026-06-10 17:12:04",
      "last_detected": "2026-05-18 07:39:59",
      "detection_rate": 0.86,
      "detection_count": 6,
      "confidence": { "score": 79, "label": "high" },
      "signal": "churn"
    }
  ],
  "event_count": 1,
  "truncated": false,
  "query": "/v1/signals/churn?technology_id=2184&min_confidence=0.5",
  "test": false,
  "fired_at": "2026-06-10 17:39:39"
}
```

Event rows use the **same shape as the Signals API**, so one parser handles both. Switch alerts carry the switch row shape instead — `from`, `to`, `category`, `churned_at`, `adopted_at`, `gap_days`. The `query` field is a ready-made API call that reproduces or widens the alert.

<Note>
  `landed_at` appears on webhook events only. It is when the transition was written to our store — the axis the watermark moves on. `event_at` remains the scan time.
</Note>

## Verifying the signature

<CodeGroup>
  ```python Python theme={null}
  import hmac, hashlib

  def verify(secret: str, timestamp: str, raw_body: bytes, header_sig: str) -> bool:
      computed = "sha256=" + hmac.new(
          secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
      ).hexdigest()
      return hmac.compare_digest(computed, header_sig)
  ```

  ```javascript Node.js theme={null}
  import crypto from "node:crypto";

  function verify(secret, timestamp, rawBody, headerSig) {
    const computed =
      "sha256=" +
      crypto
        .createHmac("sha256", secret)
        .update(`${timestamp}.`)
        .update(rawBody)
        .digest("hex");
    return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(headerSig));
  }
  ```
</CodeGroup>

<Warning>
  Sign the **raw** request body. Re-serializing parsed JSON changes key order and whitespace, and the signature will never match.
</Warning>

## Delivery guarantees

* **At-least-once.** A failed delivery keeps the watermark in place and the window is re-sent. Dedupe on `X-TC-Delivery-Id`.
* **Truncation is pagination.** `truncated: true` means more events exist; they arrive on the next sweep. Each delivery carries at most 100 events.
* **Landing order, not event order.** Sort on `event_at` yourself if you need chronology.
* **No POST on empty.** Quiet windows advance the watermark silently.
* **Auto-disable.** Ten consecutive failures set the alert to `disabled`.
* **HTTPS only.** Private, internal and cloud metadata hosts are rejected at creation, and redirects are never followed.

## Delivery frequency

`delivery_frequency` controls how often an alert may fire, independently of how fresh the underlying data is.

| Value             | Behavior                                                                    |
| ----------------- | --------------------------------------------------------------------------- |
| `daily` (default) | At most one delivery per \~24h — a digest batch. Fewer webhooks and emails. |
| `hourly`          | One delivery per sweep, as events land.                                     |

<Tip>
  Keep busy alerts on `hourly`. A `daily` alert whose scope produces more than 100 events a day can only ever drain 100 a day, so its backlog grows.
</Tip>

## Frequently asked questions

<AccordionGroup>
  <Accordion title="Why must I dedupe on the delivery id?">
    Delivery is at-least-once. If your endpoint fails or times out, the watermark stays in place and the next sweep re-delivers the same window — that is the design that guarantees nothing is lost. Deduping on `X-TC-Delivery-Id` is what turns at-least-once into effectively-once on your side.
  </Accordion>

  <Accordion title="Why are events not in chronological order?">
    The watermark moves on *landing* time — when a transition was written to our store — not on the scan time in `event_at`. That is deliberate: under crawler lag a row can land days after its scan, and an event-time watermark silently dropped those. On landing time a late row simply falls in the next window, so it is delivered late rather than lost. The consequence is that a payload can contain an event whose `event_at` is older than ones you already received. Sort on `event_at` yourself if you need chronology.
  </Accordion>

  <Accordion title="Will I get a webhook when nothing happened?">
    No. Quiet windows advance the watermark silently. Your endpoint only hears from us when there are events to deliver.
  </Accordion>

  <Accordion title="What should my endpoint return?">
    Any 2xx. The watermark advances only on a 2xx response, so returning quickly and acknowledging before you do heavy processing is the right pattern. Ten consecutive failures disable the alert.
  </Accordion>

  <Accordion title="How do I verify the signature correctly?">
    Compute `HMAC-SHA256(webhook_secret, timestamp + "." + rawBody)` and compare it to the `X-TC-Signature` header in constant time. Sign the **raw** request body, never a re-serialization of the parsed JSON — key order and whitespace will differ and the comparison will fail. Also reject stale timestamps, for example more than 5 minutes of skew, for replay protection.
  </Accordion>

  <Accordion title="Why does a subdomain event show a different host?">
    Every event carries `detection_url`, the URL the crawler actually loaded for that row. An event on `docs.example.com` is not an event on the apex domain. Show `detection_url` to your users, not just `domain`.
  </Accordion>
</AccordionGroup>
