AACSearch

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}/webhook

A 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:

HeaderValue
x-aac-timestampUnix time in seconds
x-aac-signatureHMAC-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

FieldRequiredNotes
documentsyes1–1000 documents, same shape as POST /documents
actionnoupsert (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

StatusCodeCause
400INVALID_JSONBody is not valid JSON — check what you signed
401UNAUTHORIZEDMissing x-aac-timestamp or x-aac-signature
401STALE_SIGNATURETimestamp outside the 300-second window
401INVALID_SIGNATUREHMAC does not match the payload
422INVALID_DOCUMENTdocuments[] missing, empty, or more than 1000 entries
503WEBHOOK_NOT_CONFIGUREDNo 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.

On this page