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.
eventstakes exact types (task.submitted), a prefix with*(task.*), or*for everything.spaceslimits deliveries to events in those spaces. Leave it out for every space.POST …/webhooks/{id}/testsends awebhook.testevent to that webhook alone, andGET …/webhooks/{id}/deliveriesshows 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:
- Take
tandv1from the header. There may be more than onev1while a secret is being rotated; accept any that match. - Compute the HMAC of
t + "." + body, over the bytes exactly as they arrived, before any JSON parsing. - 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')));
// });