Webhooks
A webhook is how the platform tells you something happened without you having
to ask. Register an HTTPS URL, subscribe to the event types you care about,
and deliveries arrive as POST requests with a JSON body.
If you are polling a status endpoint in a loop, this page replaces that loop.
Registering an endpoint
POST/v1/webhook_endpoints
curl -X POST https://api.agentency.com/v1/webhook_endpoints \
-H "Authorization: Bearer <YOUR_KEY>" \
-H "Content-Type: application/json" \
-d '{
"url": "https://hooks.example.com/agentency",
"description": "Production listener",
"mode": "live",
"enabled_events": [
"knowledge.dataset.trained",
"knowledge.dataset.failed",
"conversation.handoff.requested"
]
}'Use "enabled_events": ["*"] to receive everything.
The response includes the signing secret once:
{
"object": "webhook_endpoint",
"id": 1,
"url": "https://hooks.example.com/agentency",
"status": "enabled",
"secret": "whsec_ZmFrZXNlY3JldGZvcmRvY3M",
"secret_hint": "whsec_…9c4f"
}It is shown only at create and rotate time. Later reads return a hint, not the secret. If you lose it, rotate — there is no way to read it back.
Live endpoints must be HTTPS, and the URL is checked before it is stored: internal addresses, private ranges and this API's own host are refused. The same check runs again at delivery time, so an address that later resolves somewhere private is still blocked.
What a delivery looks like
POST /agentency HTTP/1.1
Content-Type: application/json
Agentency-Signature: t=1710000000,v1=5257a869e7…
Agentency-Event-Id: evt_4d9a1f2c8b7e4a6d9c0f3b2a1e5d7c9f
Agentency-Event-Type: knowledge.dataset.trained
Agentency-Delivery-Attempt: 1
Agentency-Version: 2026-08-14
User-Agent: Agentency-Webhooks/1.0The body is the event envelope described in Events.
Verify every delivery
Anyone can POST to your URL. The signature is what proves a request came
from us.
The header is HMAC-SHA256 over "{timestamp}.{raw_body}", keyed with your
endpoint secret:
- Read `t` and every `v1` value from the Agentency-Signature header.
- Reject the request if `t` is more than 300 seconds from now — that is the replay window.
- Compute HMAC-SHA256 of "{t}.{raw body}" with your secret.
- Accept if it matches ANY of the v1 values, using a constant-time comparison.
**Use the raw body**, before any JSON parsing — re-serialising changes the bytes and the signature will never match. And **check every `v1` value**: the header carries two during a secret rotation, and keeping only the last one means rejecting every delivery for the whole grace window.
Copy-pasteable implementations are below. They do both of those things.
Responding
Return any 2xx and we stop. Anything else is a failure and will be retried.
Deliveries time out after ten seconds. Write the event to a queue, return 200, and do the real work asynchronously. A handler that calls three of your own services inline will start timing out under load, and then you are debugging retries instead of the actual problem.
Deduplicate on Agentency-Event-Id. A retry re-sends the same id, and
receiving one twice is normal rather than exceptional.
Retries
A failed delivery is retried seven times over roughly seventeen hours, with increasing gaps: about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours, then 10-hour intervals. After the last attempt it is marked failed and left alone.
If an endpoint fails repeatedly it is disabled automatically, and you get a
webhook_endpoint.disabled event on your other endpoints. This protects both
sides from a dead URL absorbing deliveries forever.
There is also a circuit breaker: an endpoint that is clearly down stops receiving attempts for a couple of minutes rather than burning through its retry budget while it is unreachable.
Inspecting and replaying
GET/v1/webhook_endpoints/{id}/deliveries
Each record has the attempt count, the response status, a short response snippet, and the error. To re-send one:
POST/v1/webhook_deliveries/{id}/retry
Retrying resets the attempt budget. A delivery currently in flight returns
409 DELIVERY_IN_FLIGHT rather than being sent twice.
Rotating the secret
POST/v1/webhook_endpoints/{id}/rotate_secret
Rotation returns a new secret and keeps the old one valid for 24 hours. During that window deliveries are signed with both, so you can deploy the new secret without dropping anything.
- Rotate. Store the new secret.
- Deploy your receiver with the new secret. Both are being sent, so nothing fails.
- Confirm deliveries are still verifying.
- Do nothing — the old secret expires on its own.
This only works if your verifier checks every v1 value. A verifier that
looks at one signature will reject everything for 24 hours.
Testing
POST/v1/webhook_endpoints/{id}/test
Sends a ping event through the real delivery pipeline — same signing, same
headers, same retries. Use it to confirm a new endpoint before you rely on it.
For local development, set the endpoint to test mode and point it at a
tunnelling service. Test mode is also where relaxed host rules apply, so
localhost can be allowed by configuration where a live endpoint never could.
Common mistakes
- Parsing before verifying. Sign the raw bytes.
- Keeping one
v1. Breaks silently for 24 hours after every rotation. - Not checking the timestamp. A signature with no time bound is replayable forever.
- Slow handlers. Ten seconds, then it is a retry.
- No deduplication. At-least-once delivery means you will see duplicates.
- Ignoring
webhook_endpoint.disabled. That is the platform telling you it has given up on a URL.
What's next
- Events — the catalogue and payload shape.
- Going live — which events to subscribe to first.
- Security — secret handling.
Verify the signature
# Signed payload is "{timestamp}.{raw_body}".
BODY=$(cat body.json)
HEADER="t=1710000000,v1=…"
TS=$(printf '%s' "$HEADER" | sed -n 's/.*t=\([0-9]*\).*/\1/p')
# 1. Reject anything outside the 300s replay window.
AGE=$(( $(date +%s) - TS ))
[ "${AGE#-}" -le 300 ] || { echo "stale"; exit 1; }
# 2. grep matches ANY v1= value, so rotation works for free here.
EXPECTED=$(printf '%s' "$TS.$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
echo "$HEADER" | grep -q "v1=$EXPECTED" && echo ok