Guide
Webhooks
Receive signed events as clients grant access, verify them, and handle retries, duplicates and secret rotation.
Webhooks send an HTTPS POST to your server when something happens, such as a client granting access or a request completing, so your integration doesn’t need to poll.
Add a webhook
Add one in the dashboard under API > Webhooks, or with the API:
curl -X POST "https://api.agencyaccess.co/api/v2/webhooks" \
-H "Authorization: Bearer $AGENCYACCESS_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-H "Content-Type: application/json" \
--data-binary @- <<'JSON'
{
"url": "https://portal.example.com/hooks/agencyaccess",
"events": ["service.granted", "service.grant_failed", "request.completed"]
}
JSON
The response includes the signing secret (whsec_...) once. Store it with your other secrets. List each event type once, or use ["*"] on its own to receive every type. The endpoint must use https on port 443 and resolve to public addresses; redirects are not followed. An agency can have up to 10 webhooks.
What you receive
Each delivery is a POST with the event as a JSON body (Content-Type: application/json, User-Agent: AgencyAccess-Webhooks/1.0) and these headers:
| Header | Value |
|---|---|
AgencyAccess-Event-Id | The event’s ID, the same on every retry |
AgencyAccess-Event-Type | Such as request.completed |
AgencyAccess-Delivery-Id | This event’s delivery to this webhook, the same on every retry |
AgencyAccess-Attempt-Id | This attempt |
AgencyAccess-Signature | t=<unix time>,v1=<signature>, signed again for each attempt |
{
"id": "evt_5b1f0c9a2e7d4c38a6f1d2e9b8c7a604",
"type": "service.granted",
"schemaVersion": "2026-10-06",
"createdAt": "2026-10-07T10:02:10.000Z",
"test": false,
"data": {
"request": { "id": "2f6d8a14-...", "status": "in_progress", "stateVersion": 5, "services": [ ... ] },
"service": { "service": "Google Ads", "platform": "Google" }
}
}
Request events include the whole request as GET /requests/{id} returns it at that moment. See Events for every type and what it adds.
Verify the signature
The signature is an HMAC-SHA256 of <t>.<raw body> with your secret (the whole whsec_... value), in hex. Verify it on the raw bytes you received, before parsing JSON, and reject timestamps older than five minutes.
import crypto from "node:crypto"
import express from "express"
const app = express()
app.post("/hooks/agencyaccess", express.raw({ type: "application/json" }), (req, res) => {
if (!verify(req.body, req.get("AgencyAccess-Signature"), process.env.AGENCYACCESS_WEBHOOK_SECRET)) {
return res.status(400).end()
}
const event = JSON.parse(req.body)
res.status(200).end() // respond first
queue.add(event) // then do the work
})
function verify(rawBody, header, secret) {
if (!header) return false
const parts = header.split(",")
const t = Number(parts.find((p) => p.startsWith("t="))?.slice(2))
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")
return parts
.filter((p) => p.startsWith("v1="))
.some((p) => {
const signature = Buffer.from(p.slice(3))
return signature.length === expected.length && crypto.timingSafeEqual(signature, Buffer.from(expected))
})
}
import hashlib, hmac, time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
if not header:
return False
items = header.split(",")
try:
t = int(next(p[2:] for p in items if p.startswith("t=")))
except (StopIteration, ValueError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest().encode()
return any(hmac.compare_digest(p[3:].encode(), expected) for p in items if p.startswith("v1="))
Respond quickly
Return any 2xx status, with the whole response sent, within 10 seconds (including connecting), then do the slow work; only the first 64 KB of your response is read. Deliveries that fail with a network error, timeout, 408, 429 or 5xx are retried about 1 minute, 5 minutes, 30 minutes, 2 hours, 8 hours and 24 hours after the first failure (each time give or take 10%, and never later than 24 hours), so an event is attempted at most 7 times. A Retry-After header postpones the next retry, up to 24 hours after the first failure; retries that are then overdue follow soon after. Other statuses, including redirects, are not retried.
Retries send the same body. Each webhook receives its deliveries one at a time, so a slow endpoint delays the events after it. Deleting a webhook cancels its pending deliveries. Deliveries that come due while a webhook is paused, or while your plan has no API access, are cancelled, and events that happen while it is paused are never sent to it.
Duplicates and order
An event can arrive more than once and out of order. Handle both:
- Use the event
idto skip events you have already processed. - Use the request’s
stateVersionto avoid overwriting newer request state with an older snapshot, but still handle the event itself:service.grant_failedandconnection.attempt_completedcan carry the same version as an event before them. - A request moving to
in_progresssends no event of its own, so the firstservice.grantedorintake.completedcan still showpending. - If you suspect you missed events, read the current state from the API; past events can’t be fetched or redelivered. Recent deliveries and their attempts are listed under
GET /webhooks/{id}/deliveries.
Rotate the secret
Rotating returns a new secret. For 24 hours, deliveries carry two v1= signatures, one with each secret, so you can deploy the new secret without missing events. The verification code above accepts either. Rotating again within those 24 hours ends the oldest secret immediately.
Test it
Send a webhook.test event from the dashboard or with POST /webhooks/{id}/test. It is delivered immediately, even to a paused webhook, and the response shows your endpoint’s status code (or the connection error). A failed test is not retried. Test events have "test": true.