Skip to main content
Business & GrowthCustomer Support Services332 lines

Zendesk

"Zendesk: Support API, tickets, users, custom fields, triggers, Web Widget, webhooks, OAuth, Node SDK"

Quick Summary18 lines
Zendesk is an enterprise customer service platform built around a structured ticketing system. Integration should treat tickets as the atomic unit of work — every customer interaction maps to a ticket with defined lifecycle states, assignees, and SLA policies. Design integrations that automate ticket creation and routing, enrich tickets with external data via custom fields, and use triggers and webhooks to keep systems in sync. The Support API follows REST conventions with cursor-based pagination for large datasets. Always authenticate with OAuth tokens for production deployments, implement incremental exports for data synchronization, and respect rate limits to avoid throttling.

## Key Points

- Use `create_or_update` endpoints for users and organizations to avoid duplicate records and simplify sync logic.
- Prefer cursor-based incremental exports over search for bulk data synchronization — they are faster and not subject to the 1000-result search limit.
- Set custom fields via their numeric field ID, not name. Cache the field ID mapping at startup with `GET /ticket_fields.json`.
- Implement rate limit handling by reading `X-Rate-Limit` and `Retry-After` headers. Zendesk allows 700 requests per minute on professional plans.
- Use internal notes (non-public comments) to log automated actions so agents see the audit trail without customers receiving noise.
- Batch ticket updates with `update_many` when modifying more than 5 tickets to reduce API calls.
- Always paginate with cursor-based pagination (`page[after]`) instead of offset pagination for large result sets.
- **Using the search API for data synchronization** — Search results are eventually consistent and capped at 1000 results. Use incremental exports for reliable sync.
- **Creating tickets without a requester** — Anonymous tickets cannot be tracked or followed up on. Always associate a requester by email or user ID.
- **Storing API tokens in client-side code** — Zendesk tokens grant full API access. Always proxy requests through your backend.
- **Ignoring job status for bulk operations** — Bulk updates return a job ID, not immediate results. Poll the job status endpoint to confirm completion before proceeding.
- **Setting priorities without SLA policies** — Priority values are meaningless without corresponding SLA targets. Configure SLAs in Zendesk admin before using priority fields programmatically.
skilldb get customer-support-services-skills/ZendeskFull skill: 332 lines
Paste into your CLAUDE.md or agent config

Zendesk Integration

Core Philosophy

Zendesk is an enterprise customer service platform built around a structured ticketing system. Integration should treat tickets as the atomic unit of work — every customer interaction maps to a ticket with defined lifecycle states, assignees, and SLA policies. Design integrations that automate ticket creation and routing, enrich tickets with external data via custom fields, and use triggers and webhooks to keep systems in sync. The Support API follows REST conventions with cursor-based pagination for large datasets. Always authenticate with OAuth tokens for production deployments, implement incremental exports for data synchronization, and respect rate limits to avoid throttling.

Setup

Configure the Node client for API access:

import axios, { AxiosInstance } from "axios";

interface ZendeskConfig {
  subdomain: string;
  email: string;
  token: string;
}

function createZendeskClient(config: ZendeskConfig): AxiosInstance {
  const baseURL = `https://${config.subdomain}.zendesk.com/api/v2`;
  return axios.create({
    baseURL,
    auth: {
      username: `${config.email}/token`,
      password: config.token,
    },
    headers: { "Content-Type": "application/json" },
    timeout: 15000,
  });
}

const zd = createZendeskClient({
  subdomain: process.env.ZENDESK_SUBDOMAIN!,
  email: process.env.ZENDESK_EMAIL!,
  token: process.env.ZENDESK_API_TOKEN!,
});

// Verify connection
async function verifyConnection(): Promise<void> {
  const { data } = await zd.get("/users/me.json");
  console.log(`Connected as: ${data.user.name} (${data.user.email})`);
}

Embed the Web Widget on your frontend:

declare global {
  interface Window {
    zE: (...args: unknown[]) => void;
    zESettings: Record<string, unknown>;
  }
}

