---
description: Receive verified Zendesk events and use a ticket-bound Fetch client from application-owned tools.
title: Zendesk | Flue
image: https://flueframework.com/docs/og4.jpg
---

# Zendesk

AI-generated, awaiting review [ View as Markdown](https://flueframework.com/docs/ecosystem/channels/zendesk/index.md) [  @flue/zendesk ](https://www.npmjs.com/package/@flue/zendesk) 

## Quickstart

Add verified event-subscription ingress and application-owned Ticketing API behavior to an existing Flue project with the [Zendesk](https://developer.zendesk.com) blueprint. Run the following command in your terminal or coding agent of choice:

```
flue add channel zendesk
```

## Overview

The blueprint installs `@flue/zendesk` and `lossless-json`. It creates a narrow Fetch client at `<source-root>/zendesk-client.ts` and`<source-root>/channels/zendesk.ts` with named `channel` and project-owned`client` exports, ticket identity handling, and a ticket-bound retrieval tool. It wires that tool into an agent and adds Node types only when the target needs them; no community Zendesk SDK is installed.

```
import { createZendeskChannel } from '@flue/zendesk';
import { dispatch } from '@flue/runtime';
import assistant from '../agents/assistant.ts';
import { createZendeskClient } from '../zendesk-client.ts';

export const client = createZendeskClient({
  subdomain: process.env.ZENDESK_SUBDOMAIN!,
  email: process.env.ZENDESK_EMAIL!,
  apiToken: process.env.ZENDESK_API_TOKEN!,
});

export const channel = createZendeskChannel({
  signingSecret: process.env.ZENDESK_WEBHOOK_SIGNING_SECRET!,
  accountId: process.env.ZENDESK_ACCOUNT_ID!,
  async webhook({ payload }) {
    if (payload.type !== 'zen:event-type:ticket.created') return;
    const ticketId = ticketIdFromEvent(payload.subject, payload.detail);
    if (!ticketId) return;

    await dispatch(assistant, {
      id: channel.ticketKey({ accountId: payload.account_id, ticketId }),
      input: { type: `zendesk.${payload.type}`, eventId: payload.id, ticketId },
    });
  },
});
```

The abridged example omits the `ticketIdFromEvent()` helper; the complete helper appears in the channel module below.

A matching ticket event is admitted to the agent bound to that account and ticket, while other verified events receive an empty successful response. The full generated module validates matching ticket identity in `subject` and`detail.id`, handles comment events, and lets the bound agent retrieve the current ticket through the project-owned client. That client preserves large Zendesk identifiers and runs in Node or Cloudflare Workers.

## Configure

| Variable                          | Purpose                                                                |
| --------------------------------- | ---------------------------------------------------------------------- |
| ZENDESK\_WEBHOOK\_SIGNING\_SECRET | **Required** — Verifies inbound event bodies.                          |
| ZENDESK\_ACCOUNT\_ID              | **Required** — Restricts events and resource identity to one account.  |
| ZENDESK\_WEBHOOK\_ID              | **Optional** — Restricts deliveries to one configured webhook.         |
| ZENDESK\_SUBDOMAIN                | **Required** — Selects the account’s Ticketing API origin.             |
| ZENDESK\_EMAIL                    | **Required** — Identifies the API-token user for Basic authentication. |
| ZENDESK\_API\_TOKEN               | **Required** — Authenticates outbound Ticketing API requests.          |

It installs `@flue/zendesk` and creates a channel module with named `channel`and project-owned `client` exports. Zendesk has no officially supported Node server SDK, so the blueprint uses a narrow native Fetch client instead of adding a community wrapper.

Create a JSON event-subscription webhook with:

```
https://example.com/channels/zendesk/webhook
```

The webhook signing secret and outbound API token are separate credentials.

## Channel module

```
import { createZendeskChannel, type JsonValue, type ZendeskTicketRef } from '@flue/zendesk';
import { defineTool, dispatch } from '@flue/runtime';
import assistant from '../agents/assistant.ts';
import { createZendeskClient } from '../zendesk-client.ts';

const accountId = requiredEnv('ZENDESK_ACCOUNT_ID');

export const client = createZendeskClient({
  subdomain: requiredEnv('ZENDESK_SUBDOMAIN'),
  email: requiredEnv('ZENDESK_EMAIL'),
  apiToken: requiredEnv('ZENDESK_API_TOKEN'),
});

export const channel = createZendeskChannel({
  signingSecret: requiredEnv('ZENDESK_WEBHOOK_SIGNING_SECRET'),
  accountId,
  webhookId: process.env.ZENDESK_WEBHOOK_ID || undefined,

  // Path: /channels/zendesk/webhook
  async webhook({ c, payload, delivery }) {
    switch (payload.type) {
      case 'zen:event-type:ticket.created':
      case 'zen:event-type:ticket.comment_added': {
        const ticketId = ticketIdFromEvent(payload.subject, payload.detail);
        if (!ticketId) {
          return c.json({ error: 'Expected a Zendesk ticket event.' }, 400);
        }

        const ticket: ZendeskTicketRef = {
          accountId: payload.account_id,
          ticketId,
        };
        await dispatch(assistant, {
          id: channel.ticketKey(ticket),
          input: {
            type: `zendesk.${payload.type}`,
            eventId: payload.id,
            invocationId: delivery.invocationId,
            occurredAt: payload.time,
            ticketId,
            change: payload.event,
          },
        });
        return;
      }
      default:
        return;
    }
  },
});

export function retrieveTicket(ref: ZendeskTicketRef) {
  if (ref.accountId !== accountId) {
    throw new TypeError('Expected the configured Zendesk account.');
  }
  return defineTool({
    name: 'retrieve_zendesk_ticket',
    description: 'Retrieve the Zendesk ticket already bound to this agent.',
    parameters: {
      type: 'object',
      properties: {},
      additionalProperties: false,
    },
    async execute() {
      return JSON.stringify(await client.getTicket(ref.ticketId));
    },
  });
}

function ticketIdFromEvent(subject: string, detail: Record<string, JsonValue>): string | undefined {
  const match = /^zen:ticket:([1-9]\d*)$/.exec(subject);
  if (!match?.[1]) return undefined;
  const id = detail.id;
  if (
    !(
      (typeof id === 'string' && /^[1-9]\d*$/.test(id)) ||
      (typeof id === 'number' && Number.isSafeInteger(id) && id > 0)
    )
  ) {
    return undefined;
  }
  return String(id) === match[1] ? match[1] : undefined;
}

function requiredEnv(name: string): string {
  const value = process.env[name];
  if (!value) throw new Error(`${name} is required.`);
  return value;
}
```

The grouped branch handles selected ticket events while leaving the provider catalog open. Validate the fields consumed for every subscribed type. The example requires the ticket id in `subject` and `detail.id` to agree before using it as application identity.

## Project-owned client

Use the original account subdomain and bind credentials in trusted code:

```
import { isLosslessNumber, isSafeNumber, parse } from 'lossless-json';

type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };

export function createZendeskClient({
  subdomain,
  email,
  apiToken,
  fetcher = globalThis.fetch,
}: {
  subdomain: string;
  email: string;
  apiToken: string;
  fetcher?: typeof globalThis.fetch;
}) {
  if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(subdomain)) {
    throw new TypeError('Zendesk subdomain must be a bare DNS label.');
  }
  const authorization = `Basic ${Buffer.from(`${email}/token:${apiToken}`).toString('base64')}`;

  return {
    async getTicket(ticketId: string) {
      if (!/^[1-9]\d*$/.test(ticketId)) {
        throw new TypeError('Zendesk ticket id must be a positive integer.');
      }
      const response = await fetcher(
        `https://${subdomain}.zendesk.com/api/v2/tickets/${ticketId}.json`,
        {
          headers: {
            accept: 'application/json',
            authorization,
          },
        },
      );
      if (!response.ok) {
        throw new Error(`Zendesk API request failed with ${response.status}.`);
      }
      const body = normalizeJsonValue(parse(await response.text()));
      if (!isRecord(body) || !isRecord(body.ticket) || !isZendeskId(body.ticket.id)) {
        throw new TypeError('Zendesk returned an invalid ticket response.');
      }
      return body.ticket;
    },
  };
}

