Loading...

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 to https://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.
1

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.

The Webhooks page
2

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.

The create-webhook dialog
3

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.

Selected event chips and the signing secret

Auto-plays · use Back / Next to step through at your own pace.

Create a webhook
  1. Developers → Webhooks → Create Webhook.
  2. The URL: an HTTPS address on your side, reachable from the internet — up to 2,048 characters, never a private or loopback address.
  3. The events, from the grouped list.
  4. Create. The signing secret is shown; View secret shows it again later, Rotate secret issues a new one.
The Create Webhook dialog with a URL filled in and the grouped event catalog open
The events you can subscribe to
GroupEventsFires when…
Search resultssearchresult.created, .updated, .deleted, .bulk-createda 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.
Gatessearchresult.rejected, searchresult.quarantinedan 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.
Gatessearchresult.releasedsomeone releases a held-back record.
Gatesgate.backfill-completeda gate backfill finishes, fails, or is stopped because its gate was deleted or detached; carries its counters and error.
Listslist.created, .updated, .deleteda list is created, its settings change, or it is deleted.
Fieldsfacet.*, 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.
Searchsuggestion.added, search.rate-limit-reacheda user-generated suggestion is added, or search requests hit the per-minute rate limit.
Watcheswatch.matched, watch.digesta watch alerts on one record, or one check that found something ends. Subscribe to either, or both to get both.
Importsimport.completed, import.failedan 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.
Feedsfeed.sync-completed, feed.sync-failed, feed.recovered, feed.sync-incomplete, feed.paused, feed.resumed, feed.deleteda 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 feedsemail.ingested, email.rejected, email.backfill-completed, email.verification-receiveda 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.
Mediamedia.uploaded, media.deleted, media-store.created, media-store.deleteda file or media store changes. Store credentials are never included.
Accessapi-key.created, api-key.updated, api-key.deleted, contributor.added, contributor.removedan API key or contributor is added, changed or removed. The key value is never included.
Evalseval.run-completed, eval.run-regresseda 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.
Accountsubscription.created, subscription.auto-upgradeda 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
}
HeaderWhat it is
X-Searchability-EventThe event type, e.g. searchresult.created.
X-Searchability-DeliveryA unique id for this delivery: store it, ignore repeats.
X-Searchability-TimestampWhen it was signed, in Unix epoch seconds.
X-Searchability-Signaturesha256=<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-Event is not covered by the signature. Store each X-Searchability-Delivery id 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-Delivery ids you have seen and ignore repeats.
  • Send test sends a webhook.ping any 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 0 in 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
FreePaidEnterprise
Webhooks10100Unlimited
Manage subscriptions from the API

Everything but the secret is also on the public API and the MCP tools. Shapes in the API reference.

RouteMCP toolPermissionDoes
GET /webhook/eventswebhook_event_listwebhook:readThe event types you can subscribe to.
POST /webhookwebhook_createwebhook:createCreate one: account_name, url and events in the body. The response carries the secret once.
PUT /webhook/{account}/{id}webhook_editwebhook:editReplace the URL and events (send both). Keeps the signing secret; does not resume a suspended subscription.
DELETE /webhook/{account}/{id}webhook_deletewebhook:removeRemove it and its delivery log.
POST /webhook/{account}/{id}/testwebhook_testwebhook:createSend a test ping.
POST /webhook/{account}/{id}/resumewebhook_resumewebhook:editResume a suspended one.
GET /webhook/{account}/{id}/deliverieswebhook_delivery_listwebhook:readDelivery 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 code in the problem body:
codeRefused becauseRoute
1The account does not exist.Create
2The URL is invalid, or points at an address we refuse.Create, edit
3No event types, or one we don’t know.Create, edit
4The account has no such subscription.Edit, delete, test, resume, deliveries
6The 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.
Top