Skip to content
Docs
foxborne.comRequest a pilot

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.

How-toEvery deploymentAdminMarkdown
On this page11

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

PropertyValue
TransportHTTPS POST with a JSON body
TLSTLS 1.2 or later with FIPS-based cipher suites and TLS 1.3 support, per NIST SP 800-52 Rev. 2
Headerswebhook-id, webhook-timestamp and webhook-signature
SignatureStandard Webhooks v1, HMAC-SHA256, or Standard Webhooks v1a, Ed25519, chosen under Protocol
Client certificateOptional mutual TLS
DeliveryAt least once: a 30 s timeout, up to 5 attempts, then the dead-letter queue and replay

Events#

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

typeWhen Foxborne sends itWhat data holds
run.closed_outA1 closes out a sortiemarking, run, vehicle, sources, findings, highest, alerts and case, with link when a case exists
case.openedAn investigator opens a case on a windowmarking, case, link, by and window
case.status_changedAn incident's status changesmarking, case, link, status and by
test.pingSend test in the destination's settingsNothing: {}

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

Payload, as sentExample dataset: run.closed_out for R-0929, L1JSON
{
  "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#

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, with approval.

The headers#

HeaderWhat it holdsWhat the receiver does
webhook-idA unique ID, such as msg_a913a0d97c1525fdcdf7dca8fa. The ledger shows it as the Idempotency key.Deduplicates on it.
webhook-timestampUnix time in seconds, such as 1790181653.Rejects values too far from its own clock.
webhook-signatureOne 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#

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

  1. Build the receiver

    Verify every message before acting on it, as the example below shows. Deduplicate on webhook-id and answer within 30 s.

  2. Open the destination

    Go to Integrations. On the Destinations tab, select Generic webhook (HMAC) in the Notify panel, then Set up in its drawer.

  3. Fill in the settings

    The example dataset's receiver uses these values:

    Host allowlist
    testops.hfr.internal:443
    Protocol
    Standard Webhooks v1, HMAC-SHA256
    Endpoint and events
    https://testops.hfr.internal/hooks/foxborne for run.closed_out, case.opened and case.status_changed
    Credential reference
    secretsmanager:foxborne/int/testops-whsec
    Data class
    L1, authorized for CUI

    For L1, tick The program has authorized this destination for CUI.

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

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

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.

verify.pyPython 3.9 or later, standard library onlyPython
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#

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

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.

On the receiverbody.json holds one raw body, exactly as receivedShell
sha256sum body.json

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#