You cannot migrate from Zendesk to LayBuild in the usual sense, and we would rather say that up front. LayBuild has no Zendesk integration, no importer for tickets or macros, and no ticketing system to import them into. It is an AI agent that answers from documents on a web widget, WhatsApp and a REST API. What you can do is move your Help Center content into LayBuild, put the LayBuild widget on your website, keep Zendesk for email and tickets, and turn LayBuild handoffs into Zendesk tickets with a small script. This post walks through that setup, with code.
What moves and what stays
Zendesk stays the system of record for tickets, email, SLAs, macros, phone and anything else you run there. LayBuild does not replace any of those.
Your Help Center articles move, as a copy. LayBuild has no sync, so the copy goes stale when you edit articles in Zendesk and you re-run the export when it matters.
Front-line questions on your website (or WhatsApp) move to LayBuild. When the bot cannot help, or a customer asks for a person, the conversation hands off. Your agents can reply in the LayBuild dashboard, or your script opens a Zendesk ticket that points back to the conversation.
Customer on website / WhatsApp
|
v
LayBuild agent <---- knowledge: exported Help Center articles (files or URLs)
|
| CONVERSATION_HANDOFF webhook (HMAC-signed)
v
Your receiver script ---- POST /api/v2/tickets ----> ZendeskStep one: get the articles out of Zendesk
Zendesk's Help Center Articles API (checked September 2026) lists articles at GET /api/v2/help_center/{locale}/articles, with cursor pagination through page[size], links.next and meta.has_more. Each article includes id, title, body (HTML), html_url, draft, section_id and locale, among other fields. The response only contains articles the requesting user can view. For API tokens, Zendesk's authentication docs use basic auth with the username {email_address}/token:{api_token}.
Before writing the export, check LayBuild's limits, because they shape the output. Plans allow 10, 25 or 50 uploaded documents (Starter, Pro, Premium) and 5, 25 or 50 URLs. Files are capped at 10 MB by default. A Help Center with 200 articles cannot be 200 files on any plan, so the script below writes one Markdown file per Help Center section. Our chunker splits Markdown on headers first, then paragraphs, then sentences, so giving each article its own ## heading keeps articles from blending into each other in retrieval.
This script runs with Bun and uses the turndown package to convert article HTML to Markdown (bun add turndown and bun add -d @types/turndown).
import { mkdir, writeFile } from "node:fs/promises";
import TurndownService from "turndown";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}`);
return value;
}
const subdomain = requireEnv("ZENDESK_SUBDOMAIN");
const email = requireEnv("ZENDESK_EMAIL");
const apiToken = requireEnv("ZENDESK_API_TOKEN");
const locale = process.env.ZENDESK_LOCALE ?? "en-us";
interface Article {
id: number;
title: string;
body: string | null;
html_url: string;
draft: boolean;
section_id: number;
}
interface ArticlesPage {
articles: Article[];
meta: { has_more: boolean };
links: { next: string | null };
}
const authHeader = `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString("base64")}`;
async function fetchAllArticles(): Promise<Article[]> {
const articles: Article[] = [];
let url: string | null =
`https://${subdomain}.zendesk.com/api/v2/help_center/${locale}/articles?page[size]=100`;
while (url) {
const res = await fetch(url, { headers: { Authorization: authHeader } });
if (!res.ok) {
throw new Error(`Zendesk returned ${res.status} for ${url}`);
}
const page = (await res.json()) as ArticlesPage;
articles.push(...page.articles);
url = page.meta.has_more ? page.links.next : null;
}
return articles;
}
const turndown = new TurndownService({ headingStyle: "atx" });
function toMarkdown(article: Article): string {
// Push headings inside the article below ##, so each article's own title stays the top split point.
const body = turndown.turndown(article.body ?? "").replace(/^(#{1,4}) /gm, "##$1 ");
// Keep the public URL in the text so answers can point customers to the full article.
return `## ${article.title}\n\nSource: ${article.html_url}\n\n${body}\n`;
}
const published = (await fetchAllArticles()).filter((a) => !a.draft);
const bySection = new Map<number, Article[]>();
for (const article of published) {
bySection.set(article.section_id, [...(bySection.get(article.section_id) ?? []), article]);
}
await mkdir("export", { recursive: true });
for (const [sectionId, articles] of bySection) {
const file = `export/zendesk-section-${sectionId}.md`;
await writeFile(file, articles.map(toMarkdown).join("\n"));
console.log(`${file}: ${articles.length} articles`);
}If you have more sections than your plan's document limit, merge small sections into one file. If you hit Zendesk's API rate limits on a large Help Center, add a pause and retry around the fetch. Section IDs make poor file names for humans, so rename the files before uploading if that helps your team find them.
Step two: load the content into LayBuild
Upload the Markdown files as knowledge documents in the dashboard. LayBuild also accepts PDF, DOCX, PPTX, XLSX, CSV, TXT and a few other formats if your team keeps some answers outside the Help Center.
The alternative is listing article URLs. LayBuild fetches each URL you list, converts the HTML to Markdown and keeps up to 50,000 characters per page. It does not crawl links or read sitemaps, and URL limits (5, 25 or 50) are no looser than document limits, so URLs suit a handful of long, important pages better than a whole Help Center. Neither route re-syncs on a schedule.
Two content details are worth planning for before you upload. First, the default embedding model (BAAI/bge-small-en-v1.5) and full-text search are English. If your Help Center has other locales, the bot will still reply in the customer's language, but retrieval against English articles is weaker for non-English questions. Second, pin the few documents that must always be in context, like refund policy or supported regions. Pinned documents are injected into every prompt, capped at 4,000 characters each and 12,000 in total.
Then test with real questions from recent Zendesk tickets, including some your articles do not answer. LayBuild's strict knowledge-base mode should reply with its fixed "I do not have specific information about that in the knowledge base" message for those. That fallback triggers when retrieval finds nothing, or when fewer than 25% of the answer's stemmed words appear in the sources. It is a lexical check, so read a sample of answers yourself: it can block a correct paraphrase and can let through a wrong answer that reuses the source's words.
Step three: turn handoffs into Zendesk tickets
Handoff on request is off by default; turn on the ALLOW_HUMAN_HANDOFF org setting. A conversation also hands off after 25 AI turns. On handoff, LayBuild marks the conversation HANDOFF, alerts agents in the dashboard and sends a CONVERSATION_HANDOFF webhook to any endpoint you register for that event.
The webhook body is an envelope with event, id, timestamp and data. For a handoff, data contains the conversation id, status, reason, requestedBy, assignedAgent, userId and createdAt. It does not include the transcript or the customer's email, so the ticket below links back to the conversation instead of copying it. The same event also fires with reason: "agent_claim" when an agent claims a conversation, and with reason: "test_ping" from the dashboard's test button, so the receiver skips those to avoid duplicate tickets.
If you set a secret on the webhook, LayBuild signs each request: HMAC-SHA256 with your secret over timestamp + "." + rawBody, hex-encoded and sent as sha256=<hex> in the X-Hub-Signature-256 header, with the timestamp in X-Webhook-Timestamp. Zendesk's Tickets API creates tickets with POST /api/v2/tickets; the first comment's body becomes the description, and if you omit the requester it defaults to the authenticated user.
import { createHmac, timingSafeEqual } from "node:crypto";
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`Set ${name}`);
return value;
}
const webhookSecret = requireEnv("LAYBUILD_WEBHOOK_SECRET");
// The base URL you open the LayBuild dashboard on, so the ticket links straight to the conversation.
const dashboardUrl = requireEnv("LAYBUILD_DASHBOARD_URL");
const subdomain = requireEnv("ZENDESK_SUBDOMAIN");
const email = requireEnv("ZENDESK_EMAIL");
const apiToken = requireEnv("ZENDESK_API_TOKEN");
const MAX_AGE_MS = 5 * 60 * 1000;
const SKIPPED_REASONS = new Set(["agent_claim", "test_ping"]);
interface HandoffEnvelope {
event: string;
id: string;
timestamp: string;
data: { id: string; reason?: string };
}
function isValidSignature(rawBody: string, timestamp: string, header: string): boolean {
const expected = `sha256=${createHmac("sha256", webhookSecret).update(`${timestamp}.${rawBody}`).digest("hex")}`;
const a = Buffer.from(expected);
const b = Buffer.from(header);
return a.length === b.length && timingSafeEqual(a, b);
}
async function createZendeskTicket(conversationId: string, reason: string): Promise<void> {
const auth = Buffer.from(`${email}/token:${apiToken}`).toString("base64");
const res = await fetch(`https://${subdomain}.zendesk.com/api/v2/tickets`, {
method: "POST",
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/json" },
body: JSON.stringify({
ticket: {
subject: `LayBuild handoff: conversation ${conversationId}`,
comment: {
body: `A customer conversation was handed off by the LayBuild agent (reason: ${reason}).\n\nOpen it: ${dashboardUrl}/admin/conversations/${conversationId}`,
},
},
}),
});
if (!res.ok) {
throw new Error(`Zendesk ticket creation failed with ${res.status}`);
}
}
Bun.serve({
port: 8787,
async fetch(req) {
if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });
const rawBody = await req.text();
const timestamp = req.headers.get("x-webhook-timestamp") ?? "";
const signature = req.headers.get("x-hub-signature-256") ?? "";
if (!isValidSignature(rawBody, timestamp, signature)) {
return new Response("Bad signature", { status: 401 });
}
// Reject old deliveries so a captured request cannot be replayed later.
if (Math.abs(Date.now() - Date.parse(timestamp)) > MAX_AGE_MS) {
return new Response("Stale delivery", { status: 401 });
}
const envelope = JSON.parse(rawBody) as HandoffEnvelope;
const reason = envelope.data.reason ?? "unknown";
if (envelope.event !== "CONVERSATION_HANDOFF" || SKIPPED_REASONS.has(reason)) {
return new Response("Ignored", { status: 200 });
}
try {
await createZendeskTicket(envelope.data.id, reason);
return new Response("Created", { status: 201 });
} catch (err) {
console.error(`Handoff ${envelope.id} for conversation ${envelope.data.id} failed`, err);
return new Response("Upstream error", { status: 502 });
}
},
});Three operational notes. LayBuild does not retry failed webhook deliveries and has no dead-letter queue; it logs each delivery with its status code, so check the webhook logs after an outage and replay what failed by hand. The delivery times out after 5 seconds, so keep the receiver fast. And the envelope id is unique per delivery, which you can store if you want to make ticket creation idempotent.
What you give up, and what you get
You give up a single system. Agents now look in two places: the LayBuild dashboard for live website and WhatsApp conversations, and Zendesk for tickets. There is no AI summary on handoff and no sentiment detection, so whoever picks up the ticket reads the transcript. Learned answers are published into the knowledge base automatically after qualifying replies, without human approval, and retracted only when a customer rates the conversation 2 stars or lower; review them alongside your Help Center.
You get a website and WhatsApp bot with a fixed monthly price (₹1,499, ₹3,999 or ₹9,999, with 1,500, 8,000 or 20,000 conversations), a model you choose, and answers restricted to your exported content. Whether that is worth running next to Zendesk depends on how much of your volume is repetitive questions your Help Center already answers.
Next steps
Compare the two products in LayBuild vs Zendesk AI. For the webhook side, custom webhooks for support automation covers signing and delivery in more depth, and preparing a knowledge base for AI helps with cleaning up exported articles. Plan limits are on pricing.
