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
bashcurl -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:
httpveefore-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.
tsimport { 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);
}
pythonimport 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:
- The timestamp window — 300 seconds. Without it, a captured delivery is replayable forever.
- Constant-time comparison — a
===on the hex digest leaks timing information about how much of the signature matched. - The length check before comparing —
timingSafeEqualthrows 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.
tsapp.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
bashcurl -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:
textOutbound 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.