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.
POST /api/teams/{team}/datasets/{dataset}/webhooks
{ "name": "Product feed", "idField": "id", "versionField": "updatedAt" }| Field | What 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:
[
{ "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:
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:
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.
-
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:
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;nullfor never). A private Sanity dataset needs atokenwith read access, which is kept and never shown again: the webhook lists it undersecretSettings. Drafts are left out. -
In Sanity, under API and Webhooks, add a webhook:
Setting Value URLthe webhook'surlTrigger onCreate, Update, DeleteFilterthe filter from your query, plus the types it follows with->:_type in ["product", "category"]Projectionthe projection from your query:{_id, title, price, "category": category->title}HTTP methodPOSTSecretthe webhook'ssecret
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.
-
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:
POST /api/teams/{team}/datasets/{dataset}/webhooks { "name": "Contentful", "source": "contentful", "settings": { "spaceId": "a1b2c3d4e5f6", "contentType": "product", "deliveryToken": "…", "syncEveryHours": 24 } }Setting What it does spaceIdThe space.deliveryTokenA Content Delivery API token, from Settings and API keys in Contentful. Kept and never shown again.environmentOptional. Defaultmaster.contentTypeOptional. One content type's entries; without it, every entry.localeOptional. The locale whose values are indexed. Defaulten-US.regionOptional.eufor Contentful's EU data residency. Defaultus.syncEveryHoursOptional. 1 to 168,nullfor never. Default 24.Indx reads the published entries at once and again every
syncEveryHours. -
In Contentful, under Settings and Webhooks, add a webhook:
Setting Value URLPOSTand the webhook'surlTriggersEntry: Publish, Unpublish, Archive, DeleteFiltersEnvironment ID equalsmaster, and Content type ID equalsproductwhen you chose oneHeadersa secret headerX-Indx-Secret, with the webhook'ssecretPayloadthe 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.