Guide to Meilisearch Webhooks: Features and Best Practices
Meilisearch webhooks tell you when indexing work has finished. Documents are added, settings change, an index is swapped or a task fails, and Meilisearch sends the finished tasks to your endpoint, so you can purge a cache, alert your team or signal a deployment pipeline without polling the tasks API.
This guide covers how Meilisearch task webhooks are configured on Cloud and self-hosted instances, how to read the compressed NDJSON body, how to authenticate deliveries with the Authorization header, and the practices that keep a Meilisearch receiver dependable.
What are Meilisearch webhooks?
Meilisearch processes writes as asynchronous tasks. A task webhook sends finished tasks to a URL: "When a task completes, Meilisearch sends an HTTP POST request to your configured URL." The body is NDJSON (newline-delimited JSON), with one task object per line in the same format the Tasks API returns. If Meilisearch processed several tasks as one batch, it waits until they've all finished and sends them together, one per line.
Two details trip up most first receivers. The body isn't a single JSON document, so parse it line by line. And although the webhook pages don't mention it, the engine sends the body gzip-compressed, with Content-Encoding: gzip and Content-Type: application/x-ndjson, so you have to decompress it before you can read the lines.
Meilisearch doesn't sign webhooks. You can set an Authorization Header, for example Bearer your-secret-token, which "is sent with every webhook request so your endpoint can verify the source". Your receiver compares the header with the value you set.
Meilisearch webhook features
| Feature | Details |
|---|---|
| Cloud configuration | Project Settings > Webhooks, up to 20 per project |
| Self-hosted configuration | --task-webhook-url and --task-webhook-authorization-header, or the /webhooks API route (up to 20 per instance) |
| When it fires | When tasks finish. Every webhook receives every finished task |
| Method | POST |
| Body | Gzip-compressed NDJSON, one task per line |
| Event identifier | Each line's type, with status |
| Authentication | Optional Authorization header, sent verbatim. No signature |
| Retries | Cloud: non-2xx responses are retried with exponential backoff |
| Source IPs | Not published |
Common events
There's no subscription to particular task types: every finished task is sent. Route on each line's type and status. Common types include:
type | What the task did |
|---|---|
documentAdditionOrUpdate | Added or updated documents |
documentDeletion, documentEdition | Deleted or edited documents |
settingsUpdate | Changed index settings |
indexCreation, indexUpdate, indexDeletion, indexSwap | Managed indexes |
dumpCreation, snapshotCreation, export | Created a dump, a snapshot or an export |
taskCancelation, taskDeletion | Cancelled or deleted other tasks |
status on a finished task is succeeded, failed or canceled. Here is Meilisearch's example of a batch of three tasks, after decompression:
{"uid":4,"batchUid":4,"indexUid":"movies","status":"succeeded","type":"documentAdditionOrUpdate","canceledBy":null,"details":{"receivedDocuments":1,"indexedDocuments":1},"duration":"PT0.001192S","enqueuedAt":"2022-08-04T12:28:15.159167Z","startedAt":"2022-08-04T12:28:15.161996Z","finishedAt":"2022-08-04T12:28:15.163188Z"}
{"uid":5,"batchUid":4,"indexUid":"movies","status":"succeeded","type":"documentAdditionOrUpdate","canceledBy":null,"details":{"receivedDocuments":1,"indexedDocuments":1},"duration":"PT0.001192S","enqueuedAt":"2022-08-04T12:28:15.159167Z","startedAt":"2022-08-04T12:28:15.161996Z","finishedAt":"2022-08-04T12:28:15.163188Z"}
{"uid":6,"batchUid":4,"indexUid":"movies","status":"succeeded","type":"documentAdditionOrUpdate","canceledBy":null,"details":{"receivedDocuments":1,"indexedDocuments":1},"duration":"PT0.001192S","enqueuedAt":"2022-08-04T12:28:15.159167Z","startedAt":"2022-08-04T12:28:15.161996Z","finishedAt":"2022-08-04T12:28:15.163188Z"}
uid identifies the task and batchUid the batch it ran in. details depends on the type, and failed tasks carry an error object.
Setting up Meilisearch webhooks
Generate a long random secret first, for example with openssl rand -hex 32, and decide on the full header value, such as Bearer <secret>.
Meilisearch Cloud. Go to Project Settings > Webhooks, click + Add webhook, enter the Webhook URL and the Authorization Header, and click Add webhook.
Self-hosted. Start the instance with the webhook options:
meilisearch \
--task-webhook-url https://example.com/webhooks/meilisearch \
--task-webhook-authorization-header "Bearer YOUR_LONG_RANDOM_SECRET"
You can also set MEILI_TASK_WEBHOOK_URL, or manage several webhooks with the /webhooks API route. To call a URL on a private network, such as localhost, the instance needs --experimental-allowed-ip-networks.
GET /webhooks returns Authorization values redacted. Keep the original secret and send it again whenever you update a webhook, or Meilisearch will start sending the redacted value.
In Hookdeck. Create a source with the Meilisearch type and use its URL as the webhook URL. In Authorization Header, paste the value exactly as set in Meilisearch, including any Bearer or Basic prefix. Hookdeck compares the whole header with it. The source accepts POST. Hookdeck stores gzip bodies as binary, so the dashboard shows a binary body, and it forwards the original bytes with the Content-Encoding header to your destination, which must decompress them and split the lines. If you didn't set an Authorization Header in Meilisearch, leave verification off on the source, though setting one is the better choice.
For local development, hookdeck listen 3000 meilisearch --path /webhooks/meilisearch with the Hookdeck CLI gives you a public HTTPS URL, with no account required.
Securing Meilisearch webhooks
Check the Authorization header first, then decompress, then parse each line.
const crypto = require('crypto');
const express = require('express');
const app = express();
function safeEqual(received, expected) {
const a = Buffer.from(received, 'utf8');
const b = Buffer.from(expected, 'utf8');
// timingSafeEqual throws on buffers of different lengths
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
function authenticate(req, res, next) {
const expected = process.env.MEILISEARCH_AUTHORIZATION; // e.g. "Bearer 1f7a..."
if (!expected) return res.sendStatus(500); // fail closed when unconfigured
const received = req.get('authorization');
if (typeof received !== 'string' || !safeEqual(received, expected)) {
return res.sendStatus(401);
}
next();
}
app.post(
'/webhooks/meilisearch',
authenticate, // check the header before reading the body
express.raw({ type: '*/*', limit: '10mb' }), // inflates Content-Encoding: gzip by default
(req, res) => {
let tasks;
try {
tasks = req.body
.toString('utf8')
.split('\n')
.filter((line) => line.trim())
.map((line) => JSON.parse(line)); // one task per line, never the whole body
} catch {
return res.sendStatus(400);
}
// Queue the tasks; route on task.type and task.status.
res.sendStatus(200);
}
);
express.raw() decompresses gzip bodies by default, so req.body is already plain NDJSON. If you read the raw bytes another way, decompress them yourself. In Python:
import gzip
import hmac
import json
import os
def verify_meilisearch_request(headers) -> bool:
expected = os.environ.get("MEILISEARCH_AUTHORIZATION")
if not expected:
return False # fail closed
received = headers.get("authorization")
if not received:
return False
return hmac.compare_digest(received.encode("utf-8"), expected.encode("utf-8"))
def parse_tasks(raw_body: bytes, content_encoding: str) -> list:
if "gzip" in (content_encoding or "").lower():
raw_body = gzip.decompress(raw_body)
return [json.loads(line) for line in raw_body.decode("utf-8").splitlines() if line.strip()]
Most frameworks don't decompress request bodies for you, so check whether yours does before adding gzip.decompress, and cap the decompressed size for large batches.
A matching header shows the sender knows the value. It doesn't prove the tasks are unchanged, and a captured request can be replayed, because nothing is signed. Use HTTPS and a long random value, and rotate it if it may have leaked.
Make Meilisearch webhooks production-ready. Hookdeck Event Gateway checks the Authorization header on every Meilisearch webhook, stores each batch durably, and retries delivery to your service on a schedule you control.
Meilisearch webhook limitations and pain points
Compressed NDJSON, not JSON
The Problem: JSON.parse fails on the body, or the body looks like random bytes.
Why It Happens: The body is NDJSON, one task per line, and the engine gzips it and sets Content-Encoding: gzip. The documentation describes the NDJSON but not the compression.
Workarounds:
- Decompress when
Content-Encodingisgzip, then split on newlines and parse each line. - Skip blank lines, and treat any line that doesn't parse as a bad request.
How Hookdeck Can Help: Hookdeck stores the gzip body as binary, so filters can't see task fields as received. A transformation can decompress it and turn the lines into a JSON array before delivery, as in our guide to decompressing gzip payloads, so your service receives plain JSON and filters can route on type and status.
Retries aren't guaranteed on every deployment
The Problem: A self-hosted receiver that was down for a few minutes never gets the tasks that finished in that time.
Why It Happens: The Cloud webhooks page says non-2xx responses "are treated as failures and retried with exponential backoff". The self-hosted guide doesn't mention retries, and the open-source engine's code sends each batch once, with a 30-second timeout, and logs a failure.
Workarounds:
- Treat webhooks as a fast signal, and reconcile with the tasks API for anything that must not be missed.
- Keep handlers quick, well under 30 seconds.
How Hookdeck Can Help: Hookdeck accepts each batch as soon as it arrives, so a short outage on your side doesn't lose it. Hookdeck then retries delivery to your service on your own schedule and lets you replay any batch.
Every task, every time
The Problem: A receiver that only cares about failed document additions has to read and discard everything else.
Why It Happens: There's no per-type subscription. Every finished task goes to every webhook.
Workarounds:
- Filter early in the handler and acknowledge the rest with
200. - Keep the number of active webhooks low. Meilisearch warns that many active webhooks may affect performance.
How Hookdeck Can Help: Once a transformation has decompressed the body, Hookdeck filters can drop the task types a destination doesn't need, and one source can feed several destinations.
A redacted secret can overwrite the real one
The Problem: After a PATCH, your receiver starts rejecting every request.
Why It Happens: GET /webhooks returns Authorization values redacted. Sending that value back in an update replaces your secret with the redacted string.
Workarounds:
- Keep the original secret in your secrets manager and send it on every update.
- Never build a
PATCHbody from aGETresponse.
How Hookdeck Can Help: Hookdeck's Meilisearch source compares the Authorization header with the exact value you enter. Hookdeck answers Meilisearch with a 200 either way, then records a request without the right header as failed verification, so a mismatch shows up in the request log instead of disappearing.
Best practices
Store the whole header value
Compare against the exact value you set, prefix included, as Meilisearch's own example does.
Deduplicate on the task uid
Retries can resend a batch. Each task's uid is unique, so store it and skip repeats. See our guide to webhook idempotency.
Branch on status before type
A failed task carries an error and usually needs an alert. A succeeded task drives the follow-up work, such as a cache purge. canceled usually needs neither.
Acknowledge fast and process asynchronously
Verify, decompress, store the tasks, respond with 200, then act. See why to process webhooks asynchronously.
Conclusion
Meilisearch sends finished tasks as gzip-compressed NDJSON, one task per line, with your Authorization header as the only credential. Compare the header in constant time over HTTPS, decompress before parsing, parse line by line, route on status and type, and don't assume a self-hosted engine will retry.
Hookdeck Event Gateway sits in front of your receiver, checks the header, keeps every batch, and retries delivery to your service, and a transformation can turn the compressed lines into plain JSON on the way.
Get started with Hookdeck for free and handle Meilisearch webhooks reliably in minutes.