function isZendeskId(value: unknown): value is string | number {
  if (typeof value === 'string') return /^[1-9]\d*$/.test(value);
  return typeof value === 'number' && Number.isSafeInteger(value) && value > 0;
}

function normalizeJsonValue(value: unknown): JsonValue | undefined {
  if (
    value === null ||
    typeof value === 'boolean' ||
    typeof value === 'string' ||
    (typeof value === 'number' && Number.isFinite(value))
  ) {
    return value;
  }
  if (isLosslessNumber(value)) {
    return isSafeNumber(value.value) ? Number(value.value) : value.value;
  }
  if (Array.isArray(value)) {
    const result: JsonValue[] = [];
    for (const item of value) {
      const normalized = normalizeJsonValue(item);
      if (normalized === undefined) return undefined;
      result.push(normalized);
    }
    return result;
  }
  if (!isRecord(value)) return undefined;
  const result: { [key: string]: JsonValue } = {};
  for (const [key, item] of Object.entries(value)) {
    const normalized = normalizeJsonValue(item);
    if (normalized === undefined) return undefined;
    result[key] = normalized;
  }
  return result;
}

function isRecord(value: unknown): value is Record<string, unknown> {
  return (
    typeof value === 'object' &&
    value !== null &&
    !Array.isArray(value) &&
    !isLosslessNumber(value) &&
    Object.getPrototypeOf(value) === Object.prototype
  );
}
```

Zendesk documents API-token Basic authentication as`{email}/token:{api_token}`. OAuth bearer tokens are also available, but authorization setup, token refresh, and installation storage remain application-owned.

Do not accept an arbitrary base URL from a model or webhook field. Host-mapped Help Center domains do not replace the account’s original`<subdomain>.zendesk.com` API origin.

Install `lossless-json@4.3.0` for this client. Zendesk identifiers can exceed JavaScript’s safe integer range, so unsafe numeric ids remain decimal strings instead of being rounded.

## Bind the tool

```
import { createAgent } from '@flue/runtime';
import { channel, retrieveTicket } from '../channels/zendesk.ts';

