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 call your app gets when something changes: Search Stack POSTs a small JSON message to a URL you own the moment an event happens. 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 copy the signing secret; View secret shows it again later. Every delivery is signed with it (HMAC-SHA256). Temporary failures are retried; each delivery carries a unique id for de-duplication.

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 — up to 2,048 characters, never a private or loopback address.
- The events, from the grouped list.
- Create. The signing secret is shown; View secret shows it again later, 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 through the API, MCP or console. Bulk adds send one summary event with a count. Imports and feed syncs send import.completed instead. |
| Gates | searchresult.rejected, searchresult.quarantined | an import, feed sync or bulk_insert / bulk_insert_with_fields has a gate refuse a record, or admit it held back (active = false); carries gate_name and reason, up to 200 of each per batch of 1,000 rows or per bulk_insert call. Single creates, bulk_create and edits send none. |
| Gates | searchresult.released | someone releases a held-back record. |
| Gates | gate.backfill-completed | a gate backfill finishes, fails, or is stopped because its gate was deleted or detached; carries its counters and error. |
| 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. Renames carry previous_name. facet.type-changed: an import changed a guessed type to string; carries previous_type, type, row, value. |
| Search | suggestion.added, search.rate-limit-reached | a user-generated suggestion is added, or search requests hit the per-minute rate limit. |
| Watches | watch.matched, watch.digest | a watch alerts on one record, or one check that found something ends. Subscribe to either, or both to get both. |
| Imports | import.completed, import.failed | an import run ends, whatever started it: a feed, a file, an upload or index-a-document. One event per run, with row counts and the error. |
| Feeds | feed.sync-completed, feed.sync-failed, feed.recovered, feed.sync-incomplete, feed.paused, feed.resumed, feed.deleted | a feed produces a new file, starts failing (first failure, then the third in a row), recovers, keeps stopping short, or is paused, resumed or deleted. |
| Email feeds | email.ingested, email.rejected, email.backfill-completed, email.verification-received | a forwarded message is taken or turned away, an .mbox backfill finishes, or an unverified feed receives its confirmation mail. The code and links are never included. |
| 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.updated, api-key.deleted, contributor.added, contributor.removed | an API key or contributor is added, changed or removed. The key value is never included. |
| Evals | eval.run-completed, eval.run-regressed | a run finishes, Completed or Failed (status says which), or its pass rate drops. Both carry failed_queries; eval.run-completed also carries failure_reason — the rule. |
| Account | subscription.created, subscription.auto-upgraded | a subscription starts (checkout, free BYO or admin), or a plan is auto-upgraded near a limit (carries from_plan/to_plan). |
webhook.ping is not in the list: only Send test sends it.
What a delivery looks like
The body is the event’s data as JSON, sent as application/json. For a record event it is the record:
{
"id": "tt1375666",
"account_name": "acme",
"list_name": "movies",
"name": "Inception",
"active": true
}
| 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.
X-Searchability-Eventis not covered by the signature. Store eachX-Searchability-Deliveryid and refuse repeats, so a captured delivery cannot be re-sent under another event type; and check the body has the fields the event you act on carries.
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. Each attempt allows 5 seconds to connect and 10 seconds for a reply: respond with a 2xx first, then do the slow work.
- Retried only on a temporary failure: 5xx, 408, 429, timeout, network error, or a DNS lookup that timed out or failed (SERVFAIL). A host that doesn’t exist fails at once. One quick retry, then re-sent after 1 min, 5 min, 30 min, 2 h and 6 h — 6 attempts over about 8.5 hours, then dropped.
- Any other 4xx is a failed delivery straight away. Redirects are not followed — a 3xx is a failed delivery too.
- A blocked address — the URL now resolves to a private, loopback or internal address, or DNS says its host doesn’t exist (no such name, or no address) — fails straight away too: no retry, logged as No response, and it counts toward suspension.
- A dead endpoint backs off. After 3 attempts in a row with no response or a 5xx, its deliveries pause for 1 minute, doubling to 30. A retry due in a pause is skipped with no log row; an event's first and last attempts wait for the pause and are always sent.
- 5 events in a row each failed suspend the webhook and email the account owner. Resume it once the URL works; events while suspended are not queued and not resent.
- At most 2 deliveries at once per webhook, and 4 per receiving host per account (per worker). Over that, a delivery waits 15 s, doubling to 4 min plus jitter, up to 20 times — 1 to 2 hours — then is sent anyway.
- No ordering guarantee. Retries, waits and parallel workers mean events can arrive out of order; read the current state when order matters.
- Expect duplicates. Keep the
X-Searchability-Deliveryids you have seen and ignore repeats. - Send test sends a
webhook.pingany time, even while suspended — checks URL, signature and 2xx in one go. It never counts toward suspension. - Delivery log (the webhook's View): one row per attempt sent, so a retried delivery shows several rows with the same delivery id. No response (status
0in the API) means the URL never answered: timeout, refused or unreachable. - The log keeps the first 32,000 characters of each body. The delivery itself sent the full body.
Limits
| Free | Paid | Enterprise | |
|---|---|---|---|
| Webhooks | 10 | 100 | Unlimited |
Manage subscriptions from the API
Everything but the secret is also on the public API and the MCP tools. Shapes in the API reference.
| Route | MCP tool | Permission | Does |
|---|---|---|---|
GET /webhook/events | webhook_event_list | webhook:read | The event types you can subscribe to. |
POST /webhook | webhook_create | webhook:create | Create one: account_name, url and events in the body. The response carries the secret once. |
PUT /webhook/{account}/{id} | webhook_edit | webhook:edit | Replace the URL and events (send both). Keeps the signing secret; does not resume a suspended subscription. |
DELETE /webhook/{account}/{id} | webhook_delete | webhook:remove | Remove it and its delivery log. |
POST /webhook/{account}/{id}/test | webhook_test | webhook:create | Send a test ping. |
POST /webhook/{account}/{id}/resume | webhook_resume | webhook:edit | Resume a suspended one. |
GET /webhook/{account}/{id}/deliveries | webhook_delivery_list | webhook:read | Delivery attempts, newest first: event type, HTTP status, succeeded or not. No payloads. |
- No list route. Subscriptions and their ids are in
GET /account/{account}(account_get) →webhook_subscriptions. - A newly ticked event can take up to 30 s to start; an unticked one stops at once, queued retries included. A single-record write may still be routed by a server that hasn’t seen the change yet.
- Clients:
client.Webhooks.…covers all but resume and the delivery log, which are API/MCP only for now. - Rotating or viewing the secret is console-only, so a leaked API key can never read your signing secret.
- Refusals carry a
codein the problem body:
code | Refused because | Route |
|---|---|---|
| 1 | The account does not exist. | Create |
| 2 | The URL is invalid, or points at an address we refuse. | Create, edit |
| 3 | No event types, or one we don’t know. | Create, edit |
| 4 | The account has no such subscription. | Edit, delete, test, resume, deliveries |
| 6 | The plan’s webhook limit is reached. | Create |
- Code 5 is never returned.
- A create or edit whose DNS lookup timed out, or a create that couldn’t read the account, gets 503 with no
code: try again.