Veefore docs

Webhooks

Signed event delivery, the exact verification you must implement, and why a webhook is never an authorization.

Webhooks deliver a subset of audit events to an endpoint you control. The subset is deliberately smaller than the full event registry, because a webhook leaves your perimeter and an audit event does not.

A webhook is a notification, not an authorization

Verify the signature, then read the resource back from the API before acting on it.

A delivery can be delayed, duplicated, reordered or replayed by anything on the path. The API is the only authority on current state. Treating a webhook body as the truth is how a stale agent.resumed ends up re-enabling an agent you suspended ten seconds later.

#Subscribe

bash
curl -X POST https://veefore.example.com/v1/webhooks \
  -H "Authorization: Bearer $VEEFORE_API_KEY" \
  -H 'content-type: application/json' \
  -d '{
    "name": "security-alerts",
    "url": "https://hooks.internal.example.com/veefore",
    "event_types": ["agent.revoked", "policy.activated", "security.emergency_action"]
  }'

The response includes the signing secret, once. Subscribable event types are listed in Event types with a ◆, and served live at GET /v1/meta/event-types under webhook_subscribable.

#Verify the signature

Every delivery carries these headers:

http
veefore-signature: t=1759492684,v1=9f8c…
veefore-event-id: evt_01JAV7YQ4T2K9M3E8H6R5N1XZ0
veefore-event-type: agent.revoked
veefore-delivery-id: dlv_01JAV…
veefore-delivery-attempt: 1
user-agent: Veefore-Webhooks/1.0

Recompute HMAC-SHA256(secret, "<timestamp>.<raw body>") and compare in constant time.

ts
import { createHmac, timingSafeEqual } from 'node:crypto';

const REPLAY_WINDOW_SECONDS = 300;

function verify(header: string, rawBody: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(',').map((part) => part.trim().split('=') as [string, string]),
  );
  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp)) return false;

  // Reject anything outside the replay window before doing any crypto.
  const now = Math.floor(Date.now() / 1000);
  if (Math.abs(now - timestamp) > REPLAY_WINDOW_SECONDS) return false;

  const expected = createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  const received = Buffer.from(parts.v1 ?? '', 'utf8');
  const computed = Buffer.from(expected, 'utf8');
  if (received.length !== computed.length) return false;
  return timingSafeEqual(received, computed);
}
python
import hashlib
import hmac
import time

REPLAY_WINDOW_SECONDS = 300


def verify(header: str, raw_body: str, secret: str) -> bool:
    parts = dict(
        part.strip().split("=", 1) for part in header.split(",") if "=" in part
    )
    try:
        timestamp = int(parts["t"])
    except (KeyError, ValueError):
        return False

    if abs(int(time.time()) - timestamp) > REPLAY_WINDOW_SECONDS:
        return False

    expected = hmac.new(
        secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(parts.get("v1", ""), expected)

Use the raw body, not a re-serialised one

The signature covers the exact bytes that were sent. If your framework parses the JSON and you re-serialise it to verify, key order or whitespace will differ and every signature will fail.

Capture the raw body before parsing. In Fastify, addContentTypeParser. In Express, express.raw(). In FastAPI, await request.body().

Three things are doing work in that function, and all three matter:

  1. The timestamp window — 300 seconds. Without it, a captured delivery is replayable forever.
  2. Constant-time comparison — a === on the hex digest leaks timing information about how much of the signature matched.
  3. The length check before comparing — timingSafeEqual throws on mismatched lengths.

#Reply fast

Return 2xx quickly. Do the work afterwards, on a queue.

Deliveries time out after 10 seconds. A handler that does its processing inline will be retried while it is still working, which is how you get duplicates you then blame on Veefore.

ts
app.post('/veefore', async (request, reply) => {
  if (!verify(request.headers['veefore-signature'], request.rawBody, SECRET)) {
    return reply.code(401).send();
  }
  await queue.enqueue('veefore-event', { eventId: request.headers['veefore-event-id'] });
  return reply.code(202).send();
});

Enqueueing the event id rather than the body is the pattern worth copying. Your worker then reads current state from the API, which sidesteps staleness entirely.

#Be idempotent

veefore-event-id is stable across retries. Record processed ids and skip duplicates.

Redeliveries happen for ordinary reasons — your endpoint was slow, a deploy was in progress, a network blip. A handler that is not idempotent will eventually do something twice.

Delivery attempts are tracked, and veefore-delivery-attempt tells you which one this is. Attempt 3 on an event you already processed is normal.

#Rotate the secret

bash
curl -X POST https://veefore.example.com/v1/webhooks/whs_01ABC/rotate-secret \
  -H "Authorization: Bearer $VEEFORE_API_KEY"

Returns the new secret once. Deploy the new one before relying on it; during the window, accept either.

#Local development

Webhook delivery requires outbound egress. With GATEWAY_ALLOW_REAL_EGRESS=false — the default — delivery is not attempted and is recorded as failed with an explicit reason:

text
Outbound egress is disabled in this deployment, so webhook delivery was not attempted.

That is deliberate. A webhook reported as delivered when nothing was sent would be a lie in the one record you would later rely on. Set the flag to test delivery locally.

Redirects are refused, not followed — redirect: 'error' — so a 302 from your endpoint fails the delivery.

#What to subscribe to

Subscribe to the few things that need a human or another system to react.

Event Why
agent.revoked Someone killed an identity. Your inventory should know
policy.activated The authorization boundary moved
security.emergency_action Someone used a break-glass control
approval.requested Route it into chat so the queue gets worked
incident.created Open a ticket

Do not subscribe to every action. Use the API or audit export for bulk analysis. A webhook firing on every protected action is a firehose you will stop reading, and then the five events that mattered are lost in it.

Identity, authority, policy and enforcement for autonomous AI agents.