---
title: Webhooks
description: 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.

:::warning 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](/reference/events) 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)
```

:::danger 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.

## Related

:::cards

:::card Event types | /reference/events
Every event and which are subscribable. Generated.
:::

:::card Audit | /concepts/audit
The complete record, kept in Veefore rather than in webhook payloads.
:::

:::card Webhooks API | /reference/api/webhooks
Subscribe, list, rotate.
:::

:::card Observability | /operate/observability
Metrics and traces, as the other half of this.
:::

:::
