Webhooks
POST /indexes/{slug}/webhook — HMAC-signed document ingestion for CMS and event-driven pipelines, with a five-minute replay window.
POST /api/v1/indexes/{slug}/webhookA signed ingest endpoint for systems that push documents on their own schedule: your CMS, a publish pipeline, a queue worker. The body carries documents, the signature proves the sender knows a shared secret, and the tenant comes from the API key — never from the payload.
Requires scope documents:write.
Signature
Two headers, both required:
| Header | Value |
|---|---|
x-aac-timestamp | Unix time in seconds |
x-aac-signature | HMAC-SHA256(secret, "{timestamp}.{rawBody}") as hex; an optional sha256= prefix is accepted |
The secret is the index's webhookSecret setting, falling back to the deployment-wide SEARCH_WEBHOOK_SECRET. The timestamp must be within ±300 seconds of server time, which limits replay of a captured request.
BODY='{"documents":[{"external_id":"release-1.8","title":"Release 1.8","content":"Semantic search is generally available.","url":"https://example.com/changelog/1-8"}],"action":"upsert"}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -sS -X POST "$AAC_API/indexes/documentation/webhook" \
-H "Authorization: Bearer $AAC_KEY" \
-H "Content-Type: application/json" \
-H "x-aac-timestamp: $TS" \
-H "x-aac-signature: sha256=$SIG" \
--data "$BODY"{
"index": "documentation",
"indexed": 1,
"failed": [],
"usage": {
"plan": "pro",
"period": "2026-10",
"resetsAt": "2026-11-01T00:00:00.000Z",
"documents": { "used": 140, "limit": 50000 },
"searches": { "used": 18420, "limit": 500000 }
},
"request_id": "req_8b0d2f4a6c8e0b2d4f6a8c0e"
}The signature must be computed over the raw request body, before any JSON re-serialisation — key order and whitespace matter. In Node.js:
import { createHmac } from "node:crypto";
const rawBody = JSON.stringify({ documents, action: "upsert" });
const timestamp = Math.floor(Date.now() / 1000).toString();
const signature = createHmac("sha256", process.env.WEBHOOK_SECRET)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
await fetch(`${api}/indexes/documentation/webhook`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.AAC_KEY}`,
"Content-Type": "application/json",
"x-aac-timestamp": timestamp,
"x-aac-signature": `sha256=${signature}`,
},
body: rawBody,
});Body
| Field | Required | Notes |
|---|---|---|
documents | yes | 1–1000 documents, same shape as POST /documents |
action | no | upsert (default), create, update, emplace |
Documents are imported with source_type webhook, and the run is written to the audit log as webhook.ingest with the document count and how many were indexed.
Failures
| Status | Code | Cause |
|---|---|---|
400 | INVALID_JSON | Body is not valid JSON — check what you signed |
401 | UNAUTHORIZED | Missing x-aac-timestamp or x-aac-signature |
401 | STALE_SIGNATURE | Timestamp outside the 300-second window |
401 | INVALID_SIGNATURE | HMAC does not match the payload |
422 | INVALID_DOCUMENT | documents[] missing, empty, or more than 1000 entries |
503 | WEBHOOK_NOT_CONFIGURED | No webhookSecret on the index and no SEARCH_WEBHOOK_SECRET on the server |
{
"error": {
"code": "INVALID_SIGNATURE",
"message": "Signature does not match the payload",
"request_id": "req_1d3f5b7a9c1e3d5f7a9b1c3e"
}
}Signature comparison is constant-time, so a near-miss leaks nothing about the expected value.
Retries and ordering
The endpoint is not idempotent on its own — a retried request re-imports the same documents, which is harmless for upsert (same external_id, same content) but double-counts nothing except a little CPU. If you need exactly-once semantics, add your own sequence number to metadata and reconcile, or use POST /documents with an Idempotency-Key.
Documents are indexed in the order received within a single call. There is no cross-call ordering guarantee, so a publisher that can deliver out of order should include a version field in the document and decide server-side which copy wins.