AACSearch / guide
Webhooks
Receive signed AACSearch events on your own endpoint — subscribe, verify the HMAC signature, handle retries, and diagnose failed deliveries.
A webhook is an HTTPS endpoint you register with AACSearch. When something
happens in your organization — an experiment finishes, a hosting configuration
changes, a support thread is created — AACSearch sends a signed POST to that
endpoint with a JSON body describing the event.
Webhooks are push, not poll. They carry no API key: the receiver proves the request came from AACSearch by verifying an HMAC signature.
Webhooks versus the connector webhook
A connector has its own inbound webhook
(POST /connectors/{connectorId}/webhook) that pushes catalogue data into
AACSearch. The webhooks on this page go the other way: AACSearch notifies
your system about events in your organization.
Before you begin
- An organization where you are an owner or admin. Any member can read the list and send a test; creating, editing, rotating and deleting require one of those two roles.
- A public HTTPS endpoint that accepts
POSTand returns2xxquickly. - A way to read a request body before parsing it as JSON — signature verification needs the exact bytes.
What you'll build
An endpoint that verifies every delivery and acknowledges it, plus an alerting view of failures.
Steps
1. Create the endpoint
Open Webhooks in the console (/dashboard/webhooks) and choose
New webhook. Enter:
- a name (up to 120 characters),
- an endpoint URL (up to 2048 characters, HTTP or HTTPS),
- the events to receive — either individual events, or All events.
Copy the signing secret now
The whsec_… secret is shown once, in the "Webhook created" card. If you
lose it, rotate it from the endpoint's detail page.
Expected result: the endpoint appears in the list, enabled, with a "No deliveries yet" status.
Verify: press Send test. This creates a real delivery of a
webhook.test event. Your endpoint should receive it within a few seconds, and
the row's status should turn to success on Refresh.
All events is a live subscription
Choosing All events writes the * wildcard, so the endpoint also receives
events added to the platform later. Three events —
experiment.winner_detected, quality.regression and event.replayed —
have no group in the picker and can only be chosen through All events.
2. Read the delivery
Every request is a JSON POST with these headers:
| Header | Meaning |
|---|---|
X-AACSearch-Event | The event name, e.g. experiment.promoted |
X-AACSearch-Delivery | The delivery id (dlv_…), unique per event |
X-AACSearch-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256> |
The body is the envelope — your data is in data:
{
"id": "dlv_01J…",
"event": "experiment.promoted",
"created_at": "2026-09-16T10:32:11.482Z",
"organization_id": "org_01J…",
"data": { "experiment_id": "8c1f…", "variant_id": "popular" }
}3. Verify the signature
v1 is a hex HMAC-SHA256 of `${t}.${rawBody}` keyed by your whsec_…
secret. Compute it over the raw body, then compare in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";
/** Returns true only for a fresh, unmodified, correctly signed delivery. */
export function verifyWebhook(
secret: string,
rawBody: string,
signatureHeader: string,
toleranceSeconds = 300,
): boolean {
const parts = Object.fromEntries(
signatureHeader.split(",").map((part) => part.split("=") as [string, string]),
);
const timestamp = Number(parts.t);
const provided = parts.v1;
if (!Number.isFinite(timestamp) || !provided) return false;
// Reject replays of an old delivery.
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`).digest("hex");
const a = Buffer.from(expected, "hex");
const b = Buffer.from(provided, "hex");
return a.length === b.length && timingSafeEqual(a, b);
}Two mistakes that break verification
Parsing before verifying. Read the raw bytes and verify against those. A
JSON parser that re-serialises the body changes it, and the signature will
never match. Comparing with ===. Use a constant-time comparison so the
check cannot be timed.
Verify: send a test delivery and log the computed value alongside the
header. They must be equal. Then change one character of the secret and confirm
your verifier returns false.
4. Acknowledge quickly, process afterwards
Return 2xx as soon as the signature is verified, then do the work
asynchronously. A slow receiver is treated the same as a failing one.
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const rawBody = await request.text();
const signature = request.headers.get("X-AACSearch-Signature") ?? "";
if (!verifyWebhook(env.WEBHOOK_SECRET, rawBody, signature)) {
return new Response("invalid signature", { status: 401 });
}
const delivery = JSON.parse(rawBody);
const id = request.headers.get("X-AACSearch-Delivery") ?? delivery.id;
// Deduplicate: delivery is at-least-once (see "Retries" below).
if (await env.SEEN.get(id)) return new Response("ok", { status: 200 });
await env.SEEN.put(id, "1", { expirationTtl: 86_400 * 3 });
await env.QUEUE.send({ event: delivery.event, data: delivery.data });
return new Response("ok", { status: 200 });
},
};Verify: the console's delivery row shows a 2xx HTTP code and attempt count
1.
Retries and idempotency
A delivery is at-least-once. Failed attempts are retried with exponential backoff and full jitter:
| Property | Value |
|---|---|
| Total attempts | 6 (the first, plus 5 retries) |
| Base delay | 30 seconds |
| Cap | 15 minutes |
| Jitter | Full — each wait is a uniform draw from [0, min(30s × 2^(n-1), 15min)] |
| Last attempt | Lands within roughly 30 minutes of the first |
The console says “retried twice” — the platform retries five times
The delivery screen states that failed attempts are retried twice. The delivery queue actually grants six attempts in total. Size your deduplication window against the larger number.
An attempt is retried only when the failure might clear on its own:
| Your response | Retried? |
|---|---|
2xx | No — delivered |
408, 429 | Yes |
5xx | Yes |
Any other 4xx or 3xx | No — the delivery is recorded as failed |
Because a retry can follow an attempt whose response you never saw, your
handler must be idempotent. Key on X-AACSearch-Delivery (as in step 4) and
make the side effect safe to repeat.
Verify: point an endpoint at a URL that returns 500. The console shows the
delivery retried with a growing delay, still pending, until the sixth attempt
marks it failed.
Events you can subscribe to
| Group | Events |
|---|---|
| Support | support.thread.created, support.message.created |
| Assistant | conversation.completed |
| Experiments | experiment.started, experiment.stopped, experiment.promoted, experiment.winner_detected* |
| Hosting | hosting.enabled, hosting.updated, hosting.disabled |
| Media | media.uploaded |
| API keys | api_key.created, api_key.revoked |
| Quality | quality.regression* |
| Events | event.replayed* |
| Alerts | alert.fired |
* No group in the console picker — subscribe with All events.
A webhook's events list may also be the wildcard *, which covers every
event including ones added later. webhook.test is sent only by Send test
and is not subscribable.
Inspect deliveries
The endpoint's detail page lists the 100 most recent deliveries with their event, status, HTTP code, attempt count and time. A row opens the full record: status, HTTP code, attempts, created and finished timestamps, the error text, and the exact JSON request body with a copy action.
Use it to tell these apart:
| Symptom | Likely cause | Fix |
|---|---|---|
401 / non-2xx on every delivery | Signature verified against a re-serialised body | Verify over the raw body (step 3) |
| Signature never matches | Wrong secret after a rotation | Copy the current secret; rotation takes effect immediately |
| Handled twice | No deduplication across retries | Key on X-AACSearch-Delivery |
Delivery stays pending | Your endpoint is slow (>30 s) or returns 5xx | Acknowledge first, process asynchronously |
| Nothing arrives, list is empty | No event matched the subscription | Check the event list, or use All events |
| Disabled endpoint | The enable switch is off | Re-enable it on the endpoint or in the list |
Next
- Errors and retries — the API-side error contract and idempotency.
- A/B experiments — the
experiment.*events in context. - Organization and billing — roles that may manage webhooks.