Contents
API docs

Webhooks

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

View as Markdown

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 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:

{
  "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 page describes it.
data.canary The canary, as the 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 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:

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:

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.