DocsWebhooks & SIEMBrowse

Webhooks & SIEM

Every refusal, hold, halt and anchor failure on your log can be forwarded to systems you already watch — signed, retried, and dead-lettered where you can see it. Configured per log under Integrations in the dashboard; nothing here needs a deploy.

What is forwarded

policy.denied          a rule refused an action — at decision time (data.source: "decision")
                       or as recorded on the chain (data.source: "record")
approval.pending       a rule held an action until a named person answers
approval.decided       a person approved or rejected it; the answer is on the chain
tenant.halted          the kill switch was pulled
tenant.resumed         and released
anchor.failed          a checkpoint could not be countersigned or held in write-once storage
coverage.changed       a deduction from the coverage score appeared or cleared
reconciliation.error   the stored chain and one of its checkpoints no longer agree
egress.test            a ping you sent from the Integrations page

A destination subscribes to some of these or to all of them. Delivery is never on the write path: the event is chained first, an outbox row is written in the same process, and a worker posts it a moment later. A slow SIEM cannot slow an append, and a dead one cannot fail a decision. The outbox is a table, not a buffer, so a restart delivers what was queued rather than forgetting it.

One refusal can arrive twice. An agent that calls decide(), is refused, and then records the refusal produces a policy.denied with source: "decision" and another with source: "record". They are different facts — the plane refused, and the agent wrote it down — and a receiver counting refusals should filter on data.source.

The envelope

every event, every destination
{
  "id": "evg_3f9c1e8d2a7b4c0f5e6d",
  "type": "policy.denied",
  "createdAt": "2026-08-26T10:14:03.201Z",
  "logId": "t_1a2b3c4d5e6f/prod",
  "data": {
    "source": "decision",
    "agentId": "underwriter",
    "action": "wire_transfer",
    "sessionId": "sess_9b2e",
    "amount": 50000,
    "policyId": "human-signoff-above-threshold",
    "policyVersion": "1",
    "reasons": ["amount 50000 exceeds threshold 10000"]
  }
}

id is the event's; it is the same across every destination that receives it. data is the type-specific part and carries the chained event's id wherever one exists, so a receiver can drill from the notification to the record.

Verifying a webhook signature

A webhook destination gets a signing secret when it is created, shown once. Every POST to it carries these headers:

request headers
content-type:          application/json
user-agent:            auditant-egress/1
x-auditant-event:      policy.denied
x-auditant-log:        t_1a2b3c4d5e6f/prod
x-auditant-delivery:   dlv_7e21a9c4b3d2e1f0a9b8   stable across retries — your idempotency key
x-auditant-timestamp:  1756203243                 unix seconds, at this attempt
x-auditant-signature:  v1=9f2a…                   HMAC-SHA256(secret, "<timestamp>.<raw body>"), hex

To verify: take the body bytes exactly as received — before any JSON parsing, because re-serialised JSON will not match — compute the same HMAC over `${timestamp}.${rawBody}`, and compare in constant time. Reject a timestamp more than five minutes from your clock; that is what turns a captured request into a stale one. During a secret rotation the header may carry several v1= values separated by commas, and any one matching is a pass.

node
import { createHmac, timingSafeEqual } from "node:crypto";

export function verify(headers, rawBody, secret) {
  const ts = Number(headers["x-auditant-timestamp"]);
  if (Math.abs(Date.now() / 1000 - ts) > 300) return false;
  const expected = createHmac("sha256", secret).update(`${ts}.${rawBody}`).digest("hex");
  return String(headers["x-auditant-signature"]).split(",").some((part) => {
    const [version, mac = ""] = part.trim().split("=");
    return version === "v1" && mac.length === expected.length
      && timingSafeEqual(Buffer.from(mac), Buffer.from(expected));
  });
}
python
import hmac, hashlib, time

def verify(headers: dict, raw_body: bytes, secret: str) -> bool:
    ts = int(headers["x-auditant-timestamp"])
    if abs(time.time() - ts) > 300:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(
        hmac.compare_digest(part.strip(), f"v1={expected}")
        for part in headers["x-auditant-signature"].split(",")
    )

Answer 2xx once the event is safely yours. Anything else — a 5xx, a 4xx, a timeout, a redirect — is a failed attempt and will be retried with the same x-auditant-delivery and the byte-identical body, under a fresh timestamp and signature.

Retries and dead letters

attempt 1   immediately
attempt 2   +30 s
attempt 3   +2 min
attempt 4   +10 min
attempt 5   +30 min
attempt 6   +2 h
attempt 7   +6 h        then dead-lettered

Seven attempts over about nine hours. A delivery that fails all of them is dead-lettered: kept for thirty days, listed under “Needs attention” on the Integrations page with its last error, and retried from there with one click once the destination is back. Nothing is dropped silently, and nothing is retried forever. Delivered rows are kept for a week as a log of what left.

Splunk HEC and Datadog Logs

Both are first-class destinations: give the URL and the token, and the worker sends the format each intake expects. Neither is HMAC-signed — the token is the credential, and it is stored on the control plane and never shown again.

splunk — POST to your HEC event endpoint
Authorization: Splunk <token>

{ "time": 1756203243, "host": "auditant", "source": "auditant",
  "sourcetype": "auditant:event", "event": { …envelope… } }
datadog — POST to your site's logs intake
DD-API-KEY: <api key>
# https://http-intake.logs.datadoghq.com/api/v2/logs   (US)
# https://http-intake.logs.datadoghq.eu/api/v2/logs    (EU)

[ { "ddsource": "auditant", "service": "auditant",
    "ddtags": "log_id:t_1a2b3c4d5e6f/prod,event:policy.denied",
    "message": "policy.denied on t_1a2b3c4d5e6f/prod",
    "timestamp": "2026-08-26T10:14:03.201Z", …envelope… } ]

Anything else that accepts JSON over HTTPS — PagerDuty Events, Opsgenie, a Lambda, your own service — is a webhook destination.

Destinations are public HTTPS hosts

The control plane posts from its own network position, so an open URL field would be a request-forgery primitive. Destinations must be https://, must not carry credentials in the URL, and must not point at loopback, link-local or private address space. Redirects are not followed. A self-hosted plane on a laptop can set AUDITANT_EGRESS_ALLOW_INSECURE=1 to accept http://localhost; leave it unset anywhere that matters.

The email digest

Daily at 08:00 UTC or weekly on Mondays: what is waiting on a human, what was refused and by which agents, whether the kill switch is on, the coverage score with each deduction that appeared or cleared since the last one, and whether any notification could not be delivered. Read from the chain at send time, so it cannot disagree with the dashboard.

Mail leaves through Resend's HTTP API and nothing else — most hosts block outbound SMTP at the network edge, and a mailer that works on a laptop and fails in production is worse than one that says it is off. Without the two variables below the digest reports itself unconfigured and refuses to be enabled. A refused send is recorded and retried at a growing interval, up to six times in a period, and shown on the page.

control plane env
AUDITANT_RESEND_KEY=            # a Resend API key
AUDITANT_MAIL_FROM=             # a verified sender on that account
AUDITANT_DASHBOARD_URL=         # so the digest can link to the queue

Every change is on the chain

Adding, changing or removing a destination, and setting the digest, are chained as lifecycle events under the signed-in email. A destination that receives every refusal on your log is a control, and “who added it, and when” is the first question an incident review asks. Secrets and tokens are not part of the record.

Roadmap

Syslog (RFC 5424 over TLS) and S3 export (batched envelopes to a bucket you own) are planned and not built. Until they ship, a webhook into a collector you run covers both; nothing in the product implies otherwise.