Webhooks
A webhook is a phone call your app receives when something changes. Instead of polling — asking the API “anything new yet?” on a timer — you give us a URL you own, and the moment an event happens we POST a small JSON message to it. A record is added, a list is deleted, an eval regresses: your app hears about it straight away, and does whatever it needs to — rebuild a cache, kick off a deploy, send a Slack message.
Each webhook is a subscription: one URL, plus the set of events you want delivered there. When an event fires, we look up every active subscription that opted in to that event type and send each one its own signed request. Subscribe to as little or as much as you like; anything you didn’t tick is never sent.
Auto-plays · use Back / Next to step through at your own pace.
Create a webhook
- Go to Developers → Webhooks and click Create Webhook.
- Enter the URL that should receive the calls — a publicly reachable HTTPS endpoint on your side.
- Pick the events you care about from the grouped catalog. Only the ones you select are delivered.
- Click Create. We show the signing secret once — copy it now and store it somewhere safe. It is never shown again; if you lose it, use Rotate secret to generate a new one.
The events you can subscribe to
Events are grouped by the part of your account they come from. The common ones:
| Group | Events | Fires when… |
|---|---|---|
| Search results | searchresult.created, .updated, .deleted, .bulk-created | a record is added, changed or removed (bulk inserts 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 — handy as a CI gate. |
| 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 request body is the event’s JSON payload, sent as application/json. A searchresult.created delivery, for example:
{
"id": "tt1375666",
"list": "movies",
"data": { "name": "Inception", "year": 2010 }
}
Every request also carries these headers so you can route, verify and de-duplicate it:
| 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 and ignore repeats. |
X-Searchability-Timestamp | Unix epoch seconds when the request was signed. |
X-Searchability-Signature | sha256=<hex> — the HMAC of the timestamp and body, keyed with your secret. |
Verify the signature
Because your endpoint is a public URL, anyone could POST to it. The signature is how you know a call really came from us and wasn’t tampered with. It is HMAC-SHA256 of timestamp + "." + body using your subscription’s secret, hex-encoded and prefixed with sha256=. Recompute it on your side and compare — always with a constant-time comparison, and reject any request whose timestamp is more than a few minutes off your clock to shut down replay attacks.
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
Delivery is asynchronous and resilient. A transient failure — a 5xx, a timeout, a dropped connection — is retried a few times with exponential backoff. If your endpoint keeps failing, the message is retried from the queue several more times and then set aside for inspection rather than being dropped silently. Two practical consequences for your handler:
- Respond fast with a 2xx. Acknowledge the delivery quickly and do slow work afterwards — a slow handler looks like a failure and gets retried.
- Expect the occasional duplicate. Retries mean the same event can arrive twice. De-duplicate on
X-Searchability-Deliveryand make your handler idempotent.
Use Send test ping on a subscription to fire a webhook.ping at your endpoint any time — the quickest way to confirm your URL, signature check and 2xx response all work before real events depend on them.
A worked example
Your app keeps a cached “featured products” block that reads from a products list. Rather than rebuilding it on a timer, you create one webhook: URL https://api.acme.example/hooks/search, subscribed to searchresult.created, searchresult.updated and searchresult.deleted. Now, the instant anyone changes a product, we POST the change to your endpoint; your handler verifies the signature, checks the delivery id isn’t one it has already seen, invalidates the cache and returns 200 in a millisecond. The cache is never stale and never rebuilt for nothing — the work happens exactly when, and only when, the data actually changes.