# Webhooks

URL: /integrate/webhooks

Send signed run and case events to an HTTPS receiver you run. Foxborne signs each delivery under Standard Webhooks headers and delivers at least once, so your receiver verifies every message and deduplicates repeats.



The &#x2A;*Generic webhook (HMAC)** destination sends Foxborne's run and case events to any HTTPS receiver you run, such as a test operations tracker. The **Hosting** page describes it as "Run and case events, HMAC-signed". The receiver checks a signature on each message, so it knows the message came from your deployment and arrived unchanged.

Foxborne uses the Standard Webhooks headers, so any library that verifies that specification can check the signature. The shared secret never travels with the message.

## What Foxborne sends [#what-foxborne-sends]

| Property           | Value                                                                                                   |
| ------------------ | ------------------------------------------------------------------------------------------------------- |
| Transport          | HTTPS POST with a JSON body                                                                             |
| TLS                | TLS 1.2 or later with FIPS-based cipher suites and TLS 1.3 support, per NIST SP 800-52 Rev. 2           |
| Headers            | `webhook-id`, `webhook-timestamp` and `webhook-signature`                                               |
| Signature          | **Standard Webhooks v1, HMAC-SHA256**, or **Standard Webhooks v1a, Ed25519**, chosen under **Protocol** |
| Client certificate | Optional mutual TLS                                                                                     |
| Delivery           | At least once: a 30 s timeout, up to 5 attempts, then the dead-letter queue and replay                  |

## Events [#events]

Every body has the same envelope: `type`, `timestamp` in UTC and `data`.

| `type`                | When Foxborne sends it                      | What `data` holds                                                                                                  |
| --------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| `run.closed_out`      | A1 closes out a sortie                      | `marking`, `run`, `vehicle`, `sources`, `findings`, `highest`, `alerts` and `case`, with `link` when a case exists |
| `case.opened`         | An investigator opens a case on a window    | `marking`, `case`, `link`, `by` and `window`                                                                       |
| `case.status_changed` | An incident's status changes                | `marking`, `case`, `link`, `status` and `by`                                                                       |
| `test.ping`           | **Send test** in the destination's settings | Nothing: `{}`                                                                                                      |

Some closeouts add a field. In the example dataset, `stopped` reads "rejected, the flight log is encrypted" and `missing` lists "companion journal". For a run with no UTC anchor, `clock` reads "elapsed time only".

```json title="Payload, as sent" caption="Example dataset: run.closed_out for R-0929, L1"
{
  "type": "run.closed_out",
  "timestamp": "2026-09-23T16:40:53.002Z",
  "data": {
    "marking": "CUI",
    "run": "R-0929",
    "vehicle": "UAS-06",
    "sources": "4 of 4",
    "findings": 1,
    "highest": "warning",
    "alerts": [
      "R4 warning"
    ],
    "case": null
  }
}
```

## What the payload may carry [#what-the-payload-may-carry]

The destination's data class decides the contents. L0, the default outside the enclave, is metadata: rule, severity, vehicle alias, UTC time and case link.

The example dataset's receiver runs inside the enclave and is set to L1, **authorized for CUI**. L1 payloads carry the banner string in the `marking` field, as above. No webhook carries L2: files move only through [Export](/integrate/exports), with approval.

## The headers [#the-headers]

| Header              | What it holds                                                                                          | What the receiver does                        |
| ------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------- |
| `webhook-id`        | A unique ID, such as `msg_a913a0d97c1525fdcdf7dca8fa`. The ledger shows it as the **Idempotency key**. | Deduplicates on it.                           |
| `webhook-timestamp` | Unix time in seconds, such as `1790181653`.                                                            | Rejects values too far from its own clock.    |
| `webhook-signature` | One or more space-separated signatures, such as `v1,` followed by a base64 HMAC-SHA256.                | Accepts the message if any signature matches. |

The signed content is the message ID, a period, the timestamp, a period and the raw body exactly as received. Standard Webhooks secrets carry a `whsec_` prefix in front of a base64-encoded key. Under v1a, entries start `v1a,` and carry an Ed25519 signature instead.

## Before you start [#before-you-start]

* An admin is available to turn on egress. Admins confirm with a hardware security key.
* Your receiver offers HTTPS with TLS 1.2 or later and FIPS-based cipher suites, and supports TLS 1.3.
* The shared secret sits in your secret store, under a Secrets Manager name or a Vault path. Foxborne takes the reference, never the secret.
* For mutual TLS, the receiver checks the client certificate Foxborne presents.

## Connect a receiver [#connect-a-receiver]

