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.

How it works
1. A person or an agent changes a page (editor, API or MCP)
2. AgentDocs saves the change
3. AgentDocs POSTs a JSON event to every active webhook subscribed to that event
4. Your server handles the event (for example, it updates an agent's context or starts a rebuild)

Events

Each webhook listens for exactly one of these events. To receive several, create one webhook per event. They can share a URL.

EventDescriptionTrigger
page.createdA page was created or restored from the trashIn the app, API create, bulk create, Markdown import, restore from trash
page.updatedA page's content or title was savedEditor save, API update, version restore, approved proposal, Markdown import overwrite, page move
page.deletedA page was moved to the trashDelete in the app, API delete, bulk delete
page.renamedA page's title or slug changedRename in the page tree, API rename, API update with a new title
page.movedA page was moved to a new parent, space or positionMove dialog, API move
page.hierarchy_changedA move gave the page a different parentSent with page.moved when the parent changes
page.reorderedA page's position among its siblings changedAPI 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

EventFields
page.createdpage_id, space_id, workspace_id, title, slug, content, author_id, author_name, version
page.updatedpage_id, space_id, workspace_id, title, slug, content, author_id, author_name, version, previous_version, diff
page.deletedpage_id, space_id, workspace_id, title, deleted_by, deleted_by_name
page.renamedpage_id, space_id, workspace_id, old_title, new_title, renamed_by, renamed_by_name
page.movedpage_id, workspace_id, title, old_space_id, new_space_id, old_parent_id, new_parent_id, moved_by, moved_by_name
page.hierarchy_changedpage_id, space_id, workspace_id, title, old_parent_id, new_parent_id, changed_by, changed_by_name
page.reorderedpage_id, space_id, workspace_id, old_display_order, new_display_order, parent_page_id, reordered_by, reordered_by_name
testmessage

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

  1. Read the X-AgentDocs-Signature header.
  2. 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.
  3. Compare the two values with a constant-time comparison.
  4. 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 "", 200

Retry 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.

AttemptDelayTotal elapsed
1st retry2 seconds~2 s
2nd retry10 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

  1. Open your workspace settings (/:workspaceSlug/settings?tab=webhooks)
  2. Open the Webhooks tab
  3. Click "New webhook"
  4. Enter your endpoint URL. Use https://.
  5. Pick one event. For more events, create more webhooks.
  6. Enter a secret (optional, recommended). Use a long random value, for example from openssl rand -hex 32.
  7. Click "Create webhook"
  8. 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,
    });
  }
}
AgentDocs v1.1.0 · b55b6f7