function initZendeskWidget(key: string): void {
  window.zESettings = {
    webWidget: {
      color: { theme: "#1f73b7" },
      contactForm: {
        fields: [{ id: "description", prefill: { "*": "" } }],
      },
      helpCenter: { suppress: false },
      chat: { suppress: false },
    },
  };

  const script = document.createElement("script");
  script.id = "ze-snippet";
  script.src = `https://static.zdassets.com/ekr/snippet.js?key=${key}`;
  script.async = true;
  document.head.appendChild(script);
}

function identifyZendeskUser(name: string, email: string): void {
  window.zE("webWidget", "identify", { name, email });
}

function openZendeskWidget(): void {
  window.zE("webWidget", "open");
}

function prefillTicketForm(subject: string, description: string): void {
  window.zE("webWidget", "prefill", {
    name: { value: "", readOnly: false },
    email: { value: "", readOnly: false },
    description: { value: `**${subject}**\n\n${description}` },
  });
}

Key Techniques

Ticket Management

interface TicketCreate {
  subject: string;
  description: string;
  requesterId?: number;
  assigneeId?: number;
  priority?: "urgent" | "high" | "normal" | "low";
  tags?: string[];
  customFields?: Array<{ id: number; value: string }>;
}

async function createTicket(ticket: TicketCreate): Promise<number> {
  const { data } = await zd.post("/tickets.json", {
    ticket: {
      subject: ticket.subject,
      comment: { body: ticket.description },
      requester_id: ticket.requesterId,
      assignee_id: ticket.assigneeId,
      priority: ticket.priority ?? "normal",
      tags: ticket.tags ?? [],
      custom_fields: ticket.customFields ?? [],
    },
  });
  return data.ticket.id;
}

async function updateTicket(
  ticketId: number,
  updates: { status?: string; priority?: string; tags?: string[]; comment?: string; internal?: boolean }
): Promise<void> {
  const payload: Record<string, unknown> = {};
  if (updates.status) payload.status = updates.status;
  if (updates.priority) payload.priority = updates.priority;
  if (updates.tags) payload.tags = updates.tags;
  if (updates.comment) {
    payload.comment = { body: updates.comment, public: !(updates.internal ?? false) };
  }
  await zd.put(`/tickets/${ticketId}.json`, { ticket: payload });
}

async function bulkUpdateTickets(ticketIds: number[], updates: Record<string, unknown>): Promise<string> {
  const ids = ticketIds.join(",");
  const { data } = await zd.put(`/tickets/update_many.json?ids=${ids}`, { ticket: updates });
  return data.job_status.id;
}

async function searchTickets(query: string, page: number = 1): Promise<{ tickets: unknown[]; hasMore: boolean }> {
  const { data } = await zd.get("/search.json", {
    params: { query: `type:ticket ${query}`, page, per_page: 100 },
  });
  return { tickets: data.results, hasMore: data.next_page !== null };
}

User and Organization Management

interface ZendeskUser {
  name: string;
  email: string;
  role?: "end-user" | "agent" | "admin";
  organizationId?: number;
  userFields?: Record<string, unknown>;
}

async function createOrUpdateUser(user: ZendeskUser): Promise<number> {
  const { data } = await zd.post("/users/create_or_update.json", {
    user: {
      name: user.name,
      email: user.email,
      role: user.role ?? "end-user",
      organization_id: user.organizationId,
      user_fields: user.userFields ?? {},
    },
  });
  return data.user.id;
}

async function findUserByEmail(email: string): Promise<number | null> {
  const { data } = await zd.get("/search.json", {
    params: { query: `type:user email:${email}` },
  });
  return data.results.length > 0 ? data.results[0].id : null;
}

async function createOrganization(name: string, domains: string[]): Promise<number> {
  const { data } = await zd.post("/organizations.json", {
    organization: { name, domain_names: domains },
  });
  return data.organization.id;
}

Webhook Handler

import crypto from "node:crypto";
import type { Request, Response } from "express";

function verifyZendeskWebhook(req: Request, secret: string): boolean {
  const signature = req.headers["x-zendesk-webhook-signature"] as string;
  const timestamp = req.headers["x-zendesk-webhook-signature-timestamp"] as string;
  if (!signature || !timestamp) return false;

  const payload = timestamp + JSON.stringify(req.body);
  const expected = crypto.createHmac("sha256", secret).update(payload).digest("base64");
  return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}

interface ZendeskWebhookPayload {
  ticketId: number;
  ticketStatus: string;
  ticketPriority: string;
  requesterEmail: string;
  assigneeName?: string;
  latestComment?: string;
  tags: string;
}