<Steps>
  <Step title="Build the receiver">
    Verify every message before acting on it, as the example below shows. Deduplicate on `webhook-id` and answer within 30 s.
  </Step>

  <Step title="Open the destination">
    Go to **Integrations**. On the **Destinations** tab, select &#x2A;*Generic webhook (HMAC)** in the **Notify** panel, then **Set up** in its drawer.
  </Step>

  <Step title="Fill in the settings">
    The example dataset's receiver uses these values:

    <Fields>
      <Field name="Host allowlist">
        testops.hfr.internal:443
      </Field>

      <Field name="Protocol">
        Standard Webhooks v1, HMAC-SHA256
      </Field>

      <Field name="Endpoint and events">
        `https://testops.hfr.internal/hooks/foxborne`

         for 

        `run.closed_out`

        , 

        `case.opened`

         and 

        `case.status_changed`
      </Field>

      <Field name="Credential reference">
        secretsmanager:foxborne/int/testops-whsec
      </Field>

      <Field name="Data class">
        L1, authorized for CUI
      </Field>
    </Fields>

    For L1, tick **The program has authorized this destination for CUI**.
  </Step>

  <Step title="Send a test">
    Select **Send test**. Foxborne posts a `test.ping` event, and the dialog reports the receiver's answer, such as `204 No Content`. The test is L0, carries no data and goes into the ledger like any other delivery.
  </Step>

  <Step title="Save and turn on egress">
    Select **Save**, then the switch in the **Egress** column and **Turn on egress**. The audit log records **Configured integration** and **Enabled egress**, for example `L1 to testops.hfr.internal:443`.
  </Step>
</Steps>

## Verify a signature [#verify-a-signature]

The receiver recomputes the HMAC over the signed content and compares it in constant time with each `v1` entry in the header. The example accepts 5 minutes either side of the receiver's clock.

```python title="verify.py" caption="Python 3.9 or later, standard library only"
import base64
import hashlib
import hmac
import time

TOLERANCE_S = 300  # accept 5 minutes either side of this host's clock


def verify(secret: str, headers: dict, body: bytes) -> str:
    """Check a Standard Webhooks v1 signature and return the webhook-id."""
    h = {k.lower(): v for k, v in headers.items()}
    msg_id = h["webhook-id"]
    timestamp = h["webhook-timestamp"]

    if abs(time.time() - int(timestamp)) > TOLERANCE_S:
        raise ValueError("timestamp outside the tolerance")

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{timestamp}.".encode() + body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest())

    for entry in h["webhook-signature"].split():
        version, _, signature = entry.partition(",")
        if version == "v1" and hmac.compare_digest(signature.encode(), expected):
            return msg_id
    raise ValueError("no valid v1 signature")
```

Pass the raw request body, before any JSON parsing. Parsing and re-serializing changes the bytes, and the signature no longer matches. A missing header raises `KeyError`, which the receiver should treat as a rejection.

## Receiver checklist [#receiver-checklist]

* **Verify the signature** over the raw body, and compare in constant time.
* **Reject old timestamps.** A message outside your tolerance could be a captured request played back.
* **Deduplicate by `webhook-id`.** Foxborne delivers at least once, and a retry or a replay carries the same ID as the first attempt.
* **Respond quickly.** Return a 2xx status once the message is stored, and do slow work afterwards. Foxborne gives each attempt 30 s.
* **Check the client certificate** when you use mutual TLS.

## Check the result [#check-the-result]

Open the **Delivery ledger** tab and filter to **Automations**, which lists run and case events, or to **Tests**. The drawer shows the request line, the three headers and **Payload, as sent**, with its SHA-256. To match a delivery to a message you stored, hash the raw body and compare.

```bash title="On the receiver" caption="body.json holds one raw body, exactly as received"
sha256sum body.json
```

## Troubleshoot [#troubleshoot]

**Save refuses the credential with "That looks like a secret itself."** You pasted the `whsec_` secret. Store it in your secret store and enter its path, such as `secretsmanager:foxborne/int/name`.

**The test reads Not delivered.** The dialog gives the answer, and the ledger lists the test as **Failed**, "Test, not retried". Check the host, the port and the receiver's TLS setup, then send another test.

**Every signature fails.** Strip the `whsec_` prefix and base64-decode the rest before using it as the HMAC key. Sign the raw bytes as received, not re-serialized JSON.

**Some messages fail the timestamp check.** The receiver's clock and the deployment's disagree by more than your tolerance. Synchronize both to the same time source.

**The same message arrives twice.** That is at-least-once delivery at work. Deduplicate on `webhook-id`.

**A delivery sits in the dead-letter queue.** In the example dataset, `run.closed_out` for R-0934 got `503 Service Unavailable` on all 5 attempts. Fix the receiver, then select **Replay** on the ledger's red note or in the delivery's drawer.

## Next [#next]

* [Automations](/integrate/automations) covers A1, which sends `run.closed_out`.
* [Egress, data classes and the delivery ledger](/integrate/egress-controls) explains the classes, retries and the ledger.
* [Alerts](/integrate/alerts) lists the destinations that carry alert messages.
