Webhooks
AgentManager fires a signed HTTP POST to registered webhook URLs when a human makes an approval decision (approve or deny). Use webhooks to react to decisions without polling.
How it works
human approves/denies in portal or via API
→ AgentManager POSTs approval.decided to all registered webhook URLs
→ your server verifies HMAC signature and processes the payloadWebhooks fire only on approval decisions — not on preflight requests or policy changes.
Registering a webhook
An agent supports two webhook registration paths:
Single URL (on the agent record)
Set webhook_url directly on the agent via PUT /api/v1/agents/{agent_id}:
curl -X PUT https://api.gavrun.ai/api/v1/agents/{agent_id} \
-H "Authorization: Bearer <management_token>" \
-H "X-Tenant-Id: <tenant_id>" \
-H "Content-Type: application/json" \
-d '{"webhook_url": "https://your-server.com/webhooks/gavrun"}'Multiple subscribers (webhook_subscriptions)
Register independent receivers via the subscriptions API. Each fires independently — adding a new subscriber doesn't affect existing ones.
# Register a subscriber
curl -X POST https://api.gavrun.ai/api/v1/agents/{agent_id}/webhooks \
-H "Authorization: Bearer <management_token>" \
-H "X-Tenant-Id: <tenant_id>" \
-H "Content-Type: application/json" \
-d '{"url": "https://your-server.com/webhooks/gavrun"}'
# List subscribers
curl https://api.gavrun.ai/api/v1/agents/{agent_id}/webhooks \
-H "Authorization: Bearer <management_token>" \
-H "X-Tenant-Id: <tenant_id>"
# Remove a subscriber
curl -X DELETE https://api.gavrun.ai/api/v1/agents/{agent_id}/webhooks/{subscription_id} \
-H "Authorization: Bearer <management_token>" \
-H "X-Tenant-Id: <tenant_id>"URLs must start with http:// or https://. Both paths fire on the same decision — if both webhook_url and subscriptions are registered, all of them receive the payload.
Payload
{
"event": "approval.decided",
"approval_id": "apr_abc123def456",
"decision_id": "dec_abc123def456",
"request_id": "req_abc123def456",
"resume_decision_id": "dec_abc123def456",
"agent_id": "agent_abc123def456",
"tenant_id": "tenant_abc123",
"status": "approved",
"decided_at": "2026-07-30T12:00:00+00:00",
"reviewer_id": "alice",
"reviewer_note": "looks good"
}status is either "approved" or "denied".
resume_decision_id is the ID to pass to GET /api/v1/approvals/status or use when resuming the agent — it is always equal to decision_id in the current version.
Verifying signatures
Every delivery includes an X-Gavrun-Signature header. The value is sha256=<hex> where the hex is an HMAC-SHA256 of the raw request body using the agent's client_secret as the key.
Verify before processing:
import hashlib
import hmac
def verify_gavrun_signature(body: bytes, header: str, client_secret: str) -> bool:
if not header.startswith("sha256="):
return False
expected = "sha256=" + hmac.new(
client_secret.encode("utf-8"),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(header, expected)import { createHmac, timingSafeEqual } from "crypto";
function verifyGavrunSignature(
body: Buffer,
header: string,
clientSecret: string
): boolean {
if (!header.startsWith("sha256=")) return false;
const expected =
"sha256=" +
createHmac("sha256", clientSecret).update(body).digest("hex");
return timingSafeEqual(Buffer.from(header), Buffer.from(expected));
}The client_secret is returned at agent registration and stored by your agent process. Use hmac.compare_digest / timingSafeEqual (not ==) to prevent timing attacks.
Always return HTTP 200 — AgentManager does not retry on non-200 responses, but delivery failures are logged as approval.webhook_failed audit events.
Why webhooks instead of polling
| Polling | Webhooks | |
|---|---|---|
| Agent threads per pending approval | 1 per approval | 0 |
| API calls per approval | N × poll interval | 1 |
| Latency | up to poll interval | ~1s |
| Scale concern | self-inflicted load at high concurrency | none |
At scale (100s concurrent approvals), polling spins 100s of threads each hitting GET /approvals/status every few seconds. Webhooks eliminate this entirely — AgentManager pushes the decision once, your server unblocks the right request by approval_id.
Audit trail
Every webhook delivery generates an audit event:
approval.webhook_sent— delivery succeeded (HTTP 2xx)approval.webhook_failed— delivery failed;metadata.errorcontains the exception
Both events include approval_id, decision_id, and url in metadata. Subscription deliveries also include subscription_id.