Audit-write webhook (HTTPS push)
Operator runbook for piping IdentityMesh audit events to a
customer-supplied HTTPS endpoint in near-real-time. This is the
push counterpart to the pull-style audit feeds described in
siem-integration.md and the syslog stream
shipped via siem-syslog.md. Use it when your
SIEM (or detection-orchestration platform — Splunk SOAR, Tines,
Torq, custom Logic Apps / Cloud Run) wants events delivered
synchronously rather than polled.
The webhook sink is opt-in. Nothing is wired by default, and
nothing the audit-row write path does depends on the webhook
being healthy. The audit row is committed to IM_AdminAudit /
IM_ObjectAudit and stamped into the ER-005 hash chain before
the webhook fires; if the receiver is unreachable, the durable
record is still intact.
When to choose the webhook
- You need per-event push (not per-batch poll). A SOAR playbook that fires within 1-2 seconds of an admin action.
- You’re already on a Splunk / Sentinel / Datadog stack but want a parallel feed straight to a custom collector (compliance team mailbox, Teams alert channel, ticketing system).
- The collector wants JSON over HTTPS rather than syslog (RFC 5424) or CEF / LEEF text frames.
- You want HMAC-SHA256 origin verification so the receiver can reject anything that didn’t come from this IdentityMesh install (signing rolls out as a follow-up — see “Signing” below).
If your receiver speaks syslog, prefer the syslog sink — it has
fewer moving parts, no TLS cert juggling, and lower per-event
overhead. If your receiver wants ArcSight / QRadar envelopes,
see siem-cef-leef.md.
What gets shipped
The webhook ships audit-row events only — admin-surface
mutations recorded to IM_AdminAudit, plus identity-data changes
recorded to IM_ObjectAudit. Operational debug / info lines are
filtered out so the receiver isn’t drowned in connector chatter.
The filter is expression-based and ships in
appsettings.Webhook.example.json:
EndsWith(SourceContext, 'AuditStore') or @Properties['AuditRow'] = 'true'
Tighten or loosen this expression to your taste. The AuditRow
property is reserved for future use — when audit-row tagging
moves to an explicit LogContext.PushProperty("AuditRow", true)
at the audit-write call sites, the filter picks it up
automatically.
Each event is delivered as a JSON object — by default the Compact JSON Format shape. Example:
{
"@t": "2026-04-25T11:32:18.4210000Z",
"@m": "Admin audit row recorded: POST /api/connectors -> 201",
"@l": "Information",
"SourceContext": "IdentityMesh.Infrastructure.Sql.SqlAdminAuditStore",
"ActorUpn": "alice@corp",
"Action": "POST /api/connectors",
"TargetKind": "Connector",
"TargetId": "8e6b8c44-...",
"StatusCode": 201,
"CorrelationId": "0HMV-...",
"MachineName": "im-engine-01",
"InstanceName": "IdentityMeshEngine"
}
Batches of up to 100 events (or 256 KB, whichever first) are
POSTed to the configured requestUri every two seconds.
Enabling the webhook
The Sync Engine ships with a sample at:
<install dir>\appsettings.Webhook.example.json
Copy the Serilog.Using array entry, the Serilog.WriteTo
block, and the Serilog.Filter block into the live
appsettings.json (or, preferred for a host-specific override,
appsettings.Production.json). Restart the IdentityMesh service.
Minimal recipe
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.File",
"Serilog.Sinks.EventLog",
"Serilog.Sinks.Http"
],
"WriteTo": [
{
"Name": "Http",
"Args": {
"requestUri": "https://siem.corp.example/identitymesh/webhook",
"queueLimitBytes": 67108864,
"logEventsInBatchLimit": 100,
"batchSizeLimitBytes": 262144,
"period": "00:00:02",
"textFormatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact",
"restrictedToMinimumLevel": "Information"
}
}
],
"Filter": [
{
"Name": "ByIncludingOnly",
"Args": {
"expression": "EndsWith(SourceContext, 'AuditStore') or @Properties['AuditRow'] = 'true'"
}
}
]
}
}
Key knobs:
| Field | Meaning |
|---|---|
requestUri | HTTPS endpoint on the receiver. HTTP is permitted but not recommended; the sink does not disable TLS verification. |
logEventsInBatchLimit | Max events per POST. 50-200 is sane. |
batchSizeLimitBytes | Max batch payload size (262144 = 256 KB). |
period | Flush interval (TimeSpan). 1-5 s is sane. |
queueLimitBytes | Process-memory queue cap (67108864 = 64 MB). Sustained outage past this drops oldest events. |
textFormatter | Per-event JSON shape. CompactJsonFormatter is recommended. |
restrictedToMinimumLevel | Severity floor for the webhook specifically (Information catches admin-audit + object-audit; Warning is the safe minimum on a noisy host). |
Receiver expectations
The receiver should:
- Accept
POSTon the configured path with content-typeapplication/json. - Read the body as a JSON envelope with an
eventsarray (the sink’s batch shape):{ "events": [ { ...event 1... }, { ...event 2... } ] } - Respond with a 2xx status within ~10 seconds. Non-2xx is treated as a transient failure and the batch is retried.
- Be idempotent: under retry the same event may be
delivered twice. Use
@t+ the audit-row primary key (AuditIdif present in@mt/ properties) as the dedup key. - Verify the HMAC signature header (see “Signing” below) once it’s enabled.
Sample receiver — Node.js (Express)
const express = require('express');
const crypto = require('crypto');
const HMAC_KEY = Buffer.from(process.env.IM_HMAC_KEY, 'base64');
const app = express();
app.use(express.raw({ type: 'application/json', limit: '1mb' }));
app.post('/identitymesh/webhook', (req, res) => {
const sig = req.header('X-IdentityMesh-Signature') || '';
const body = req.body;
const expected = crypto
.createHmac('sha256', HMAC_KEY)
.update(body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return res.status(401).end();
}
const { events } = JSON.parse(body.toString('utf8'));
for (const ev of events) {
// ... persist / forward / alert ...
}
res.status(202).end();
});
app.listen(8443);
Sample receiver — Python (Flask)
import os, hmac, hashlib, json
from flask import Flask, request, abort
HMAC_KEY = bytes.fromhex(os.environ["IM_HMAC_KEY"])
app = Flask(__name__)
@app.post("/identitymesh/webhook")
def receive():
raw = request.get_data()
sig = request.headers.get("X-IdentityMesh-Signature", "")
expected = hmac.new(HMAC_KEY, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
payload = json.loads(raw)
for ev in payload.get("events", []):
# persist / forward / alert
pass
return "", 202
Sample receiver — C# (IHostedService)
app.MapPost("/identitymesh/webhook", async (HttpRequest req, byte[] body) =>
{
var sig = req.Headers["X-IdentityMesh-Signature"].ToString();
var expected = Convert.ToHexString(
HMACSHA256.HashData(hmacKey, body)).ToLowerInvariant();
if (!CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(sig),
Encoding.ASCII.GetBytes(expected)))
return Results.Unauthorized();
using var doc = JsonDocument.Parse(body);
foreach (var ev in doc.RootElement.GetProperty("events").EnumerateArray())
{
// persist / forward / alert
}
return Results.Accepted();
});
Signing
ER-041 specifies HMAC-SHA256 origin verification on every batch.
The shared key is configured under Webhook:HmacKey in
appsettings.json; the receiver verifies the
X-IdentityMesh-Signature header against the raw request body.
Status: signature emission ships as a follow-up in this
release. The current Serilog.Sinks.Http configuration delivers
batches over HTTPS but does not stamp the HMAC header — the
sink’s request-shaping hook landed in 9.x but is not yet wired
through the engine. Operators who need signed delivery today
have two options:
- Reverse-proxy mode: front the receiver with NGINX / HAProxy / Cloudflare and let the proxy inject the header by computing the HMAC on the body.
- Custom sink wrapper: subclass
Serilog.Sinks.Http’sIHttpClientto inject the header at request build time. Document and contribute the wrapper back to this repo for the next release.
The receiver code samples above already verify the header so when signing flips on, no receiver changes are required.
HMAC key rotation
- Generate the new key on the receiver side
(
openssl rand -base64 32). - Update the receiver to accept either the old key or the new key during the rotation window.
- Update
Webhook:HmacKeyin IdentityMesh’sappsettings.json(or its secrets backend persecrets-keyvault.md/secrets-vault.md). - Restart the IdentityMesh service. The new key takes effect on first emit.
- Once the next batch is observed at the receiver verifying under the new key, drop the old key from the receiver.
Failure modes
- Receiver unreachable at boot. The sink queues events in-memory while it retries the connection asynchronously. Events written before the receiver comes up are flushed on first successful POST.
- Receiver unreachable mid-stream. Batches that fail get
retried with exponential backoff. The in-memory queue is
bounded by
queueLimitBytes(default 64 MB); on sustained outage the sink drops oldest events first. - Receiver returns 5xx / timeout. Same as above — transient, retried.
- Receiver returns 4xx. The sink logs to Serilog
SelfLogand drops the batch. Misconfigured receivers (wrong path, wrong content-type) won’t infinite-loop the engine. - Audit row never lost. The audit row is committed to SQL
and stamped into the ER-005 hash chain before Serilog
emits the corresponding log line. A webhook outage cannot
affect the durable record. See
audit-chain.mdfor how the chain is verified.
To surface the sink’s own diagnostics (TLS handshake errors,
4xx drops), enable Serilog SelfLog once at startup or
temporarily point SELF_LOG at a writable file:
Serilog.Debugging.SelfLog.Enable(msg =>
File.AppendAllText("logs\\serilog-selflog.txt", msg + "\n"));
Operational notes
- Cost / throughput. A 100k-mesh-object connector with hourly syncs writes 50-200k object-audit rows per day. At 100 events / batch every 2 s that’s roughly one batch per second sustained — verify the receiver can absorb that before pointing this at production.
- Receiver ACL. Lock down ingress to the IdentityMesh host IPs at the firewall level. The HMAC header is origin proof but does not gate connection establishment.
- TLS pinning. The sink uses the OS trust store. If the receiver is using an internal CA, add it to the local machine certificate store before flipping the sink on.
- Combine with file sink. The default rolling file sink remains active alongside the webhook — treat the file as the durable record and the webhook as the live tap, mirroring the syslog pattern.
Configurable fields cheat-sheet
| Concern | Where to set it |
|---|---|
| Severity floor | Serilog.WriteTo[Http].Args.restrictedToMinimumLevel |
| Per-event field shape | textFormatter arg — CompactJsonFormatter recommended |
| Filter (audit-only) | Serilog.Filter[ByIncludingOnly].Args.expression |
| Receiver URL | requestUri |
| Batch size / period | logEventsInBatchLimit / batchSizeLimitBytes / period |
| Memory cap | queueLimitBytes (drops-oldest beyond this) |
| HMAC key | Webhook:HmacKey (secrets-backed; see signing follow-up) |
Related
siem-integration.md— the broader SIEM field map (file, Event Log, audit tables, OTLP).siem-syslog.md— RFC 5424 syslog feed for collectors that prefer pull / syslog ingest.siem-sentinel.md— Microsoft Sentinel direct ingestion for Azure-native deployments.siem-cef-leef.md— ArcSight / QRadar envelope formats.audit-retention.md— how long the audit tables (the source of webhook events) retain rows.audit-chain.md— ER-005 hash chain that guarantees the audit row is durable before the webhook fires.secrets-keyvault.md/secrets-vault.md— backing theWebhook:HmacKeysetting with a real secrets store rather than plaintext appsettings.