export default createAgent(({ id }) => {
  const ticket = channel.parseTicketKey(id);
  return {
    model: 'anthropic/claude-haiku-4-5',
    tools: [retrieveTicket(ticket)],
  };
});
```

The tool accepts no account, ticket id, API host, or credential from the model.`ticketKey()` includes account and ticket identity because Zendesk resource ids are account-scoped. The key remains an identifier, not an authorization capability.

## Verification

Zendesk sends:

```
X-Zendesk-Account-Id
X-Zendesk-Webhook-Id
X-Zendesk-Webhook-Invocation-Id
X-Zendesk-Webhook-Signature
X-Zendesk-Webhook-Signature-Timestamp
```

The signature is base64 HMAC-SHA256 over the signature timestamp concatenated directly with the exact request body. There is no delimiter.`@flue/zendesk` preserves and verifies those bytes before UTF-8 decoding or JSON parsing.

The HMAC covers the timestamp and body, not the account, webhook, or invocation headers. The package requires those headers, checks payload `account_id`against the account header, and can restrict configured account and webhook ids. Treat header metadata as provider routing context rather than independent authorization.

Zendesk does not document a timestamp acceptance window or clock-skew rule. The channel exposes `delivery.signatureTimestamp` but does not invent freshness semantics.

## Event shape

The callback receives `{ c, payload, delivery }`, keeping the Flue-verified provider-native payload separate from the unsigned header metadata.

`payload` is Zendesk’s own [common event envelope](https://developer.zendesk.com/api-reference/webhooks/event-types/webhook-event-types/), with the provider’s snake\_case field names:

* `account_id`, normalized to a positive decimal string;
* `id`, the provider event id;
* `type` and `zendesk_event_version`, both open strings;
* `subject` such as `zen:ticket:<id>`, and `time`;
* provider-native `detail` and `event` JSON objects.

An index signature forwards any authenticated future or unmodeled fields, so verified future event families remain observable. JSON is parsed losslessly: unsafe integer literals retain their exact decimal spelling as strings, and the top-level integer `account_id` is normalized to a decimal string.

`delivery` is the unsigned routing metadata read from the request headers:`webhookId`, `invocationId`, and `signatureTimestamp`. Zendesk’s HMAC does not cover these headers, so treat them as provider routing context, not authorization.

Zendesk’s current documentation is inconsistent about ticket delivery setup: the event catalog and Support UI documentation list ticket subscriptions, while the developer webhook guide still recommends triggers or automations for ticket activity. Use the grouped ticket example only when the account exposes those event subscriptions. Custom trigger payloads are developer-authored and are not accepted as if they were the fixed common event envelope.

This initial channel targets provider-defined JSON event subscriptions. Custom trigger and automation webhooks can use developer-authored payloads, other media types, and other methods, so they are not silently treated as the same protocol. Sunshine Conversations and Zendesk AI Agent webhooks also have different or incomplete authentication and delivery contracts and remain separate research.

## Responses and delivery

Returning nothing produces an empty `200`. A JSON-compatible value becomes a JSON response. A normal Hono or Fetch `Response` passes through unchanged. A thrown callback or unsupported return value fails closed with retryable `409`.

Zendesk allows 12 seconds for the complete request. The channel does not enforce a deadline, because racing the callback against a timer cannot actually cancel JavaScript work that has already started — the timed-out work keeps running while a misleading failure is returned. Instead, admit durable work promptly (for example `dispatch(...)` then return) and rely on idempotency rather than blocking on slow operations before acknowledging.

Zendesk retries `409` up to three times, conditionally retries `429` and `503`with a short `Retry-After`, and retries timeouts up to five times. Delivery is best effort and may be duplicated or omitted. Persist the signed `payload.id` in application-owned storage when duplicate admission is unacceptable. The unsigned`delivery.invocationId` is useful for correlating provider attempts but is not a replay-resistant deduplication key. Use an exact `200` for ordinary acknowledgment.

## Cloudflare Workers

Ingress uses Web Crypto and standards-based Fetch APIs. The project-owned client uses native Fetch plus `Buffer` for documented Basic authentication. Both paths execute in workerd with Flue’s required `nodejs_compat`configuration.

Test the real exported client with injected fail-closed Fetch in Node and workerd. Assert the exact Zendesk host, ticket path, method, and authorization header, and reject every unexpected destination. Create original synthetic events and local HMACs for ingress tests. Do not create a webhook, subscribe to live events, obtain a real token, or contact Zendesk.

See the [@flue/zendesk API reference](https://flueframework.com/docs/api/zendesk-channel/).

## Docs Navigation

Current page: [Zendesk](https://flueframework.com/docs/ecosystem/channels/zendesk/)

### Sections

* [Guide](https://flueframework.com/docs/getting-started/quickstart/)
* [Reference](https://flueframework.com/docs/api/agent-api/)
* [CLI](https://flueframework.com/docs/cli/overview/)
* [SDK](https://flueframework.com/docs/sdk/overview/)
* [Ecosystem](https://flueframework.com/docs/ecosystem/)

* [  Overview ](https://flueframework.com/docs/ecosystem/)

### Channels

* [ Discord ](https://flueframework.com/docs/ecosystem/channels/discord/)
* [ Facebook ](https://flueframework.com/docs/ecosystem/channels/messenger/)
* [ GitHub ](https://flueframework.com/docs/ecosystem/channels/github/)
* [ Google Chat ](https://flueframework.com/docs/ecosystem/channels/google-chat/)
* [ Intercom ](https://flueframework.com/docs/ecosystem/channels/intercom/)
* [ Linear ](https://flueframework.com/docs/ecosystem/channels/linear/)
* [ Microsoft Teams ](https://flueframework.com/docs/ecosystem/channels/teams/)
* [ Notion ](https://flueframework.com/docs/ecosystem/channels/notion/)
* [ Resend ](https://flueframework.com/docs/ecosystem/channels/resend/)
* [ Salesforce ](https://flueframework.com/docs/ecosystem/channels/salesforce-marketing-cloud/)
* [ Shopify ](https://flueframework.com/docs/ecosystem/channels/shopify/)
* [ Slack ](https://flueframework.com/docs/ecosystem/channels/slack/)
* [ Stripe ](https://flueframework.com/docs/ecosystem/channels/stripe/)
* [ Telegram ](https://flueframework.com/docs/ecosystem/channels/telegram/)
* [ Twilio ](https://flueframework.com/docs/ecosystem/channels/twilio/)
* [ WhatsApp ](https://flueframework.com/docs/ecosystem/channels/whatsapp/)
* [ Zendesk ](https://flueframework.com/docs/ecosystem/channels/zendesk/)

### Sandboxes

* [ boxd ](https://flueframework.com/docs/ecosystem/sandboxes/boxd/)
* [ Cloudflare Shell ](https://flueframework.com/docs/ecosystem/sandboxes/cloudflare-shell/)
* [ Cloudflare Sandbox ](https://flueframework.com/docs/ecosystem/sandboxes/cloudflare/)
* [ Daytona ](https://flueframework.com/docs/ecosystem/sandboxes/daytona/)
* [ E2B ](https://flueframework.com/docs/ecosystem/sandboxes/e2b/)
* [ exe.dev ](https://flueframework.com/docs/ecosystem/sandboxes/exedev/)
* [ islo ](https://flueframework.com/docs/ecosystem/sandboxes/islo/)
* [ Mirage ](https://flueframework.com/docs/ecosystem/sandboxes/mirage/)
* [ Modal ](https://flueframework.com/docs/ecosystem/sandboxes/modal/)
* [ Vercel Sandbox ](https://flueframework.com/docs/ecosystem/sandboxes/vercel/)

### Deploy

* [ AWS ](https://flueframework.com/docs/ecosystem/deploy/aws/)
* [ Cloudflare ](https://flueframework.com/docs/ecosystem/deploy/cloudflare/)
* [ Docker ](https://flueframework.com/docs/ecosystem/deploy/docker/)
* [ Fly.io ](https://flueframework.com/docs/ecosystem/deploy/fly/)
* [ GitHub Actions ](https://flueframework.com/docs/ecosystem/deploy/github-actions/)
* [ GitLab CI/CD ](https://flueframework.com/docs/ecosystem/deploy/gitlab-ci/)
* [ Node.js ](https://flueframework.com/docs/ecosystem/deploy/node/)
* [ Railway ](https://flueframework.com/docs/ecosystem/deploy/railway/)
* [ Render ](https://flueframework.com/docs/ecosystem/deploy/render/)
* [ SST ](https://flueframework.com/docs/ecosystem/deploy/sst/)

### Databases

* [ libSQL ](https://flueframework.com/docs/ecosystem/databases/libsql/)
* [ MongoDB ](https://flueframework.com/docs/ecosystem/databases/mongodb/)
* [ MySQL ](https://flueframework.com/docs/ecosystem/databases/mysql/)
* [ Postgres ](https://flueframework.com/docs/ecosystem/databases/postgres/)
* [ Redis ](https://flueframework.com/docs/ecosystem/databases/redis/)
* [ Supabase ](https://flueframework.com/docs/ecosystem/databases/supabase/)
* [ Turso ](https://flueframework.com/docs/ecosystem/databases/turso/)
* [ Valkey ](https://flueframework.com/docs/ecosystem/databases/valkey/)

### Tooling

* [ Braintrust ](https://flueframework.com/docs/ecosystem/tooling/braintrust/)
* [ OpenTelemetry ](https://flueframework.com/docs/ecosystem/tooling/opentelemetry/)
* [ Sentry ](https://flueframework.com/docs/ecosystem/tooling/sentry/)