Workflow: How to normalize Zendesk ticket data for AI models

<p>Raw Zendesk Support ticket data can be difficult to digest for artificial intelligence (AI) models. Tickets often include hypertext markup language (HTML), signatures, quoted email threads, inconsistent fields, and untrusted user input.</p><p>Common use cases for Zendesk Support ticket data with AI models include:</p><ul>
<li data-list-item-id="efe9cc02c889ae77bdc6a5985ce61c83a">Triage and route tickets with AI</li>
<li data-list-item-id="ece2be9ab7a79b82dda447440e2ebf03b">Summaries for agents</li>
<li data-list-item-id="e5ce58fee7f7423e3647ee19ff407c147">Detect sentiment and topics</li>
<li data-list-item-id="e7c2edea9fd719a59dc8a67d146a8c833">Quality assurance (QA) scoring systems</li>
<li data-list-item-id="ec63f745fb676b44c41708438663c9636">Conversational search</li>
<li data-list-item-id="e24c6549f2a0ffc4825565ce656ec3282">Use with schema-aware generative pretrained transformer (GPT) assistants</li>
</ul><p>This workflow explains how to normalize, clean, standardize, and structure Zendesk Support ticket data so you can use it reliably with AI models.</p><p>The workflow includes these steps:</p><div class="note">
<span class="notetitle"><strong>Disclaimer</strong>:</span> This article is provided for instructional purposes only. Zendesk does not support or guarantee the code. Post any issues you have in the comments section or try searching for a solution online.</div><div style="margin-bottom: 20px; text-align: right;">
<button id="master-toggle" class="btn fw-semibold text-dark" data-action="expand" style="background-color: #DEF991; border: none; outline: none; border-radius: 7px; padding: 10px 20px; font-size: 15px; transition: background-color 0.2s ease;" onmouseover="this.style.backgroundColor='#cbef6d'" onmouseout="this.style.backgroundColor='#DEF991'">
Expand all tabs
</button>
</div>
<script>
document.addEventListener('DOMContentLoaded', function () {
const toggleBtn = document.getElementById('master-toggle');

toggleBtn.addEventListener('click', function () {
const action = this.getAttribute('data-action');
const headers = document.querySelectorAll('.accordion');

headers.forEach(header => {
const isActive = header.classList.contains('active');
const panel = header.nextElementSibling; // Grabs the corresponding .panel container

if (action === 'expand' && !isActive) {
// Open it by simulating a standard click
header.click();
} else if (action === 'collapse' && isActive) {
// Close it by simulating a standard click
header.click();
}
});

// Flip button text and states
if (action === 'expand') {
this.setAttribute('data-action', 'collapse');
this.textContent = 'Collapse all tabs';
} else {
this.setAttribute('data-action', 'expand');
this.textContent = 'Expand all tabs';
}
});
});
</script><div class="accordion"><h3 id="h_01KG7Y460S96BJYMV2B14TTFP3">Step 1: Extract ticket data</h3></div><div class="panel">
<p>Retrieve ticket data from the <a href="https://developer.zendesk.com/api-reference/ticketing/introduction/" target="_blank" rel="noopener noreferrer">Zendesk Support application programming interface (API)</a>.</p>
<p>Create a <strong>.env</strong> file.</p>
<p>In your project directory, create a file named <strong>.env</strong> and add your Zendesk account details:</p>
<pre class="language-auto">ZENDESK_SUBDOMAIN=yoursubdomain
ZENDESK_EMAIL=you@company.com
ZENDESK_API_TOKEN=your_api_token</pre>
<p>To add the ticket fetch function to the app.js file:</p>
<p>This code builds the Zendesk Support API base URL from your subdomain, creates a Hypertext Transfer Protocol (HTTP) Basic Authorization header with your API token, and defines a <code>fetchTicket()</code> function that retrieves a ticket and includes comment events:</p>
<pre class="language-auto">const ZENDESK_BASE_URL = `https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2`;

const authHeader = Buffer.from(
`${process.env.ZENDESK_EMAIL}/token:${process.env.ZENDESK_API_TOKEN}`
).toString("base64");

async function fetchTicket(ticketId) {
const response = await fetch(
`${ZENDESK_BASE_URL}/tickets/${ticketId}.json?include=comment_events`,
{
method: "GET",
headers: {
"Authorization": `Basic ${authHeader}`,
"Content-Type": "application/json"
}
}
);

if (!response.ok) {
throw new Error(`Zendesk API error: ${response.status}`);
}

return response.json();
}</pre>
<p>Next, extract data from the response. Extract these minimum elements:</p>
<ul>
<li data-list-item-id="eb1ae741c2629c457fd681f9e7f41abe4">Ticket metadata, including ID, subject, priority, and group</li>
<li data-list-item-id="effa3927530d9fff169fa97af3f97cfc5">Ticket description</li>
<li data-list-item-id="e5e7ee64a0623b544c9a7e713029f5dd3">Public and internal comments</li>
<li data-list-item-id="eedba6adac6f92aa8fae8c0cbf0c32646">Tags</li>
<li data-list-item-id="e1ff04ec0d6408274abc510de3e47851b">Custom fields</li>
</ul>
<p>Ensure comments include the html_body or a plain-text fallback, such as body or plain_body.</p>
<div class="p"><div class="note">
<span class="title">Note:</span> For bulk ticket data exports, use the <a href="https://developer.zendesk.com/api-reference/ticketing/ticket-management/incremental_exports/" target="_blank" rel="noopener noreferrer">Incremental Exports API</a>. This tutorial uses the <a href="https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/" target="_blank" rel="noopener noreferrer">Ticketing API</a>.</div></div>
</div><div class="accordion"><h3 id="h_01KG7ZZHW92DR9SWKACQXK9FN7">Step 2: Clean HTML</h3></div><div class="panel">
<p>Zendesk Support ticket data often contains HTML markup such as <code>&lt;p&gt;</code>, <code>&lt;br&gt;</code>, and links. Most large language model (LLM) workflows work best with plain text, so in this step you convert HTML to readable text to remove tags and standardize formatting.</p>
<pre class="language-auto">function htmlToText(html) {
if (!html) return "";

const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");

// Remove scripts and styles
doc.querySelectorAll("script, style").forEach(el = el.remove());

// Convert &lt;br&gt; to newlines
doc.querySelectorAll("br").forEach(br = br.replaceWith("\n"));

return normalizeWhitespace(doc.body.textContent || "");
}

