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

TypeSeverityWhen
grant.revokedwarningA sub-agent grant was revoked.
grant.expiring_24hwarningA live grant expires within 24 hours.
key.expiring_24hcriticalAn API key expires within 24 hours.
rate.cap_exceededcriticalThe daily event cap was reached; writes refused until 00:00 UTC.
bundle.verify_failcriticalA 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.