Keep a Dataset in Sync with a Webhook

A webhook lets a shop, a CMS or a product feed tell Indx when something changes, without code of your own in between. You make a webhook for a dataset, give its address and secret to the sender, and every change it sends is applied to the documents within seconds.

A webhook can also fill a dataset from nothing: make the dataset, make the webhook, and send the full set once (below). From then on the changes keep it current.

Ready-made: Sanity and Contentful need no code at all.

Make a webhook

Team admins make webhooks, in the console under the dataset's Webhooks, or through the API. GET …/webhooks/sources lists the sources a webhook can have, with their settings.

http
POST /api/teams/{team}/datasets/{dataset}/webhooks
{ "name": "Product feed", "idField": "id", "versionField": "updatedAt" }
FieldWhat it does
name
What the webhook is for, shown in the console.
idField
The field that holds the sender's own id, as text or a number. Default id.
versionField
Optional. A date or a number that grows with each version of a document. A change older than the indexed version is left alone, so events that arrive out of order do no harm.
brakeShare
Optional. How much of the dataset deletes may touch in an hour before a call is held. Default 0.1, a tenth.

The answer holds the webhook's url and its secret. The secret is shown once; keep it with the sender. POST …/webhooks/{id}/rotate-secret gives a new one and retires the old at once.

What the sender sends

POST to the webhook's url. The body is the document itself, a list of documents, or { "events": [...] }, up to 1,000 a call. An event field says what happened:

json
[
  { "event": "upsert", "id": "sku-1042", "name": "Oak chair", "price": 129.0, "updatedAt": "2026-10-27T09:14:00Z" },
  { "event": "delete", "id": "sku-0977" }
]

insert, update, upsert and delete are understood, and so are their past tenses and publish and unpublish. Without an event field a document is an upsert. Send the whole document each time: an update replaces the document, it does not merge into it.

Ids may be text. Indx keys documents by number, so a text id gets a number of its own the first time it is seen and keeps it. When the ids are numbers and idField is the key field, they are used as they are.

Sign every call

Each call carries an X-Indx-Signature header: the time and an HMAC-SHA256 of the time and the body, made with the secret.

X-Indx-Signature: t=1793092440,sha256=5f2c…

The signed text is "<t>.<body>", with t in Unix seconds. A call more than five minutes off is refused, so a captured call cannot be sent again later. In Node:

js
import crypto from "node:crypto";

const t = Math.floor(Date.now() / 1000);
const sig = crypto.createHmac("sha256", secret).update(`${t}.${body}`).digest("hex");
await fetch(url, {
  method: "POST",
  headers: { "Content-Type": "application/json", "X-Indx-Signature": `t=${t},sha256=${sig}` },
  body,
});

A good call is answered 202 Accepted as soon as it is stored. Indx applies calls in the order they came, also across a restart. A bad signature gets 401, and more than 120 calls a minute gets 429: send changes in batches instead.

When the data is wrong

A sender can be wrong too, so a webhook checks what it is given:

  • Each document on its own. A text value in a number field, a missing id or an unknown event is refused, and the rest of the call is still applied. Refused documents are listed in the call's delivery.
  • Older versions. With a versionField, a change older than what is indexed is left alone.
  • The brake. Deletes, and updates that empty a field the document had, are counted per hour. A call that would take the count past the brake (a tenth of the dataset as it was an hour before, never fewer than 20) is held, not applied, and the team's admins are told. While it is held the webhook waits: later calls join the same batch, in order, so a bulk unpublish that arrives one document a call (as Sanity and Contentful send it) is one decision, and nothing overtakes what waits. An admin then applies the batch or discards it, under the dataset's Webhooks in the console, or through the API:
http
GET    /api/teams/{team}/datasets/{dataset}/webhooks/{id}/held
POST   /api/teams/{team}/datasets/{dataset}/webhooks/{id}/held/{heldId}/apply
DELETE /api/teams/{team}/datasets/{dataset}/webhooks/{id}/held/{heldId}
POST   /api/teams/{team}/datasets/{dataset}/webhooks/{id}/held/apply      (all, oldest first)
DELETE /api/teams/{team}/datasets/{dataset}/webhooks/{id}/held            (all)

Send the full set

POST the whole set, signed the same way, to the webhook's syncUrl (its url with /sync after it), as a list of documents or { "documents": [...] }. It replaces the dataset's documents: what is not in the set is removed. The old documents answer searches until the new ones are indexed.

