# Webhooks

Every alert POSTed to endpoints you register: the payload, verifying the signature, retries.

Feint can POST every alert to HTTPS endpoints you register, signed, as it is recorded. Email keeps going regardless; webhooks are for the systems that act on an alert before a person reads it: a SIEM, an on-call tool, a ticket queue, a script of your own.

## Registering an endpoint

Endpoints are managed on the [Alerting page](https://feintsecurity.com/dashboard/alerting) of the console, by organization admins and members. Add an `https://` URL and Feint shows the endpoint's **signing secret once**. Give it to whatever receives the deliveries; Feint keeps only an encrypted copy, to sign with. If it is lost, rotate it.

An endpoint receives the alerts recorded after it was added, while it is enabled. Disabling one pauses delivery, and alerts recorded meanwhile are not sent later. **Send test** delivers a `webhook.test` message on demand, so a receiver can be wired up before the first real alert.

For MSSP partners: an endpoint added while acting as your own organization receives every alert for your organization and each of your clients, and the payload names the client. An endpoint added while acting as one client receives that client's alerts alone. Registering the same URL both ways delivers that client's alerts to it twice.

## What is sent

One `POST` per alert per endpoint, with a JSON body:

```json
{
  "id": "0b6d0f3e-7c1a-4e2b-8f9d-1a2b3c4d5e6f",
  "type": "canary.used",
  "created_at": "2026-09-26T09:45:12.088311+00:00",
  "data": {
    "event": {
      "id": "6f1d2c3b-4a59-4e2b-8f9d-0b6d0f3e7c1a",
      "kind": "canary.used",
      "occurred_at": "2026-09-26T09:41:00+00:00",
      "source_ips": ["203.0.113.44"],
      "client": null
    },
    "canary": {
      "id": "8d3e6f1a-2b4c-4d5e-9f01-23456789abcd",
      "placement": "prod .env on web-01",
      "last_used_ips": ["203.0.113.44"],
      "client": null,
      "url": "https://feintsecurity.com/dashboard/canaries/8d3e6f1a-2b4c-4d5e-9f01-23456789abcd"
    }
  }
}
```

The sample is abridged. `data.event` carries every field `GET /v1/alerts` returns for the event, and `data.canary` every field `GET /v1/tokens/{id}` returns for the canary, plus `url`.

| Field | Meaning |
|---|---|
| `id` | The delivery's id, also sent as the `webhook-id` header. Stable across retries: de-duplicate on it. |
| `type` | The event kind: `canary.used`, `canary.revoked_externally`, `canary.missing` or `canary.expired`. `webhook.test` for a test delivery. |
| `created_at` | When this delivery was created. |
| `data.event` | The event, as the [alerts](https://feintsecurity.com/docs/alerts) page describes it. |
| `data.canary` | The canary, as the [canary tokens](https://feintsecurity.com/docs/canary-tokens) page describes it, plus `url`: the decoy's own page in the console. |

`data.event.source_ips` may be empty when a GitLab alert is first delivered; GitLab reports addresses on its own schedule. For an AWS decoy, `data.canary.external_id` is the access key ID, `data.event.detail` carries CloudTrail's exact count and one entry per call (when, the action, AWS's error code if any, the source address and the user agent), and `data.event.records_url` is where the records themselves are fetched. The body is rebuilt for every attempt from the live record, so a retry can carry a `data.canary.last_used_ips` the first attempt did not. `client` is null unless you are an MSSP partner receiving a client's alert.

A test delivery has `type` `webhook.test`, and `data.endpoint` and `data.message` in place of the event and canary.

## Headers

| Header | Value |
|---|---|
| `webhook-id` | The delivery id. |
| `webhook-timestamp` | Unix seconds at which the request was signed. |
| `webhook-signature` | `v1,` followed by the base64 HMAC-SHA256 of `{id}.{timestamp}.{body}` under your secret. |
| `content-type` | `application/json` |
| `user-agent` | `Feint-Webhooks/1.0 (+https://feintsecurity.com/docs/webhooks)` |

This is the [Standard Webhooks](https://www.standardwebhooks.com/) scheme, so any of its libraries verifies a Feint delivery.

## Verifying a delivery

Verify before you parse, over the raw bytes of the body as received. With the `standardwebhooks` package:

```python
from standardwebhooks import Webhook

wh = Webhook(SECRET)  # the whsec_... shown when the endpoint was added
payload = wh.verify(body, headers)  # raises if the signature is wrong or the timestamp is stale
```

By hand:

```python
import base64
import hashlib
import hmac
import time


def verify(secret: str, headers: dict, body: bytes, tolerance: int = 300) -> bool:
    msg_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    if abs(time.time() - int(timestamp)) > tolerance:
        return False
    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()
    return any(
        hmac.compare_digest(sig.split(",", 1)[1], expected)
        for sig in headers["webhook-signature"].split(" ")
        if sig.startswith("v1,")
    )
```

## Responses and retries

Answer `2xx` within 10 seconds; the response body is ignored. Do the work after responding.

Anything else is retried with growing gaps (1 min, 2 min, 4 min, 8 min, 16 min, 32 min, 1 h 4 min, 2 h 8 min, 4 h 16 min), up to 10 attempts in all. A `408` or `429` is retried too, honouring `Retry-After` when it is a number of seconds (up to six hours). Any other `4xx`, and any redirect, is not retried: redirects are never followed, so give the final URL. Every attempt is recorded, and the endpoint's status on the Alerting page says when the last one failed and why.

Deliveries to an endpoint go out one at a time in the order recorded, but a retry can arrive after a newer alert. Order by `data.event.detected_at`, and de-duplicate on `id`.

## Endpoints that keep failing

A delivery whose attempts are all used up is one failed delivery; a delivery that lands resets the count. When a delivery fails and the endpoint's first failure since its last success is more than 3 days old, Feint disables the endpoint and emails the organization's alert address once: which endpoint, since when, and that alerts are not reaching it. An endpoint nothing was sent to is never disabled.

The Alerting page shows the endpoint as **Disabled by Feint**, with when it was disabled, how many deliveries failed and since when, and the last error. **Enable** clears the count and delivers from the next alert on; alerts recorded while it was disabled are not sent. An endpoint disabled by hand is shown as **Disabled** and gets no email.

## Rotating the secret

**Rotate secret** issues a new one, shown once and effective immediately; the old secret stops verifying. Update a receiver that can hold two secrets first. Otherwise expect a few failed deliveries, which are retried.

## Source addresses

Feint has no fixed egress addresses. Verify signatures rather than allowlisting IPs.
