Webhooks
A URL of yours that we POST a small JSON message to the moment something changes — so your code never asks on a timer.
- A webhook is a subscription: one URL plus the events you want there. Add a record to
products, we POST tohttps://example.com/hooks/searchstack. - Every active subscription for that event type gets its own signed message. What you didn’t tick is never sent.
- Your app reacts at once — clear a cache, start a release, post to Slack.
What a webhook is
A webhook is a phone call your app gets when something changes. Instead of polling to ask “anything new?”, Search Stack POSTs a small JSON message to a URL you own the moment an event happens — a record added, a list deleted, an eval regressed. Webhooks live under Developers → Webhooks.

Pick a URL and the events
Give it the URL that should receive the calls, then pick which events you care about. The catalog is grouped by area — API keys, contributors, evals, lists, media, search results and more — so you subscribe to exactly what your app needs and nothing else.

Signed, retried, de-duplicable
Chosen events show as chips. Click Create and Search Stack shows the signing secret once — copy it now. Every delivery is signed with it (HMAC-SHA256) so your endpoint can verify the call really came from us. Deliveries retry on failure and each carries a unique id you can use to de-duplicate.

Auto-plays · use Back / Next to step through at your own pace.
Create a webhook
- Developers → Webhooks → Create Webhook.
- The URL: an HTTPS address on your side, reachable from the internet.
- The events, from the grouped list.
- Create. The signing secret shows once — copy it now. Rotate secret issues a new one.
The events you can subscribe to
| Group | Events | Fires when… |
|---|---|---|
| Search results | searchresult.created, .updated, .deleted, .bulk-created | a record is added, changed or removed. Bulk adds send one summary event with a count. |
| Lists | list.created, .updated, .deleted | a list is created, its settings change, or it is deleted. |
| Fields | facet.*, searchable-field.*, resource.* | a field is added, renamed or removed on a list. Renames carry previous_name. |
| Media | media.uploaded, media.deleted, media-store.created, media-store.deleted | a file or media store changes. Store credentials are never included. |
| Access | api-key.created, api-key.deleted, contributor.added, contributor.removed | an API key or contributor is added or removed. The key value is never included. |
| Evals | eval.run-completed, eval.run-regressed | an evaluation finishes, or its score drops below the baseline. |
| Account | subscription.auto-upgraded, webhook.ping | a plan is auto-upgraded near a limit (carries from_plan/to_plan), or you send a test ping. |
What a delivery looks like
The body is the event’s data as JSON, sent as application/json:
{
"id": "tt1375666",
"list": "movies",
"data": { "name": "Inception", "year": 2010 }
}
| Header | What it is |
|---|---|
X-Searchability-Event | The event type, e.g. searchresult.created. |
X-Searchability-Delivery | A unique id for this delivery: store it, ignore repeats. |
X-Searchability-Timestamp | When it was signed, in Unix epoch seconds. |
X-Searchability-Signature | sha256=<hex>: an HMAC of the timestamp, the body and your secret. |
Verify the signature
Your URL is public, so anyone could POST to it. The signature is HMAC-SHA256 of timestamp + "." + body using your subscription’s secret, hex-encoded, prefixed sha256=.
- Recompute it and compare in constant time, as below.
- Reject a timestamp more than a few minutes off your clock, so old messages cannot be re-sent.
public static bool IsValid(string secretBase64, string timestamp, string payload, string signatureHeader)
{
const string Prefix = "sha256=";
if (!signatureHeader.StartsWith(Prefix, StringComparison.Ordinal)) return false;
var key = Convert.FromBase64String(secretBase64);
var data = Encoding.UTF8.GetBytes(timestamp + "." + payload);
using var hmac = new HMACSHA256(key);
var expected = Convert.ToHexString(hmac.ComputeHash(data)).ToLowerInvariant();
var actual = signatureHeader[Prefix.Length..];
return CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(expected), Encoding.ASCII.GetBytes(actual));
}
const crypto = require('node:crypto');
function isValid(secretBase64, timestamp, payload, signatureHeader) {
const prefix = 'sha256=';
if (!signatureHeader.startsWith(prefix)) return false;
const key = Buffer.from(secretBase64, 'base64');
const expected = crypto.createHmac('sha256', key)
.update(`${timestamp}.${payload}`).digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signatureHeader.slice(prefix.length), 'utf8');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
Retries and failures
- Sent in the background. A temporary failure — 5xx, timeout, dropped connection — is retried a few times with a growing wait.
- A URL that keeps failing is retried from the queue several more times, then set aside to be looked at rather than dropped.
- Respond fast with a 2xx, then do the slow work — a slow answer looks like a failure and gets retried.
- Expect duplicates. Keep the
X-Searchability-Deliveryids you have seen and ignore repeats. - Send test ping sends a
webhook.pingany time — checks URL, signature and 2xx in one go.
Manage subscriptions from the API
Subscribe / list / edit / delete / test are also on the public API and the MCP tools, for a key with webhook:* permissions:
GET /webhook/events— the event types you can subscribe to.POST /webhook— create one (url+events); the response carries the secret once.PUT/DELETE /webhook/{account}/{id}— change the URL or events, or remove it.POST /webhook/{account}/{id}/test— send a test ping.- Clients:
client.Webhooks.…. Shapes in the API reference. - Rotating or viewing the secret and reading delivery logs are console-only, so a leaked API key can never read your signing secret.