function handleZendeskWebhook(req: Request, res: Response): void {
  if (!verifyZendeskWebhook(req, process.env.ZENDESK_WEBHOOK_SECRET!)) {
    res.status(401).json({ error: "Invalid signature" });
    return;
  }

  const payload = req.body as ZendeskWebhookPayload;

  if (payload.ticketPriority === "urgent") {
    console.log(`URGENT ticket #${payload.ticketId} from ${payload.requesterEmail}`);
    // Trigger PagerDuty or Slack alert
  }

  if (payload.ticketStatus === "solved") {
    console.log(`Ticket #${payload.ticketId} solved by ${payload.assigneeName}`);
    // Update internal tracking system
  }

  res.status(200).json({ received: true });
}

Incremental Export for Data Sync

async function incrementalTicketExport(startTime: number): Promise<{
  tickets: unknown[];
  nextStartTime: number;
  hasMore: boolean;
}> {
  const { data } = await zd.get("/incremental/tickets/cursor.json", {
    params: { start_time: startTime },
  });

  return {
    tickets: data.tickets,
    nextStartTime: data.end_time,
    hasMore: !data.end_of_stream,
  };
}

async function fullIncrementalSync(since: number): Promise<unknown[]> {
  const allTickets: unknown[] = [];
  let startTime = since;
  let hasMore = true;

  while (hasMore) {
    const batch = await incrementalTicketExport(startTime);
    allTickets.push(...batch.tickets);
    startTime = batch.nextStartTime;
    hasMore = batch.hasMore;

    if (hasMore) {
      await new Promise((resolve) => setTimeout(resolve, 1000));
    }
  }
  return allTickets;
}

OAuth Authentication Flow

import type { Request, Response } from "express";

function getZendeskOAuthURL(subdomain: string, clientId: string, redirectUri: string): string {
  const params = new URLSearchParams({
    response_type: "code",
    redirect_uri: redirectUri,
    client_id: clientId,
    scope: "read write",
  });
  return `https://${subdomain}.zendesk.com/oauth/authorizations/new?${params}`;
}

async function exchangeOAuthCode(
  subdomain: string,
  code: string,
  clientId: string,
  clientSecret: string,
  redirectUri: string
): Promise<string> {
  const { data } = await axios.post(`https://${subdomain}.zendesk.com/oauth/tokens`, {
    grant_type: "authorization_code",
    code,
    client_id: clientId,
    client_secret: clientSecret,
    redirect_uri: redirectUri,
    scope: "read write",
  });
  return data.access_token;
}

Best Practices

  • Use create_or_update endpoints for users and organizations to avoid duplicate records and simplify sync logic.
  • Prefer cursor-based incremental exports over search for bulk data synchronization — they are faster and not subject to the 1000-result search limit.
  • Set custom fields via their numeric field ID, not name. Cache the field ID mapping at startup with GET /ticket_fields.json.
  • Implement rate limit handling by reading X-Rate-Limit and Retry-After headers. Zendesk allows 700 requests per minute on professional plans.
  • Use internal notes (non-public comments) to log automated actions so agents see the audit trail without customers receiving noise.
  • Batch ticket updates with update_many when modifying more than 5 tickets to reduce API calls.
  • Always paginate with cursor-based pagination (page[after]) instead of offset pagination for large result sets.

Anti-Patterns

  • Using the search API for data synchronization — Search results are eventually consistent and capped at 1000 results. Use incremental exports for reliable sync.
  • Creating tickets without a requester — Anonymous tickets cannot be tracked or followed up on. Always associate a requester by email or user ID.
  • Storing API tokens in client-side code — Zendesk tokens grant full API access. Always proxy requests through your backend.
  • Ignoring job status for bulk operations — Bulk updates return a job ID, not immediate results. Poll the job status endpoint to confirm completion before proceeding.
  • Setting priorities without SLA policies — Priority values are meaningless without corresponding SLA targets. Configure SLAs in Zendesk admin before using priority fields programmatically.
  • Overusing triggers for integration logic — Zendesk triggers are limited and hard to debug. Use webhooks to push events to your own services where you have full control over business logic.

Install this skill directly: skilldb add customer-support-services-skills

Get CLI access →