Webhook (HTTP push)
A webhook task has no database to read: your application POSTs its changes to /api/v1/ingest/{task id}, and the request returns once the documents are confirmed in Meilisearch.
Create the task
- Create a connector of type Webhook. It has no connection settings.
- Create a task on it. The table is only a name for the feed (it appears in logs). Set a primary key column in the mapping step if your rows do not have an
idfield. - Optionally add a signing secret (see signed requests). Generate creates a random
whsec_…value; copy it then, because it is never shown again. - Start the task. Its Overview tab shows a Push endpoint card with the URL and, for signed tasks, a ready-to-run signed
curlexample.
The task id is in the URL, so give the task a readable id (for example products-push) when you create it.
Payload
The body is a single change, a JSON array of changes, or newline-delimited JSON (NDJSON), up to 64 MiB:
{"op": "upsert", "row": {"id": 1, "name": "Desk lamp", "price": 39.9}}
[
{"op": "upsert", "row": {"id": 1, "name": "Desk lamp"}},
{"op": "delete", "row": {"id": 2}},
{"row": {"id": 3, "name": "Chair"}}
]
{"op":"upsert","row":{"id":4,"name":"Shelf"}}
{"op":"delete","row":{"id":5}}
| Field | Meaning |
|---|---|
op | upsert (default; insert, update and replace are aliases), delete or truncate. |
row | The row as a JSON object. Required and non-empty for upserts and deletes; a delete needs at least the key columns. Integral numbers become integers, others floats. |
Changes in one request are applied in order, and requests to one task are applied in the order they arrive. A truncate holds the task unless allowTruncate is on.
Responses
| Status | Meaning |
|---|---|
200 {"accepted":n,"position":"…"} | Every change is durably in Meilisearch. |
400 | Invalid payload (the error says why), or the task is not a webhook task. |
401 | Not authenticated, or the signature is missing, wrong, or stale. |
403 | Authenticated, but the role is below moderator. |
503 | the task is not running; start it and retry. |
504 | changes were not confirmed in time; retry (writes are idempotent) after 60 seconds. |
Retry on 503, 504 and network errors: the same change applied twice gives the same document.
Authentication
How a push authenticates depends on whether the task has a signing secret (source.webhookSecret):
| Task | What the request needs |
|---|---|
| No secret | A bearer token or session with at least the moderator role. The static CDC_ADMIN_TOKEN works: Authorization: Bearer …. |
| Secret set | A valid signature. No token is needed, and a token alone is refused with 401. |
Anonymous callers get 401 both for unknown task ids and for tasks without a secret, so task ids cannot be probed.
# Task without a secret: a token with the moderator role or higher
curl -X POST "https://cdc.example.com/api/v1/ingest/products-push" \
-H "Authorization: Bearer $CDC_ADMIN_TOKEN" -H 'Content-Type: application/json' \
-d '{"op":"upsert","row":{"id":1,"name":"Desk lamp"}}'
Signed requests
With a signing secret (at least 16 characters), each request carries two headers:
X-CDC-Timestamp: 1767225600
X-CDC-Signature: sha256=<hex HMAC-SHA256(secret, timestamp + "." + raw body)>
The timestamp is in Unix seconds and must be within 5 minutes of the server clock. The HMAC covers the timestamp, a dot, and the exact bytes of the body, so sign what you send. A request is refused with 401 when it is unsigned (the request is not signed: send the X-CDC-Timestamp and X-CDC-Signature headers), signed with another secret or over a changed body (the signature does not match …), or too old or too far in the future (the signature timestamp is outside the accepted 5 minute window; check the sender's clock).
With curl and openssl
CDC=https://cdc.example.com
TASK=products-push
SECRET='whsec_…' # the task's signing secret
BODY='{"op":"upsert","row":{"id":1,"name":"Desk lamp"}}'
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')
curl -X POST "$CDC/api/v1/ingest/$TASK" -H 'Content-Type: application/json' \
-H "X-CDC-Timestamp: $TS" -H "X-CDC-Signature: sha256=$SIG" -d "$BODY"
From Node.js
import { createHmac } from 'node:crypto';
async function push(changes) {
const body = JSON.stringify(changes);
const ts = Math.floor(Date.now() / 1000).toString();
const sig = createHmac('sha256', process.env.CDC_WEBHOOK_SECRET).update(`${ts}.${body}`).digest('hex');
const res = await fetch('https://cdc.example.com/api/v1/ingest/products-push', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CDC-Timestamp': ts, 'X-CDC-Signature': `sha256=${sig}` },
body,
});
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json(); // {accepted, position}
}
Rotating the secret
The secret is encrypted at rest and returned masked (••••••). In the task's Edit page, Rotate generates a new one and Remove clears it (the API takes "" to remove it; sending the mask back keeps the stored one). The X-CDC-Signature header may hold several comma-separated signatures, and any one that matches is accepted, so a sender can sign with both the old and the new secret while you switch.
Positions and limits
Positions are a per-task sequence number (0, 1, 2…), one per pushed change. Pushes are queued in memory (64 requests wait at most; further pushers block) and are not kept across restarts: a push that was not confirmed when the service stopped fails, and the sender must retry. The schema is a single id column (the primary key unless the mapping names another).
Do not resync a webhook task
The webhook source has nothing to copy: its snapshot is empty. A Resync (or a Reset checkpoint while zero-downtime resync is on) rebuilds the index from nothing: an in-place resync clears it, and a zero-downtime rebuild swaps in an empty shadow index. Your application then has to push every document again. Use the stream sync mode for webhook tasks: it refuses resyncs (409, resync needs a snapshot). Never use snapshot mode.