The sync webhook
One signed endpoint per knowledge base. What it is, when to reach for it, and every field, header and error.
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
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=<hex digest of "1757155200.<raw body>">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:
{
"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" }
}{
"items": [
{ "external_id": "faq-refunds", "title": "Refunds", "content_md": "…" },
{ "external_id": "faq-shipping", "title": "Shipping", "content_md": "…" },
{ "external_id": "faq-old", "action": "delete" }
]
}Fields
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.
{
"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
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_idprefixes, 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.