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_3kTqZ8vN1mW4xR7pL2cY9bH5dF6gJ0sA",
"secret_hint": "whsec_…J0sA"
}It is shown only at create and rotate time. Later reads return a hint, not the
secret, and so does a retry of the create with the same Idempotency-Key
(secret_already_shown: true). If you lose it, rotate — there is no way to
read it back.
Every endpoint must be HTTPS, in live and test mode alike, 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.
The delivery policy, generated from the API's own description of it:
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
tand everyv1value from the Agentency-Signature header. - Reject the request if
tis 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 delivery gets seven attempts in total: the first, then six retries over roughly seventeen and a half hours, with increasing gaps of about 1 minute, 5 minutes, 30 minutes, 2 hours, 5 hours and 10 hours. After the seventh attempt it is marked failed and left alone.
If 20 attempts in a row fail, across its deliveries, the endpoint is
disabled automatically, and you get a webhook_endpoint.disabled event on
your other endpoints and an email. This protects both sides from a dead URL
absorbing deliveries forever. Disabling an endpoint yourself
(PATCH with status: disabled) sends no event.
A disabled endpoint is sent nothing: a delivery that falls due while it is off
is marked failed without a request, and turning the endpoint back on does not
re-send it. Retry those deliveries one by one, or read what you missed from
GET /v1/events.
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 its status, the attempts made, when the next one is due, and
your endpoint's answer to the latest attempt: the status code, the first 512
characters of the body and how long it took. A null response_status means
no answer came at all — a timeout or a refused connection. Filter with
status=failed to find what to re-send:
POST/v1/webhook_deliveries/{id}/retry
Only a failed delivery can be retried, and retrying resets its attempt budget.
It re-sends the same event, with the same id, to that endpoint only — no new
event is recorded. Turn a disabled endpoint back on first, or the retry fails
again without a request.
A delivery currently in flight returns 409 DELIVERY_IN_FLIGHT, and one your
endpoint already accepted returns 409 DELIVERY_ALREADY_DELIVERED, so neither
is sent twice. Test pings and retries share a budget of 20 a minute per key.
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.
Rotating again inside those 24 hours keeps the original secret signing until its window ends and replaces only the new one, so finish one rotation before you start the next.
Testing
POST/v1/webhook_endpoints/{id}/test
Sends a ping event through the real delivery pipeline — same signing, same
headers, same retries — to this endpoint only, whether or not it subscribes to
ping. The response carries the ping's event_id, so you can find its
delivery. Use it to confirm a new endpoint before you rely on it.
For local development, point the endpoint at an HTTPS tunnelling service.
mode is a label for your own bookkeeping: test endpoints receive the same
events as live ones and need HTTPS too.
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