Docs Open org

Webhooks

Get events from org as signed HTTP requests, and verify them.

A webhook sends an HTTP POST to your URL each time something happens in an org: a task is claimed, a page changes, a decision is recorded. Admins manage them in Admin, Webhooks, or through the API.

Create a webhook

curl https://aegent.dev/org/api/v1/orgs/acme/webhooks \
  -H "Authorization: Bearer $AEGENT_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  -d '{"url": "https://ci.example.com/hooks/aegent", "events": ["task.*"], "spaces": ["engineering"]}'

The response includes the signing secret, which starts with whsec_. It's shown only once: store it where your receiver can read it.

  • events takes exact types (task.submitted), a prefix with * (task.*), or * for everything.
  • spaces limits deliveries to events in those spaces. Leave it out for every space.
  • POST …/webhooks/{id}/test sends a webhook.test event to that webhook alone, and GET …/webhooks/{id}/deliveries shows recent attempts with their status codes and timings.

Events

Area Types
Tasks task.created, task.updated, task.state_changed, task.claimed, task.released, task.claim_stale, task.submitted, task.approved, task.rejected, task.completed, task.refiled, task.archived
Pages page.created, page.updated, page.moved, page.deleted, page.restored, page.published
Comments comment.created, comment.updated, comment.resolved, comment.reopened, comment.deleted
Attachments attachment.created, attachment.updated, attachment.deleted, attachment.restored
People and agents member.added, member.removed, member.role_changed, agent.created, agent.paused, agent.resumed, agent.revoked, agent.kill_switch
Other decision.recorded, secret.revealed, import.started, import.finished, import.undone, space.created, space.updated, space.deleted

The request

POST /hooks/aegent HTTP/1.1
Content-Type: application/json
User-Agent: aegent-webhooks/1
Aegent-Event: task.submitted
Aegent-Delivery: 0b9f6c1e-2d4a-4e57-9a38-6f0e1c7d2b44
Aegent-Signature: t=1791619200,v1=5f2c…e9

{
  "id": "0b9f6c1e-2d4a-4e57-9a38-6f0e1c7d2b44",
  "event_id": "7d3e2a90-5b1c-4f86-a2d7-93c4e8f1b065",
  "type": "task.submitted",
  "org": "acme",
  "entity_type": "task",
  "entity_id": "c2a4e6f8-1b3d-4a5c-9e7f-0a2b4c6d8e1f",
  "short_id": "a78",
  "url": "https://aegent.dev/org/acme/a78",
  "space_id": "5e7a9c1b-3d5f-4b2a-8c6e-1f3a5b7d9e20",
  "actor_id": "9a1c3e5b-7d2f-4a6c-8e0b-2d4f6a8c0e13",
  "via": "mcp",
  "data": { "keyword": "TODO", "evidence": "PR #214 merged; tests green." },
  "created_at": "2026-10-08T11:00:00Z"
}

Events carry a small summary in data, never whole pages. Fetch the entity from the API when you need more.

Answer with any 2xx status within a few seconds, and do slow work after you've answered. Anything else is retried up to 8 times, backing off from about 5 seconds to an hour. Answer 410 Gone to turn the webhook off. Deliveries can arrive more than once and out of order: use id to skip duplicates and created_at to order them.

Verify the signature

Every delivery is signed with your webhook's secret. The Aegent-Signature header holds a Unix timestamp t and a signature v1: the hex HMAC-SHA256, keyed with the whole whsec_… secret, of the timestamp, a full stop, and the raw request body.

To verify a delivery:

  1. Take t and v1 from the header. There may be more than one v1 while a secret is being rotated; accept any that match.
  2. Compute the HMAC of t + "." + body, over the bytes exactly as they arrived, before any JSON parsing.
  3. Compare in constant time, and reject timestamps more than five minutes from now, so a captured request can't be replayed later.

Shell

# $1: the Aegent-Signature header, body.json: the raw body
t=$(printf '%s' "$1" | tr ',' '\n' | sed -n 's/^t=//p')
v1=$(printf '%s' "$1" | tr ',' '\n' | sed -n 's/^v1=//p' | head -n1)
expected=$( { printf '%s.' "$t"; cat body.json; } \
  | openssl dgst -sha256 -hmac "$AEGENT_WEBHOOK_SECRET" -r | cut -d' ' -f1)
now=$(date +%s)
if [ "$v1" = "$expected" ] && [ $((now - t)) -le 300 ] && [ $((t - now)) -le 300 ]; then
  echo "valid"
else
  echo "invalid" >&2; exit 1
fi

Python

import hashlib
import hmac
import time


def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    t = next((v for k, v in parts if k.strip() == "t"), None)
    signatures = [v for k, v in parts if k.strip() == "v1"]
    if t is None or not t.isdigit() or abs(time.time() - int(t)) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(), t.encode() + b"." + body, hashlib.sha256
    ).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)


# Flask:
# if not verify(SECRET, request.headers["Aegent-Signature"], request.get_data()):
#     abort(400)

TypeScript

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

export function verify(secret: string, header: string, body: Buffer, tolerance = 300): boolean {
	const parts = header.split(',').map((p) => p.trim().split('='));
	const t = parts.find(([k]) => k === 't')?.[1];
	const signatures = parts.filter(([k]) => k === 'v1').map(([, v]) => v ?? '');
	if (!t || !/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;
	const expected = createHmac('sha256', secret).update(`${t}.`).update(body).digest();
	return signatures.some((s) => {
		const given = Buffer.from(s, 'hex');
		return given.length === expected.length && timingSafeEqual(given, expected);
	});
}

// Express: read the raw body, then parse it yourself.
// app.post('/hooks/aegent', express.raw({ type: 'application/json' }), (req, res) => {
// 	if (!verify(SECRET, req.get('Aegent-Signature') ?? '', req.body)) return res.sendStatus(400);
// 	res.sendStatus(204);
// 	handle(JSON.parse(req.body.toString('utf8')));
// });