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.
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#
| 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#
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".
{
"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#
| 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#
- 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#
Build the receiver
Verify every message before acting on it, as the example below shows. Deduplicate on
webhook-idand answer within 30 s.Open the destination
Go to Integrations. On the Destinations tab, select Generic webhook (HMAC) in the Notify panel, then Set up in its drawer.
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/foxborneforrun.closed_out,case.openedandcase.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.
Send a test
Select Send test. Foxborne posts a
test.pingevent, and the dialog reports the receiver's answer, such as204 No Content. The test is L0, carries no data and goes into the ledger like any other delivery.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.
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.
sha256sum body.jsonTroubleshoot#
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#
- Automations covers A1, which sends
run.closed_out. - Egress, data classes and the delivery ledger explains the classes, retries and the ledger.
- Alerts lists the destinations that carry alert messages.