# Zendesk Support

> Send Zendesk Support tickets and comments to Dimension Labs with the universal API.

This brings in each Zendesk ticket's public comments, from the customer and from your agents, as one conversation per ticket. You run it on your side: a small script pulls from the Zendesk API and posts each comment to the [universal API](/api/tracker).

## What you need

- A Zendesk Support account with admin access to Admin Center, to create an API token.
- A Zendesk API token and the email address of the user it acts as.
- A Dimension Labs API key for a new **Universal API** source. See [Your API key](/api/tracker#your-api-key).

## 1. Get API access in Zendesk

1. In Admin Center, go to **Apps and integrations > APIs > Zendesk API**.
2. Add an API token and copy it. You can have more than one active token.
3. Authenticate with Basic auth, using `{email_address}/token` as the username and the token as the password. With curl that is `-u you@example.com/token:YOUR_API_TOKEN`.

Zendesk marks API tokens as deprecated and recommends OAuth access tokens instead. Both are described on Zendesk's [security and authentication](https://developer.zendesk.com/api-reference/introduction/security-and-auth/) page. If you use an OAuth access token, send it as `Authorization: Bearer YOUR_ACCESS_TOKEN` and the rest of this guide is the same.

## 2. Pull new tickets

Use the cursor-based [incremental ticket export](https://developer.zendesk.com/api-reference/ticketing/ticket-management/incremental_exports/). It returns every ticket that changed since a time you give it.

- First call: `GET /api/v2/incremental/tickets/cursor?start_time=UNIX_SECONDS`.
- Later calls: `GET /api/v2/incremental/tickets/cursor?cursor=AFTER_CURSOR`, using the `after_cursor` from the last response.
- Keep requesting `after_url` until `end_of_stream` is `true`. Store the last `after_cursor` for the next run.

```bash
curl -u "$ZENDESK_EMAIL/token:$ZENDESK_API_TOKEN" \
  "https://YOUR_SUBDOMAIN.zendesk.com/api/v2/incremental/tickets/cursor.json?start_time=1760313600"
```

The export doesn't include comments. For each ticket, call [list comments](https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_comments/): `GET /api/v2/tickets/{ticket_id}/comments?page[size]=100`. Comments come back oldest first. Follow `links.next` while `meta.has_more` is `true` ([pagination](https://developer.zendesk.com/api-reference/introduction/pagination/)).

Rate limits, per Zendesk: the incremental export endpoints allow 10 requests per minute. Other Support API calls are limited per minute by plan (200 on Team, 400 on Professional, 700 on Enterprise). A `429` response carries a `Retry-After` header with the seconds to wait ([rate limits](https://developer.zendesk.com/api-reference/introduction/rate-limits/)).

## 3. Map the fields

| Dimension Labs | Zendesk | Notes |
|---|---|---|
| `text` | comment `plain_body` | The comment as plain text. Skip comments where `public` is `false`: those are internal notes. |
| `userId` | ticket `requester_id` | Zendesk's internal user id for the customer, on both their comments and the agents' replies. |
| `incoming` | comment `author_id` equals ticket `requester_id` | `true` for the requester's comments, `false` for everyone else's. |
| `sessionId` | ticket `id` | Prefix it, for example `zendesk-123`. |
| `conversationId` | ticket `id` | Same value as `sessionId`. |
| `dimensionlabs_timestamp` | comment `created_at` | Convert with `toMillis`. |
| `platformJson` | ticket `via`, `status`, `tags`, `brand_id`, `organization_id`, `satisfaction_rating` | `organization_id` lets you join to account data. |

**Personal data.** Don't send the requester's email or name. Comment text often holds names, emails and phone numbers: redact them before you send. See [PII redaction](/data/pii-redaction).

## 4. Send it

```js
// zendesk.mjs. Environment: ZENDESK_SUBDOMAIN, ZENDESK_EMAIL, ZENDESK_API_TOKEN, DL_API_KEY.


const HOUR = 60 * 60 * 1000;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const base = `https://${process.env.ZENDESK_SUBDOMAIN}.zendesk.com/api/v2`;
const auth = 'Basic ' + Buffer.from(`${process.env.ZENDESK_EMAIL}/token:${process.env.ZENDESK_API_TOKEN}`).toString('base64');

// Retries Zendesk's rate limits (429) and server errors (5xx), honoring Retry-After.
async function get(url) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(url, { headers: { Authorization: auth } });
    if (res.ok) return res.json();
    if ((res.status !== 429 && res.status < 500) || attempt === 5) throw new Error(`Zendesk returned ${res.status}: ${await res.text()}`);
    await sleep((Number(res.headers.get('retry-after')) || 2 ** attempt) * 1000);
  }
}

// Sends every public comment written at or after `since` (Unix ms). Returns the new `since`.
export async function syncZendesk({ since, historical = false }) {
  const startTime = Math.min(Math.floor(since / 1000), Math.floor(Date.now() / 1000) - 120); // Zendesk wants a time in the past
  let url = `${base}/incremental/tickets/cursor.json?start_time=${startTime}`;
  let newest = since;
  for (;;) {
    const page = await get(url);
    for (const ticket of page.tickets) {
      let next = `${base}/tickets/${ticket.id}/comments.json?page[size]=100`;
      while (next) {
        const res = await get(next);
        for (const c of res.comments) {
          const ts = toMillis(c.created_at);
          if (!c.public || ts < since || !c.plain_body?.trim()) continue;
          await sendToDimensionLabs({
            text: c.plain_body,
            userId: String(ticket.requester_id),
            incoming: c.author_id === ticket.requester_id,
            sessionId: `zendesk-${ticket.id}`,
            conversationId: `zendesk-${ticket.id}`,
            dimensionlabs_timestamp: ts,
            platformJson: { via: ticket.via?.channel, status: ticket.status, tags: ticket.tags, brand_id: ticket.brand_id, organization_id: ticket.organization_id, satisfaction_rating: ticket.satisfaction_rating?.score },
          }, { historical: historical || ts < Date.now() - HOUR });
          newest = Math.max(newest, ts);
        }
        next = res.meta?.has_more ? res.links.next : null;
      }
    }
    if (page.end_of_stream) return newest;
    url = page.after_url;
  }
}
```

Each run starts the export from `since`, so a comment at the boundary can be sent twice. That's fine: duplicates are removed automatically.

## 5. Run it

Save the code as `zendesk.mjs` in a project set up as in [Set up your project](/api/tracker#set-up-your-project), and run it with the [scheduled sync](/api/scheduled-sync) runner: `import { syncZendesk as sync } from './zendesk.mjs';`.

- **History first:** `node run.mjs 2025-01-01` sends every comment since that date to `/trackhistorical`. Then start a [backfill](/dimensions/backfill) from the **Backfill** tab.
- **Then every 15 minutes:** the scheduled job sends new comments to `/track`, and they are enriched automatically every day. A comment that is more than an hour old when the job sees it goes to `/trackhistorical`, and needs a backfill.
- **Re-runs are safe:** duplicates are removed automatically.

You can also [upload a file](/data/upload-a-file) of exported tickets with one row per comment (check it with the [file checker](/data/check-a-file)), then run a backfill.

## Zendesk reference

- [Incremental exports](https://developer.zendesk.com/api-reference/ticketing/ticket-management/incremental_exports/)
- [Ticket comments](https://developer.zendesk.com/api-reference/ticketing/tickets/ticket_comments/)
- [Tickets](https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/)
- [Security and authentication](https://developer.zendesk.com/api-reference/introduction/security-and-auth/)
- [Pagination](https://developer.zendesk.com/api-reference/introduction/pagination/)
- [Rate limits](https://developer.zendesk.com/api-reference/introduction/rate-limits/)

Checked against Zendesk's documentation on 2026-10-13.
