A push, not a proof
Webhooks
A webhook is how the API tells your endpoint that authority changed, for example a revoked grant. Delivery is queued and retried. Read the signature section before relying on it, because today the header cannot be checked on your side.
Register an endpoint
curl -sS -X PUT "https://api.afaprotocol.com/v1/webhooks" -H "X-API-Key: afa-beta-EXAMPLE-e4qs" -H "Content-Type: application/json" -d '{"url": "https://hooks.example.com/afa/7f3a1c"}'One URL per account. A URL that is not absolute http or https is refused with 422 and never stored. GET /v1/webhooks shows the current registration; DELETE /v1/webhooks removes it.
What arrives
A POST with Content-Type: application/json and the header X-AFA-Signature. The body is compact JSON with sorted keys:
{
"body": "Grant g-0c9f31ab7e2d for sub-agent worker-1 was revoked; authority ends at the next authorization check.",
"severity": "warning",
"type": "grant.revoked"
}Which notices ride the webhook channel
| Type | Severity | When |
|---|---|---|
| grant.revoked | warning | A sub-agent grant was revoked. |
| grant.expiring_24h | warning | A live grant expires within 24 hours. |
| key.expiring_24h | critical | An API key expires within 24 hours. |
| rate.cap_exceeded | critical | The daily event cap was reached; writes refused until 00:00 UTC. |
| bundle.verify_fail | critical | A bundle verification failed. |
Delivery
Dispatch only queues. A drain runs every two minutes, POSTs each queued payload with a five second timeout, and retries up to three times with short backoff. Only a 2xx counts as sent. After three failures the item is recorded as failed and dropped; a dead endpoint does not accumulate a backlog.
Check: revoke a grant, then read GET /v1/notifications. The row for channel webhook shows delivery_status sent or failed with the attempt count.
The signature, and why you cannot check it yet
X-AFA-Signature is sha256= followed by the hex HMAC-SHA256 of the exact body bytes, keyed with a per-account secret. Today that secret is derived on the server and is not issued to the account, so a receiver cannot recompute the header. Until a per-endpoint secret is issued, treat a delivery as a prompt to poll, not as evidence: on grant.revoked call GET /v1/grants/status and act on that answer.
Check: GET /v1/webhooks returns configured and url and no secret. When a secret field appears there, the code below applies.
Verifying, once a secret is issued
Python
import hashlib
import hmac
def verify_afa_signature(body_bytes: bytes, header: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(secret.encode("utf-8"), body_bytes, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, header or "")
# In your handler: read the RAW body before any JSON parsing.
# ok = verify_afa_signature(request.get_data(), request.headers.get("X-AFA-Signature", ""), AFA_WEBHOOK_SECRET)
Node
const crypto = require("node:crypto");
function verifyAfaSignature(bodyBuffer, header, secret) {
const expected = "sha256=" + crypto.createHmac("sha256", secret).update(bodyBuffer).digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(header || "");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Use the raw request body (a Buffer), not a re-serialised object.
Limits
A webhook tells you that something changed. It does not carry the record itself, and a missed delivery is not an error you will see unless you read the notification log. Poll the API for the state that matters.