Webhooks — signed events for approvals, rank drops, and citations
Receive outbound events from CitationBench — approval requests, rank drops, AI citation drops, link placements, indexing outcomes. HMAC-SHA256 signed, retried, dead-lettered.
Receive outbound events from CitationBench in your service: approval requests, rank drops, citation drops, link placements, indexing outcomes. Signed with HMAC-SHA256. Retried on failure. Dead-lettered if all retries fail.
Endpoint shape
POST https://your-server.com/your-handler HTTP/1.1
Content-Type: application/json
CitationBench-Signature: t=1716537272,v1=8d2a3b1e...
CitationBench-Event-Id: evt_***
CitationBench-Event-Type: agent.invocation.awaiting_approval
CitationBench-Delivery-Attempt: 1
{
"type": "agent.invocation.awaiting_approval",
"id": "evt_***",
"createdAt": "2026-05-24T08:14:32Z",
"workspaceId": "ws_***",
"data": {
"approvalId": "appr_***",
"invocationId": "inv_***",
"skill": "produce.publish",
"preview": { "...": "..." }
}
}Endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | /v1/webhooks | List registered webhooks |
| POST | /v1/webhooks | Register |
| GET | /v1/webhooks/{id} | Get one |
| PATCH | /v1/webhooks/{id} | Update (URL, events, secret rotation) |
| DELETE | /v1/webhooks/{id} | Delete |
| POST | /v1/webhooks/{id}/test | Fire a test event |
| GET | /v1/webhooks/dead-letter | List events that failed all retries |
| POST | /v1/webhooks/dead-letter/{id}/replay | Replay a dead-lettered event |
Register
curl -X POST https://api.citationbench.com/v1/webhooks \
-H "Authorization: Bearer sk_live_***" \
-d '{
"url": "https://your-server.com/webhooks/citationbench",
"events": [
"agent.invocation.awaiting_approval",
"agent.invocation.completed",
"rank.dropped",
"ai_citation.share_of_voice.dropped",
"link_building.link.placed"
],
"description": "Production webhook for client portal"
}'Response
{
"id": "whk_***",
"url": "https://your-server.com/webhooks/citationbench",
"events": ["agent.invocation.awaiting_approval", "..."],
"secret": "whsec_***",
"createdAt": "2026-05-24T08:00:00Z",
"active": true
}Save the secret immediately — you can't see it again. Use it to verify signatures.
Event catalog
Agent invocations
| Event | When |
|---|---|
agent.invocation.started | Invocation moves from PENDING to RUNNING |
agent.invocation.awaiting_approval | Invocation pauses for human review |
agent.invocation.completed | Terminal SUCCEEDED |
agent.invocation.failed | Terminal FAILED (includes error details) |
agent.invocation.cancelled | Terminal CANCELLED |
Rank tracking
| Event | When |
|---|---|
rank.checked | A rank check completed (fires per workspace per run) |
rank.dropped | A drop exceeded the configured threshold |
rank.improved | A keyword gained 5+ positions |
rank.lost_top_10 | A keyword fell out of top 10 |
AI citations (GEO)
| Event | When |
|---|---|
ai_citation.sample.completed | A daily sample run finished for a query |
ai_citation.share_of_voice.dropped | Share-of-voice dropped beyond threshold |
ai_citation.share_of_voice.improved | Share-of-voice rose |
Production
| Event | When |
|---|---|
produce.blog_post.created | Draft ready |
produce.blog_post.published | Pushed to a CMS |
produce.landing_page.created | Draft ready |
produce.landing_page.published | Pushed to a CMS |
produce.publish.completed | Generic publish event |
produce.evaluate.completed | An evaluation finished (with score) |
Indexing
| Event | When |
|---|---|
indexing.url.submitted | Sent to GSC / IndexNow |
indexing.url.indexed | Verified as indexed |
indexing.url.not_indexed | Verified as not indexed after a window |
indexing.url.failed | Submission failure |
Link building
| Event | When |
|---|---|
link_building.draft.awaiting_approval | A campaign draft is ready for review |
link_building.email.sent | An outreach email actually sent |
link_building.email.opened | Recipient opened |
link_building.email.replied | Recipient replied |
link_building.link.placed | A link went live |
link_building.inbound.received | An inbound message was ingested |
link_building.inbound.requires_review | An inbound was escalated by an eval gate |
Research
| Event | When |
|---|---|
research.discuss.pain_point.surfaced | A new high-signal pain point detected |
research.content_gap.report.created | A gap report is ready |
Signature verification
Every webhook is signed. Verify with your secret.
TypeScript
import crypto from "node:crypto";
function verify(
body: string,
signatureHeader: string,
secret: string,
): boolean {
const [tPart, v1Part] = signatureHeader.split(",");
const timestamp = tPart.split("=")[1];
const v1Sig = v1Part.split("=")[1];
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
return false; // 5-minute replay protection
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${body}`)
.digest("hex");
return crypto.timingSafeEqual(Buffer.from(v1Sig), Buffer.from(expected));
}Python
import hmac, hashlib, time
def verify(body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(p.split("=") for p in signature_header.split(","))
timestamp = int(parts["t"])
v1 = parts["v1"]
if abs(time.time() - timestamp) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.{body.decode()}".encode(),
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(v1, expected)Retry policy
| Attempt | Backoff |
|---|---|
| 1 | immediate |
| 2 | 5 s |
| 3 | 30 s |
| 4 | 5 min |
| 5 | dead-letter |
Failures (anything other than 2xx) trigger a retry. After the 4th retry, the event goes to the dead-letter queue.
Dead-letter queue
# List
curl .../v1/webhooks/dead-letter \
-G --data-urlencode "since=2026-05-01T00:00:00Z"
# Replay
curl -X POST .../v1/webhooks/dead-letter/dlq_***/replayDead-lettered events are retained for 30 days.
Test a webhook
curl -X POST .../v1/webhooks/whk_***/test \
-d '{ "event": "agent.invocation.awaiting_approval" }'Sends a fake event of the chosen type. Useful for end-to-end testing your handler.
Best practices
- Respond fast. Return
2xxwithin 5 seconds. Process asynchronously after returning. - Idempotency. Use
CitationBench-Event-Idto dedupe — the same event can re-deliver after a retry. - Verify signatures always. Including in dev (use a separate dev webhook with a different secret).
- Subscribe narrowly. Don't subscribe to
*and filter server-side; subscribe to the specific events you handle. - Rotate secrets quarterly.
PATCH /v1/webhooks/{id}with a newsecretrotates without losing in-flight deliveries.
Errors during delivery
The delivery log on a webhook surfaces per-attempt errors:
curl .../v1/webhooks/whk_***/deliveries \
-G --data-urlencode "since=2026-05-20T00:00:00Z"Each delivery shows the response code, body excerpt, latency, and attempt number.
Related
- Authentication
- Errors
- Agent · approval (most common webhook target)
- Playbook · Client-approval gated publishing
Connect GSC + Ahrefs + DataForSEO
Step-by-step setup for the three core data integrations every SEO workspace needs, with the trade-offs of bring-your-own-key versus platform-provided credentials.
Changelog
All notable changes to the CitationBench REST API and MCP server. New endpoints, breaking changes with 12-month deprecation windows, bug fixes, and model upgrades.