Skip to content
Foo GuardDocs
DOCUMENTATION

Audit streaming

Stream a Team workspace's audit records to your SIEM, verify each batch, and check that your copy has no gaps.

Page text and source link for your AI assistant. Nothing is sent automatically.

Audit streaming sends every record in a Team workspace's audit log to a system you control: admin activity (member, policy, key, billing and setting changes) and Gateway decisions. Records are sent in the order they were recorded. Each one carries the hash that links it to the record before it, so your copy can show that nothing was removed or changed, including by Foo Guard.

The stream also carries Foo Guard's signed checkpoints, each with a proof that covers only your workspace. See Check a signed checkpoint.

Set it up

Owners and admins open Settings › Audit log › Streaming.

  1. Choose HTTPS endpoint (signed JSON, works with any SIEM that accepts webhooks) or Splunk HTTP Event Collector.
  2. Enter a public HTTPS URL on port 443, without redirects, credentials or query parameters. For Splunk, also enter the HEC token.
  3. Choose where to start: new records only, from a date, or full history. History is sent first, in order, then new records follow.
  4. Save. For an HTTPS endpoint, copy the signing secret now; it is shown only once.
  5. Send a test, then start streaming.

Changing the destination pauses streaming until a new test succeeds. Choose Continue where streaming stopped to pick up after the last delivered record; together with the copy at the previous destination, nothing is missing.

What your endpoint receives

Each delivery is one POST containing up to 500 records (about 1 MB). The scheduler runs about every five minutes and sends batches until it catches up.

{
  "type": "fooguard.audit-stream/v1",
  "delivery": "8f0c2c1e-…",
  "workspace": { "id": "5d0e…" },
  "records": [
    {
      "sequence": 48211,
      "entryHash": "9c1e…",
      "entry": "{\"schemaVersion\":\"fooguard.workspace-evidence/v1\",\"sequence\":48211,\"previousHash\":\"e41a…\",…}",
      "summary": { "source": "gateway_decision", "requestId": "…", "decision": "block", "outcome": "not_sent", "mode": "enforce", "tool": "create_pull_request", "rule": "…", "agentId": "…", "policyVersion": 12, "at": "2026-10-30T23:14:02Z" }
    }
  ]
}
  • entry is the stored record exactly as Foo Guard hashed it. Keep it byte for byte.
  • summary is for searching and alerting. It is not part of the proof. Gateway summaries never include tool arguments, responses or credentials, and are left out if the request has been deleted.
  • A test delivery has "test": true and no records.

For Splunk HEC, each record is one event with sourcetype=fooguard:audit, sent with Authorization: Splunk <token>.

Verify each delivery

HTTPS deliveries carry the same signature headers as Gateway webhooks: X-FooGuard-Delivery, X-FooGuard-Timestamp and X-FooGuard-Signature (v1= plus the HMAC-SHA256 of the timestamp, a period and the raw body, keyed with your signing secret). Check the signature before storing anything, and check that delivery in the body matches the delivery header.

Return a 2xx within 8 seconds once the batch is stored durably. A failed batch is usually retried with the same delivery ID and records, but after repeated failures or a 413 it is split into smaller batches with new IDs, and changing the destination can re-send records. Store records by sequence and ignore ones you already have.

Check that your copy is complete

For every record:

  1. entryHash must equal the SHA-256 of the entry text (UTF-8).
  2. The previousHash inside entry must equal the entryHash of the record with the previous sequence.
  3. sequence must increase by exactly one.

A missing sequence number or a hash that doesn't match means a record was lost or altered.

Check a signed checkpoint

Every hour Foo Guard signs a checkpoint over all workspaces' logs and stores it where it can't be changed or deleted. Your stream receives a checkpoints item when your log grew since the last one, and at least once a day otherwise. Each item arrives after the record it covers, in a delivery with an empty records list.

{
  "checkpoints": [{
    "checkpoint": { "payload": "{\"schemaVersion\":\"fooguard.evidence-checkpoint/v2\",…,\"workspacesRoot\":\"…\"}", "algorithm": "ECDSA_SHA_256", "keyId": "fooguard-audit-1", "signature": "…" },
    "workspace": { "organizationId": "5d0e…", "sequence": 48212, "head": "9c1e…" },
    "salt": "…",
    "index": 517,
    "siblings": ["…", "…"]
  }]
}

The signed payload holds only a Merkle root over every workspace. The tree has at least 1,024 slots, each workspace sits at a random slot with a fresh random salt every hour, and empty slots hold random values, so the proof tells you nothing about other workspaces, how many there are, or when they were active. To check one:

  1. Verify the signature with the key named by keyId from `/.well-known/fooguard-audit-keys.json`, and check that the payload's issuedAt is not before that key's validFrom.
  2. Check that workspace.organizationId is your workspace ID, and that the payload is a production v2 checkpoint.
  3. Recompute the root from your entry, its salt and the proof, and compare it with workspacesRoot in the signed payload.
  4. Check that head equals the entryHash of record sequence in your copy. Then every record up to that one is covered by Foo Guard's signature.
  5. Optionally, confirm the SHA-256 of checkpoint.payload appears in the public list at `/api/audit-checkpoints`. Everyone sees the same list, so this shows you weren't given a private copy.
import { createHash, createPublicKey, verify } from 'node:crypto';

const sha = (...parts) => createHash('sha256').update(Buffer.concat(parts)).digest();

export function verifyCheckpointProof(item, publicKeyPem, keyId, myWorkspaceId) {
  const { checkpoint, workspace, salt, index, siblings } = item;
  if (checkpoint.keyId !== keyId || workspace.organizationId !== myWorkspaceId) return false;
  const signedOk = verify('sha256', Buffer.from(checkpoint.payload, 'utf8'),
    { key: createPublicKey(publicKeyPem), dsaEncoding: 'der' }, Buffer.from(checkpoint.signature, 'base64'));
  if (!signedOk) return false;
  const signed = JSON.parse(checkpoint.payload);
  if (signed.schemaVersion !== 'fooguard.evidence-checkpoint/v2' || signed.series !== 'production' || signed.keyId !== keyId) return false;
  if (siblings.length !== Math.log2(signed.treeSize)) return false;
  const leaf = JSON.stringify({ organizationId: workspace.organizationId, sequence: workspace.sequence, head: workspace.head });
  let hash = sha(Buffer.from([0]), Buffer.from(salt, 'hex'), Buffer.from(leaf, 'utf8'));
  let position = index;
  for (const sibling of siblings) {
    const other = Buffer.from(sibling, 'hex');
    hash = position % 2 === 0 ? sha(Buffer.from([1]), hash, other) : sha(Buffer.from([1]), other, hash);
    position >>= 1;
  }
  return position === 0 && hash.toString('hex') === signed.workspacesRoot;
}

For Splunk HEC, each proof is one event with sourcetype=fooguard:checkpoint; the proof is in the event's checkpointProof field.

Failures and recovery

If your endpoint fails, later records wait so order is kept. Retries back off to once every 30 minutes. After 30 minutes of failures, owners and admins are emailed once for that run of failures. After seven days of failures, streaming stops itself and keeps its place; resume it after fixing the endpoint. Nothing is skipped.

Pausing also keeps its place. Removing the destination deletes its secret or token and stops streaming.

Related pages

Build confidently. Keep your agents accountable.Get help ↗
Search Foo Guard Docs