Send it once to fill a new dataset, and now and then (nightly is common) to catch any change that went missing on the way. A first sync gives the dataset its key field, indxKey unless it has one, and starting roles read from the documents: text searchable; text whose values repeat (a brand, a category, tags) filterable and facetable too; dates sortable; numbers and true/false filterable and sortable. Adjust them in the console afterwards; later syncs keep what you set.

The brake applies here too: a sync that would leave the dataset more than a tenth smaller is held for an admin, since a broken export looks just like that. A sync with no documents is refused.

GET …/webhooks/{id}/deliveries lists the latest 200 calls: what each one inserted, updated, deleted, left alone and refused, and why.

Sanity

Indx understands Sanity's GROQ-powered webhooks as they come, signature included, and fetches the full set from Sanity itself. No code on your side.

  1. Make the dataset in Indx, then the webhook, with your Sanity project, dataset and a GROQ query that selects and shapes the documents. In the console, choose Sanity under the dataset's Webhooks; it then lists the settings for step 2. Through the API:

    http
    POST /api/teams/{team}/datasets/{dataset}/webhooks
    {
      "name": "Sanity",
      "source": "sanity",
      "settings": {
        "projectId": "abc123",
        "dataset": "production",
        "query": "*[_type == \"product\"]{_id, title, price, \"category\": category->title}",
        "syncEveryHours": 24
      }
    }

    Indx fetches the set at once and again every syncEveryHours (1 to 168; null for never). A private Sanity dataset needs a token with read access, which is kept and never shown again: the webhook lists it under secretSettings. Drafts are left out.

  2. In Sanity, under API and Webhooks, add a webhook:

    SettingValue
    URL
    the webhook's url
    Trigger on
    Create, Update, Delete
    Filter
    the filter from your query, plus the types it follows with ->: _type in ["product", "category"]
    Projection
    the projection from your query: {_id, title, price, "category": category->title}
    HTTP method
    POST
    Secret
    the webhook's secret

Each call is read fresh: Indx asks Sanity for the document through your query, so what is indexed is what the query gives, whatever the call carried. When the changed document is one your query follows, such as a renamed category, Indx brings along the documents that refer to it, so the products show the new name at once; the category itself is not indexed. More than 500 documents referring to one change are brought by a full sync instead. If Sanity cannot be asked, the call is applied as it came, which is why the projection matches your query's.

POST …/webhooks/{id}/sync fetches the full set now. PUT …/webhooks/{id} changes the name, the brake and the settings (a token left out is kept); a changed query fetches the full set again.

Contentful

Indx understands Contentful's webhooks as they come and reads the published entries from Contentful's Content Delivery API itself. No code on your side.

  1. Make the dataset in Indx, then the webhook. In the console, choose Contentful under the dataset's Webhooks; it then lists the settings for step 2. Through the API:

    http
    POST /api/teams/{team}/datasets/{dataset}/webhooks
    {
      "name": "Contentful",
      "source": "contentful",
      "settings": {
        "spaceId": "a1b2c3d4e5f6",
        "contentType": "product",
        "deliveryToken": "…",
        "syncEveryHours": 24
      }
    }
    SettingWhat it does
    spaceId
    The space.
    deliveryToken
    A Content Delivery API token, from Settings and API keys in Contentful. Kept and never shown again.
    environment
    Optional. Default master.
    contentType
    Optional. One content type's entries; without it, every entry.
    locale
    Optional. The locale whose values are indexed. Default en-US.
    region
    Optional. eu for Contentful's EU data residency. Default us.
    syncEveryHours
    Optional. 1 to 168, null for never. Default 24.

    Indx reads the published entries at once and again every syncEveryHours.

  2. In Contentful, under Settings and Webhooks, add a webhook:

    SettingValue
    URL
    POST and the webhook's url
    Triggers
    Entry: Publish, Unpublish, Archive, Delete
    Filters
    Environment ID equals master, and Content type ID equals product when you chose one
    Headers
    a secret header X-Indx-Secret, with the webhook's secret
    Payload
    the default

An entry becomes a document with _id, _contentType and _updatedAt, then its fields in the chosen locale. A reference becomes the id of the entry it points to, and rich text its plain text. Publishing an entry adds or updates its document; unpublishing, archiving or deleting removes it. Changing an entry that others refer to does not update those others until the next full read.

If your space has request verification on and its signing secret is the webhook's secret, Indx also accepts Contentful's own signature in place of the header.