# The sync webhook > One signed endpoint per knowledge base. What it is, when to reach for it, and every field, header and error. Source: https://welcomeai.dev/docs/sync-webhook Category: Integrations Updated: 2026-09-06 --- ## What a webhook is A webhook is a URL you give to another system so it can call *you* when something happens. The alternative is polling -- asking "anything new?" every few minutes -- which is slower and mostly wasted. A webhook is one request, at the moment the thing actually changed. ## What this one does Every knowledge base can have one signed endpoint. Post a document to it and the document is added, updated or removed. That is the whole contract. It exists for content that lives somewhere we do not have a connector for: an internal CMS, a Notion database routed through Zapier, a Google Sheet in Make, a script that runs after your build. If you already use WordPress or Webflow, use those connectors instead -- they know what changed and handle deletions for you. ### When to use it, and when not | You want to | Use | |---|---| | Sync a WordPress or Webflow site | The connector for it | | Crawl a public website | The website crawler | | Push from your own backend, in your own code | The REST API with an API key | | Let Zapier, Make or n8n push, with no code | **This webhook** | The difference between this and the REST API is only how you prove who you are. The REST API takes an API key in a header, which is right when you control the code. This takes a signature, which is right when a no-code tool is making the request: the URL carries a public token, so the tool needs no credential store, and the signature proves the body was not tampered with. ## Turning it on Integrations, then Generic sync webhook. Pick a knowledge base and press Enable. You get two things: - **The endpoint**, `https://app.welcomeai.dev/api/v1/kb/{kbId}/sync?t=whsec_…`. The token in the query string identifies which source is being fed. It is not a secret on its own -- it names, it does not authorise. - **The signing secret**, shown once. Store it where your automation keeps credentials. Rotating the secret invalidates the previous one immediately. There is no overlap window, so change it in your automation first, or accept a few failed requests. ## Authentication Sign the **raw body** with HMAC-SHA256 and send the hex digest. ``` X-WelcomeAI-Timestamp: 1757155200 X-WelcomeAI-Signature: sha256="> ``` The signed string is the timestamp, a literal dot, and the body exactly as it goes over the wire. Sign the bytes you send -- re-serialising the JSON first changes the whitespace and the signature will not match. A timestamp older or newer than **five minutes** is refused, so a captured request cannot be replayed later. If your tool cannot add a header, omit the timestamp and sign the body alone; that still works and is what most Zapier setups do, but it gives up the replay protection. ## The request `POST`, `Content-Type: application/json`. One document, or a batch: ```json { "external_id": "faq-refunds", "title": "Refunds", "url": "https://acme.com/help/refunds", "content_md": "# Refunds\n\nWithin 14 days, email billing@acme.com.", "metadata": { "category": "billing" } } ``` ```json { "items": [ { "external_id": "faq-refunds", "title": "Refunds", "content_md": "…" }, { "external_id": "faq-shipping", "title": "Shipping", "content_md": "…" }, { "external_id": "faq-old", "action": "delete" } ] } ``` ### Fields | Field | Required | What it is | |---|---|---| | `external_id` | **yes** | Your identifier for this document, up to 500 characters. Posting the same one again updates it rather than making a second copy. This is the single most important field to get right. | | `content_md` or `content_html` | yes, unless deleting | The content. Markdown is preferred; HTML is converted. Up to 2 MB. | | `title` | no | What a citation shows. Worth setting -- "Untitled" in a citation makes a right answer look wrong. | | `url` | no | Where a reader can see the original. Becomes the citation's link. | | `excerpt` | no | A short summary, stored alongside. | | `metadata` | no | Any JSON object of your own. Kept with the document. | | `content_hash` | no | If you send one, an unchanged hash skips re-indexing entirely. Cheap and worth doing on a large sync. | | `action` | no | `upsert` (the default) or `delete`. | A batch is at most **25 items** and the whole body at most **4 MB**. ### Deleting Send `{"external_id": "…", "action": "delete"}`. The document, its chunks and its embeddings go. Deleting something that was never there is not an error -- it reports `missing` and moves on, so a replayed delete is safe. ## The response `200`, always with a per-item breakdown. A partial failure is a `200` with `ok: false`, not a `500` -- one bad item in a batch of twenty-five must not make you resend the other twenty-four. ```json { "ok": true, "accepted": 2, "failed": 0, "results": [ { "external_id": "faq-refunds", "status": "created" }, { "external_id": "faq-shipping", "status": "unchanged" } ] } ``` `status` is one of `created`, `updated`, `unchanged`, `deleted`, `missing` or `failed`. A `failed` item carries an `error` describing why. **`unchanged` is the one to watch.** It means the content hash matched what we already had, so nothing was re-indexed. On a nightly full sync most items should say `unchanged`; if they all say `updated`, something in your pipeline is rewriting the content each run -- a regenerated timestamp in the body, usually -- and you are paying to embed the same text again. ## Errors | Status | Code | What happened | |---|---|---| | 400 | `invalid_json` | The body is not JSON | | 400 | `invalid_input` | A field is missing or the wrong shape. The message names the field | | 401 | `invalid_signature` | The signature, the secret or the timestamp is wrong | | 404 | `not_found` | No webhook is enabled for that knowledge base, or the token does not match | | 409 | `source_paused` | The source is paused. Resume it in Connected sources | | 413 | `payload_too_large` | The body is over 4 MB | `401` is the one people hit. In order of likelihood: the body was re-serialised before signing; the timestamp was included in the header but not in the signed string, or the other way round; the secret was rotated; the clock is off by more than five minutes. ## Retries We do not retry -- you are the caller. Retry a `5xx` or a network failure with a backoff, and **do not** retry a `4xx`: nothing about the request will succeed on a second attempt. Retries are safe. `external_id` makes every write idempotent, so sending the same document twice produces one document, and the second call reports `unchanged`. ## Limits There is no per-endpoint rate limit today. Ingestion is bounded by your plan's document and storage allowances, and a batch is capped at 25 items and 4 MB. Be reasonable on a first import: a few requests a second, not a few hundred. ## Keeping it safe - **Treat the secret like a password.** It is the only thing standing between your knowledge base and anyone who has the URL, and the URL travels through whichever tool you pasted it into. - **Rotate it when someone leaves**, or when an automation is decommissioned. - **One knowledge base, one webhook.** If two systems feed the same knowledge base, give them separate knowledge bases or separate `external_id` prefixes, so you can tell later which one sent what. - **Do not send anything a visitor should not read.** Everything you post is content the assistant may quote to whoever asks. - Every request is recorded in the Sync log with what it found and what it changed, which is where to look when the content is not what you expected.