Webhooks
Get an HTTP POST from AgentDocs whenever a page changes.
Overview
A webhook sends an HTTP POST to your URL when something happens to a page in your workspace: it is created, updated, deleted, renamed, moved or reordered. Use webhooks instead of polling the API for changes.
Webhooks are configured at the workspace level. A webhook receives its event for every page in every space of that workspace.
ProFree plan: 1 webhook per workspace. Pro: unlimited. Accounts that haven't verified their email address can't create webhooks yet. Disabled webhooks count toward the limit.
Events
Each webhook listens for exactly one of these events. To receive several, create one webhook per event. They can share a URL.
| Event | Description | Trigger |
|---|---|---|
| page.created | A page was created or restored from the trash | In the app, API create, bulk create, Markdown import, restore from trash |
| page.updated | A page's content or title was saved | Editor save, API update, version restore, approved proposal, Markdown import overwrite, page move |
| page.deleted | A page was moved to the trash | Delete in the app, API delete, bulk delete |
| page.renamed | A page's title or slug changed | Rename in the page tree, API rename, API update with a new title |
| page.moved | A page was moved to a new parent, space or position | Move dialog, API move |
| page.hierarchy_changed | A move gave the page a different parent | Sent with page.moved when the parent changes |
| page.reordered | A page's position among its siblings changed | API reorder |
One change can send several events. An API update that changes the title sends page.renamed and page.updated, and every move also sends page.updated. No event is sent when a page is purged from the trash, promoted to a space, or removed along with its space or workspace.
Payload format
Every delivery is a POST with a JSON body of three fields: event (the event name), timestamp (when AgentDocs sent it, in ISO 8601) and data (the fields listed below).
Fields in data
| Event | Fields |
|---|---|
| page.created | page_id, space_id, workspace_id, title, slug, content, author_id, author_name, version |
| page.updated | page_id, space_id, workspace_id, title, slug, content, author_id, author_name, version, previous_version, diff |
| page.deleted | page_id, space_id, workspace_id, title, deleted_by, deleted_by_name |
| page.renamed | page_id, space_id, workspace_id, old_title, new_title, renamed_by, renamed_by_name |
| page.moved | page_id, workspace_id, title, old_space_id, new_space_id, old_parent_id, new_parent_id, moved_by, moved_by_name |
| page.hierarchy_changed | page_id, space_id, workspace_id, title, old_parent_id, new_parent_id, changed_by, changed_by_name |
| page.reordered | page_id, space_id, workspace_id, old_display_order, new_display_order, parent_page_id, reordered_by, reordered_by_name |
| test | message |
IDs are UUIDs. author_id and the *_by fields hold the ID of the user who made the change; for an agent, that's the user its token belongs to. The matching *_name fields can be null. content is the page's full Markdown. Parent IDs are null for top-level pages.
diff summarizes the change in lines. additions and deletions are counts, and preview shows up to 10 changed lines, each starting with + or -. If a change is too large to compare quickly, diff arrives as a summary instead: type is "summary", truncated is true, additions and deletions are null, and old_lines and new_lines give the line counts.
Example: page.updated
POST /webhook HTTP/1.1 Content-Type: application/json User-Agent: AgentDocs-Webhook/1.0 X-AgentDocs-Event: page.updated X-AgentDocs-Signature: sha256=a8b18d2cbe6b54bd09fcb511bc4b15df2d56e83d3563748aa6f1b1cde781760e
{
"event": "page.updated",
"timestamp": "2026-10-26T10:30:00.000Z",
"data": {
"page_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"space_id": "3f2b8c1e-5d4a-4e6b-9a7c-2d1e0f9b8a76",
"workspace_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"title": "Deployment Guide",
"slug": "deployment-guide",
"content": "# Deployment Guide\n\nRun the migration before you deploy.\nThen restart the app.\n",
"author_id": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"author_name": "Alice",
"version": 7,
"previous_version": 6,
"diff": {
"type": "lines",
"additions": 2,
"deletions": 1,
"preview": "-Run the migrations first.\n+Run the migration before you deploy.\n+Then restart the app."
}
}
}Example: page.deleted
{
"event": "page.deleted",
"timestamp": "2026-10-26T10:31:12.000Z",
"data": {
"page_id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"space_id": "3f2b8c1e-5d4a-4e6b-9a7c-2d1e0f9b8a76",
"workspace_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"title": "Deployment Guide",
"deleted_by": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
"deleted_by_name": "Alice"
}
}HMAC signature verification
If the webhook has a secret, each request carries an X-AgentDocs-Signature header: sha256= followed by the hex HMAC-SHA256 of the raw request body, keyed with your secret. Check it to confirm the request came from AgentDocs. Webhooks without a secret are not signed, so always set one.
Verification process
- Read the
X-AgentDocs-Signatureheader. - Compute sha256= plus the hex HMAC-SHA256 of the raw body bytes, using your secret. Use the body exactly as received: parsed and re-serialized JSON won't match.
- Compare the two values with a constant-time comparison.
- If the header is missing or the values differ, reject the request with 401.
Node.js example
const crypto = require('crypto');
const express = require('express');
const app = express();
function isValidSignature(rawBody, header, secret) {
if (typeof header !== 'string') return false;
const expected = Buffer.from(
'sha256=' + crypto.createHmac('sha256', secret).update(rawBody).digest('hex')
);
const received = Buffer.from(header);
// timingSafeEqual throws on a length mismatch, so compare lengths first.
return received.length === expected.length &&
crypto.timingSafeEqual(received, expected);
}
// express.raw keeps the body as the exact bytes AgentDocs signed.
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const header = req.get('X-AgentDocs-Signature');
if (!isValidSignature(req.body, header, process.env.AGENTDOCS_WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // reply within 10 seconds
handleEvent(event); // your code: do slow work after replying
});
app.listen(3000);Python example
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["AGENTDOCS_WEBHOOK_SECRET"].encode()
@app.post("/webhook")
def handle_webhook():
body = request.get_data() # raw bytes, exactly as signed
expected = "sha256=" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()
received = request.headers.get("X-AgentDocs-Signature", "")
if not hmac.compare_digest(expected.encode(), received.encode()):
abort(401)
event = request.get_json()
print(event["event"], event["data"].get("page_id"))
return "", 200Retry behavior
If a delivery fails with a 5xx status, a network error or no full response within 10 seconds, AgentDocs retries it up to three times. A 4xx response or a redirect (3xx) is not retried. Redirects are never followed.
| Attempt | Delay | Total elapsed |
|---|---|---|
| 1st retry | 2 seconds | ~2 s |
| 2nd retry | 10 seconds | ~12 s |
| 3rd retry (last) | 60 seconds | ~72 s |
Elapsed times leave out the time spent waiting for your server: up to 10 seconds per attempt.
After the third retry fails, AgentDocs stops trying. The webhook stays active; it is never disabled automatically. Every attempt appears in the delivery log, where you can resend it. Delivery is best-effort: retries that are still waiting are lost if the AgentDocs server restarts.
⚠️ Tip: AgentDocs waits at most 10 seconds for the full response. Reply right away with a 2xx status and do slow work in the background. Don't answer with a redirect: it counts as a failure.
Delivery details
- Headers: Content-Type: application/json, User-Agent: AgentDocs-Webhook/1.0, X-AgentDocs-Event (the event name) and, if the webhook has a secret, X-AgentDocs-Signature.
- The URL must be a public http:// or https:// address. Addresses on private, loopback, link-local or other internal networks are rejected when you save the webhook, and again before each delivery. Use https://, because the body includes page content.
- Events can arrive out of order, and occasionally more than once (for example, when your server was slow and the delivery was retried). Use timestamp, and data.version on page.created and page.updated, to put them in order and skip ones you've already handled.
- Each change is sent once to every active webhook subscribed to its event. A disabled webhook receives nothing, and missed events are not replayed when you turn it back on.
- Webhook deliveries don't count toward your monthly API calls.
Setting up webhooks
Only the workspace owner can create, change or delete webhooks, from a browser session or with the owner's account API token. Space admins, space tokens and apps connected over OAuth (such as an MCP connector) can't manage webhooks.
Via the UI
- Open your workspace settings (
/:workspaceSlug/settings?tab=webhooks) - Open the Webhooks tab
- Click "New webhook"
- Enter your endpoint URL. Use https://.
- Pick one event. For more events, create more webhooks.
- Enter a secret (optional, recommended). Use a long random value, for example from openssl rand -hex 32.
- Click "Create webhook"
- Use the lightning icon to send a test event, and the clock icon to see each delivery attempt and resend failed ones.
Via the API
# Create a webhook (workspace owner, account API token)
curl -X POST https://agentdocs.eu/api/workspaces/WORKSPACE_ID/webhooks \
-H "Authorization: Token YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-server.com/webhook",
"event": "page.updated",
"secret": "YOUR_RANDOM_SECRET"
}'
# Response: 201 Created
{
"webhook": {
"id": "5f0c2a8e-1b7d-4c3e-9a6f-8d2b4e1c7a90",
"workspace_id": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d",
"url": "https://your-server.com/webhook",
"event": "page.updated",
"active": true,
"secret": "YOUR_RANDOM_SECRET",
"created_at": "2026-10-26T10:30:00.000Z",
"updated_at": "2026-10-26T10:30:00.000Z"
}
}
# Manage webhooks
GET /api/workspaces/WORKSPACE_ID/webhooks # list (includes secrets)
PATCH /api/workspaces/WORKSPACE_ID/webhooks/WEBHOOK_ID # url, event, secret, active
DELETE /api/workspaces/WORKSPACE_ID/webhooks/WEBHOOK_ID
GET /api/workspaces/WORKSPACE_ID/webhooks/WEBHOOK_ID/logs # last 20 attempts
GET /api/workspaces/WORKSPACE_ID/webhooks/WEBHOOK_ID/stats # 7- and 30-day success rates
POST /api/workspaces/WORKSPACE_ID/webhooks/WEBHOOK_ID/logs/LOG_ID/retry # resend a logged delivery
POST /api/webhooks/test {"url": "...", "secret": "..."} # send a test event (10 per hour)To rotate a secret, PATCH the webhook with a new one. The next delivery is signed with it, with no overlap, so accept both secrets on your side while you switch. A resend from the delivery log reuses the original payload and timestamp. The test endpoint sends an event named test whose data holds only a message, and returns your status code and the first 500 characters of your response.
Use case: keeping agents in sync
To keep an agent's knowledge base current, point three webhooks (page.created, page.updated and page.deleted) at the same URL. Three webhooks need the Pro plan. The page's Markdown is already in the payload, so there's nothing to fetch.
// One handler for page.created, page.updated and page.deleted
async function handleEvent(event) {
const { data } = event;
if (event.event === 'page.deleted') {
await removeDocument(data.page_id); // your code
return;
}
if (event.event === 'page.created' || event.event === 'page.updated') {
// Events can arrive out of order: keep only the newest version.
const stored = await getDocument(data.page_id); // your code
if (stored && stored.version >= data.version) return;
await upsertDocument(data.page_id, { // your code
title: data.title,
content: data.content,
version: data.version,
});
}
}