function normalizeWhitespace(text) {
return text
.replace(/\r\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}</pre>
</div><div class="accordion"><h3 id="h_01KH70VQ4J60PYG9N2FXW3E1HP">Step 3: Remove signatures and disclaimers</h3></div><div class="panel">
<p>When you process email ticket data, you often see signatures and disclaimers that add bulk. If you don’t need them, remove them.</p>
<pre><code class="language-auto">function stripSignatures(text) {
const lines = text.split("\n");

const signatureMarkers = [
/^--$/,
/^sent from my/i,
/^best regards/i,
/^thanks[,!]?$/
];

for (let i = 0; i <lines.length if="if"> re.test(lines[i].trim()))) {
return normalizeWhitespace(lines.slice(0, i).join("\n"));
}
}

return normalizeWhitespace(text);
}</lines.length></code></pre>
</div><div class="accordion"><h3 id="h_01KH723VDZHSKQRSYCY3TJB9P4">Step 4: Mask sensitive information</h3></div><div class="panel">
<p>Some tickets include sensitive information. Before you send data to an AI model, remove or mask sensitive data.</p>
<pre class="language-auto">function maskPII(text) {
return text
// Email addresses
.replace(
/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi,
"[REDACTED_EMAIL]"
)
// Phone numbers
.replace(
/\b(\+?\d{1,3}[\s-]?)?\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{4}\b/g,
"[REDACTED_PHONE]"
)
// Token-like strings
.replace(/\b[a-f0-9]{32,}\b/gi, "[REDACTED_TOKEN]");
}</pre>
</div><div class="accordion"><h3 id="h_01KH724DM91TPWQRHKZH2ZQAHJ">Step 5: Normalize ticket fields</h3></div><div class="panel">
<p>If you use custom ticket fields, define them. This ensures readability and prevents extra API requests each time you process ticket data for your AI model.</p>
<pre class="language-auto">const TICKET_SCHEMA = {
customTicketField1: null,
customTicketField2: null,
customTicketField3: null,
customTicketField4: []
};</pre>
<p>Map custom ticket field IDs to the schema:</p>
<pre class="language-auto">const CUSTOM_FIELD_MAP = {
111: "customerTier",
222: "platformsAffected"
};

function normalizeFields(ticket) {
const fields = { ...TICKET_SCHEMA };

for (const field of ticket.custom_fields || []) {
const key = CUSTOM_FIELD_MAP[field.id];
if (!key) continue;

fields[key] = normalizeValue(field.value);
}

return fields;
}

function normalizeValue(value) {
if (Array.isArray(value)) {
return value.map(v = v.toLowerCase()).sort();
}

if (typeof value === "string") {
const v = value.toLowerCase();
return v;
}

return value;
}</pre>
</div><div class="accordion"><h3 id="h_01KH726SB294C9B958RFK860J0">Step 6: Choose primary text for AI inputs</h3></div><div class="panel">
<p>Define which parts of the ticket to extract. This code sample focuses on ticket comments.</p>
<pre class="language-auto">function getPrimaryText(ticket, comments) {
const newestPublic = [...comments]
.reverse()
.find(c = c.public !== false);

const raw = newestPublic?.html_body || "";

let text = htmlToText(raw);
text = stripSignatures(text);
text = maskPII(text);

return text;
}</pre>
</div><div class="accordion"><h3 id="h_01KH7277F0Q5DR88T8E8463XPH">Step 7: Apply prompt-safe formatting</h3></div><div class="panel">
<p>Use a prompt-safe format. This ensures that the model treats user content as <strong>data</strong>, not instructions.</p>
<pre class="language-auto">function safePromptBlock(userText) {
return `
&lt;&lt;&lt;BEGIN_USER_MESSAGE&gt;&gt;&gt;
${userText}
&lt;&lt;&lt;END_USER_MESSAGE&gt;&gt;&gt;
`.trim();
}</pre>
</div><div class="accordion"><h3 id="h_01KH727JXGYH5X6GD8K71FFKSQ">Step 8: Truncate and validate data</h3></div><div class="panel">
<p>In the final step, truncate and validate the normalized data.</p>
<pre class="language-auto">function truncate(text, maxChars = 8000) {
if (text.length &lt;= maxChars) return text;
return text.slice(0, maxChars) + "\n...[TRUNCATED]";
}</pre>
<p>Also validate that required fields exist, values match expectations, and text is present.</p>
<p>After you complete these checks, you have AI-friendly, normalized ticket data that’s ready for your prompts.</p>
</div>