Skip to content
Integrations~5 minutes

Custom webhook

The custom webhook integration lets you push content from any external system — a headless CMS, internal wiki, database, or custom pipeline — directly into your workspace knowledge base via a signed HTTP request.

Each push can upsert or delete a single document identified by an external_id you control. LuluDesk chunks and embeds the content automatically; your assistant answers questions from it within seconds.


1

Connect the webhook in your dashboard

Open the Integrations tab in your workspace dashboard, find the Custom webhook card, and click Connect.

LuluDesk generates a whk_… secret and shows you:

  • Endpoint URL https://luluclaw.com/api/integrations/kb-webhook
  • Secret — copy it now; it is shown exactly once.

Store both values securely (e.g. as environment variables in your deployment). Clicking Connect again rotates the secret — all previous signatures immediately become invalid.

2

Sign your requests

Every request must include three headers:

  • X-LuluDesk-Workspace — your workspace UUID (visible in the dashboard URL).
  • X-LuluDesk-Timestamp — current Unix time in seconds (Math.floor(Date.now() / 1000)). Requests older than 5 minutes are rejected to prevent replay attacks.
  • X-LuluDesk-Signature — HMAC-SHA256 computed as:
    text
    sha256=hex(hmac_sha256(secret, timestamp + "." + rawBody))
    where rawBody is the exact UTF-8 bytes of the JSON body, and the prefix sha256= is a literal string.
3

Push a document

Send a POST to https://luluclaw.com/api/integrations/kb-webhook with Content-Type: application/json.

Payload fields

FieldTypeDescription
action"upsert" | "delete"Required. Index or remove the document.
external_idstringRequired. Your stable document identifier. Allowed characters: [A-Za-z0-9._:/-], 1–200 chars. Used as the chunk key — upsert and delete must use the same value.
titlestring?Optional. Prepended to content before chunking. Max 300 chars.
urlstring?Optional https URL. Informational only — not used for chunk keying.
contentstringRequired for upsert. Plain text content to index. Max 50,000 chars.

The response is { ok: true, chunks: n } where n is the number of chunks inserted (upsert) or deleted (delete).

4

Curl example

bash
# Upsert a document
curl -X POST https://luluclaw.com/api/integrations/kb-webhook \
  -H "Content-Type: application/json" \
  -H "X-LuluDesk-Workspace: <your-workspace-id>" \
  -H "X-LuluDesk-Timestamp: $(date +%s)" \
  -H "X-LuluDesk-Signature: sha256=$(
    BODY='{"action":"upsert","external_id":"blog/post-1","title":"Hello World","content":"Full post content here."}'
    TS=$(date +%s)
    echo -n "$TS.$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}'
  )" \
  -d '{"action":"upsert","external_id":"blog/post-1","title":"Hello World","content":"Full post content here."}'
5

Node.js example

A self-contained TypeScript helper that computes the signature and calls the endpoint:

typescript
import crypto from "node:crypto";

const WEBHOOK_SECRET = process.env.LULUDESK_WEBHOOK_SECRET!;
const WORKSPACE_ID   = process.env.LULUDESK_WORKSPACE_ID!;
const ENDPOINT       = "https://luluclaw.com/api/integrations/kb-webhook";

async function pushDocument(doc: {
  action: "upsert" | "delete";
  external_id: string;
  title?: string;
  content?: string;
}) {
  const body      = JSON.stringify(doc);
  const timestamp = String(Math.floor(Date.now() / 1000));
  const payload   = `${timestamp}.${body}`;
  const signature = "sha256=" + crypto
    .createHmac("sha256", WEBHOOK_SECRET)
    .update(payload)
    .digest("hex");

  const res = await fetch(ENDPOINT, {
    method:  "POST",
    headers: {
      "Content-Type":          "application/json",
      "X-LuluDesk-Workspace":  WORKSPACE_ID,
      "X-LuluDesk-Timestamp":  timestamp,
      "X-LuluDesk-Signature":  signature,
    },
    body,
  });

  if (!res.ok) throw new Error(`Webhook failed: ${res.status}`);
  return res.json(); // { ok: true, chunks: n }
}

// Upsert
await pushDocument({
  action:      "upsert",
  external_id: "blog/post-1",
  title:       "Hello World",
  content:     "Full post content here.",
});

// Delete
await pushDocument({ action: "delete", external_id: "blog/post-1" });

Upsert semantics

An upsert replaces all existing chunks for the given external_id, then inserts the new chunks. There is no partial update — always send the full current content of the document on every change.

Delete semantics

A delete removes all chunks for the given external_id. If no chunks exist the call succeeds with chunks: 0.

Rate limits

The endpoint accepts up to 60 requests per minute per workspace. Exceeding this returns HTTP 429. Batch pushes should be spread across time or use an exponential back-off on 429 responses.

Rotating the secret

Click Rotate secret in the dashboard to generate a new secret. The old secret is immediately invalidated — update your environment variables before rotating in production.

Disconnecting

Click Disconnect in the dashboard to remove the webhook source and all its indexed chunks from your knowledge base. Subsequent requests are rejected with 401, the same response as an invalid signature.

Need help? See the FAQ or reach out via the chat widget on any LuluDesk page.