> ## Documentation Index
> Fetch the complete documentation index at: https://docs.deploybaseline.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Zendesk

> Tickets, comments, users, organizations and groups, search, views, triggers, OAuth, and webhooks.

Stateful Zendesk emulation of the Support API v2 under `/api/v2`, with Zendesk's JSON shapes and error bodies. Tickets follow the real status lifecycle, with comments and internal notes, audits, custom fields, problems and incidents, followers and CCs, satisfaction ratings, and bulk jobs. It also covers users with identities, organizations with memberships, groups, search with the Zendesk query syntax, views, triggers that notify webhooks, webhooks signed the way Zendesk signs them, and OAuth with scopes. Lists page by offset and by cursor, and every answer carries rate limit headers. The tests drive it with the official [`node-zendesk`](https://github.com/blakmatrix/node-zendesk) client (6.0.1).

## At a glance

| | |
| - | - |
| Package | `@deploybaseline/zendesk` |
| Port | `4000` when started alone; named services take consecutive ports |
| Config key | `zendesk` |
| HTTP operations | 164, browsable at `http://localhost:3999/docs/zendesk` while it runs ([API reference](/api-reference.md)) |

```bash CLI theme={null}
npx @deploybaseline/cli start --service zendesk
```

```ts Programmatic theme={null}
import { createEmulator } from "@deploybaseline/cli";

const zendesk = await createEmulator({ service: "zendesk" });
// zendesk.url is http://localhost:4000
```

```json baseline.config.json theme={null}
{
  "zendesk": {
    "subdomain": "acme",
    "owner": {
      "name": "Admin",
      "email": "admin@example.com"
    },
    "groups": [
      {
        "name": "Tier 2"
      }
    ],
    "agents": [
      {
        "name": "Alex Agent",
        "email": "alex@example.com",
        "groups": ["Support"]
      }
    ],
    "organizations": [
      {
        "name": "Globex",
        "domain_names": ["globex.example"]
      }
    ],
    "end_users": [
      {
        "name": "Casey Customer",
        "email": "casey@globex.example"
      }
    ],
    "api_tokens": [
      {
        "token": "baseline-zendesk-api-token",
        "description": "Local development"
      }
    ],
    "oauth_clients": [
      {
        "name": "My app",
        "identifier": "my_app",
        "secret": "my-app-secret",
        "redirect_uris": ["http://localhost:3000/zendesk/callback"]
      }
    ],
    "tickets": [
      {
        "subject": "Printer is on fire",
        "comment": "The printer in room 4 is smoking.",
        "requester": "casey@globex.example",
        "assignee": "alex@example.com",
        "priority": "high",
        "tags": ["hardware"]
      }
    ]
  }
}
```

## Start

```bash theme={null}
npx @deploybaseline/cli start --service zendesk
```

Zendesk starts only when named in config or with `--service`. When it is the only enabled service, it listens on `http://localhost:4000`.

Without seed config, the account has the owner `Admin <admin@example.com>`, a `Support` group, the system ticket fields, and the default views.

Point `node-zendesk` at the emulator with `endpointUri`:

```ts theme={null}
import { createClient } from "node-zendesk";

const zendesk = createClient({
  username: "admin@example.com",
  token: "baseline-zendesk-api-token",
  endpointUri: `${process.env.ZENDESK_EMULATOR_URL ?? "http://localhost:4000"}/api/v2`,
});

const { result: ticket } = await zendesk.tickets.create({
  ticket: { subject: "Printer is on fire", comment: { body: "Room 4 is smoking." }, priority: "urgent" },
});
```

## URL mapping

| Real Zendesk URL | Emulator URL |
| - | - |
| `https://{subdomain}.zendesk.com/api/v2/...` | `$ZENDESK_EMULATOR_URL/api/v2/...` |
| `https://{subdomain}.zendesk.com/oauth/...` | `$ZENDESK_EMULATOR_URL/oauth/...` |

Paths work with or without the `.json` suffix.

## Auth

* API token: `Authorization: Basic base64("{email}/token:{api_token}")` with a token from `api_tokens[]`, for any active user's email.
* Password: `Authorization: Basic base64("{email}:{password}")`, only when `password_access: true`. Seed passwords with `owner.password`, `agents[].password`, and `end_users[].password`.
* OAuth: `Authorization: Bearer {access_token}`. `read` covers every `GET`, `write` every change, and resource scopes such as `tickets:read` or `users:write` cover one resource.
* A token the CLI accepts (for example `test_token_admin`) sent as a bearer token acts as the account owner.

An unknown credential answers 401 `{"error":"Couldn't authenticate you"}`. A role or scope that does not cover the endpoint answers 403 `{"error":"Forbidden",...}`. End users may read their own tickets and comments, rate their solved tickets, and read and edit themselves; agents may not manage admins; webhooks, triggers, groups, ticket fields, and OAuth clients need an admin.

`X-On-Behalf-Of: {email}` makes an admin act as another user when the call uses a CLI token or an OAuth token with the `impersonate` scope.

## OAuth

* `GET /oauth/authorizations/new?response_type=code&client_id=&redirect_uri=&scope=&state=` shows the consent screen, where you choose the user to sign in as. `code_challenge` and `code_challenge_method` add PKCE, and `response_type=token` returns `#access_token=` (implicit grant)
* `POST /oauth/tokens` with JSON or a form: `authorization_code` (with `client_secret`, or a `code_verifier`), `client_credentials` (a confidential client acting as its owner), and `refresh_token`. Tokens do not expire unless the request sets `expires_in` (300 to 172800 seconds), which also returns a refresh token. Errors follow RFC 6749 (`invalid_grant`, `invalid_client`, `unsupported_grant_type`, `invalid_scope`)
* `GET /api/v2/oauth/tokens`, `GET /api/v2/oauth/tokens/current`, `POST /api/v2/oauth/tokens` (admins; answers `full_token`), `GET` and `DELETE /api/v2/oauth/tokens/:id`
* `GET /api/v2/oauth/clients`, `POST /api/v2/oauth/clients`, `GET /api/v2/oauth/clients/:id`, `PUT /api/v2/oauth/clients/:id`, `DELETE /api/v2/oauth/clients/:id`, and `PUT /api/v2/oauth/clients/:id/generate_secret`

## Errors

| Status | Body |
| - | - |
| 400 | `{"error": "...", "description": "..."}` for bad JSON and parameters |
| 401 | `{"error": "Couldn't authenticate you"}` |
| 403 | `{"error": "Forbidden", "description": "You do not have access to this page..."}` |
| 404 | `{"error": "RecordNotFound", "description": "Not found"}`, or `InvalidEndpoint` for unknown paths |
| 409 | `{"error": "UpdateConflict", ...}` |
| 422 | `{"error": "RecordInvalid", "description": "Record validation errors", "details": {"field": [{"description": "...", "error": "..."}]}}` |
| 429 | `{"error": "APIRateLimitExceeded", ...}` |

The Webhooks API answers errors in its own `{"errors": [{"code", "title", "detail"}]}` envelope.

## Rate limits

Every answer carries `X-Rate-Limit`, `X-Rate-Limit-Remaining`, `ratelimit-limit`, `ratelimit-remaining`, and `ratelimit-reset`. Past `rate_limit` requests a minute (700 by default; `0` turns the limit off) the answer is 429 `{"error":"APIRateLimitExceeded"}` with `Retry-After`.

## Tickets

* `GET /tickets`, `GET /tickets/count`, `GET /tickets/show_many?ids=`, `GET /tickets/:id`
* `POST /tickets` answers 201 with `ticket` and `audit`. The first comment is required (`Description: cannot be blank`)
* `PUT /tickets/:id` with `comment`, `status`, `priority`, `type`, `assignee_id`, `group_id`, `requester_id` or `requester: { email, name }`, `tags`, `additional_tags`, `remove_tags`, `custom_fields`, `due_at`, `problem_id`, `external_id`, `follower_ids`, `email_cc_ids`, `collaborator_ids`, and `safe_update` with `updated_stamp` (409 `UpdateConflict` when the ticket changed)
* `DELETE /tickets/:id` soft deletes; `GET /deleted_tickets`, `PUT /deleted_tickets/:id/restore`, and `DELETE /deleted_tickets/:id` (a job) manage deleted tickets
* `POST /tickets/create_many`, `PUT /tickets/update_many` (`?ids=` with one `ticket`, or a `tickets` array with ids), and `DELETE /tickets/destroy_many?ids=` answer a job status. Jobs run at once; the creation answer says `queued` and `GET /job_statuses/:id` says `completed` with per item results and errors
* Tags: `GET /tickets/:id/tags`, `POST` replaces, `PUT` adds, `DELETE` removes
* `GET /tickets/:id/collaborators`, `/followers`, `/email_ccs`, `/incidents`, `GET /problems`
* `GET /users/:id/tickets/requested`, `/ccd`, `/assigned`, `/followed`, and `GET /organizations/:id/tickets`

### Status lifecycle

* A new ticket with an assignee is `open`. A ticket cannot go back to `new`.
* Solving assigns the solving agent when no one is assigned, and offers a satisfaction rating.
* A comment from the requester reopens a `pending`, `hold`, or `solved` ticket.
* Solved tickets close `solved_close_after_ms` after they were solved (four days by default), through the "Close ticket 4 days after status is set to solved" automation. `POST /_zendesk/simulate/close-solved` with an optional `{"ticket_ids": [...]}` closes them now.
* Closed is terminal: updates answer 422 `Status: closed prevents ticket update`. Create a follow-up with `via_followup_source_id`.
* Solving a problem solves its incidents.
* Setting both `group_id` and an `assignee_id` outside that group is refused.

### Custom fields

`ticket_fields[]` seeds custom fields, and `/ticket_fields` manages them. Values are checked by type (`text`, `textarea`, `checkbox`, `date`, `integer`, `decimal`, `regexp`, `tagger`, `multiselect`); `tagger`, `multiselect`, and `checkbox` values add and remove their tags.

## Comments

`GET /tickets/:id/comments` (with `include=users` and `sort_order=desc`), `GET /tickets/:id/comments/count`, and `PUT /tickets/:id/comments/:comment_id/make_private`. Comments take `body` or `html_body`, `public` (false makes an internal note), and `author_id`, and answer `body`, `html_body`, and `plain_body`. End users see only public comments. `GET /tickets/:id/audits` and `GET /tickets/:id/audits/:audit_id` list the `Create`, `Change`, and `Comment` events of each update, with the trigger that made a change in its `via`.

## Satisfaction ratings

The requester rates a solved or closed ticket with `POST /tickets/:id/satisfaction_rating` and `{"satisfaction_rating": {"score": "good"}}` (or `bad`, with an optional `comment`). `GET /satisfaction_ratings` (`score=good|bad|received`) and `GET /satisfaction_ratings/:id` read them.

## Users, organizations, and groups

* Users: list (`role`, `role[]`), count, `me`, search (`query` or `external_id`), autocomplete, `show_many`, create, `create_many`, `create_or_update`, `create_or_update_many`, show, update, delete (soft). A new user joins the organization whose `domain_names` match the email domain
* Identities: list, show, create, update, `make_primary`, `verify`, delete
* Tags on users and organizations: `GET`, `POST` replaces, `PUT` adds, `DELETE` removes. `GET /tags` and `GET /autocomplete/tags` list tags across the account
* Organizations: list, count, `show_many`, search (`name` or `external_id`), autocomplete, create, `create_many`, `create_or_update`, `destroy_many`, show, update, delete, and their users
* Organization memberships: list (all, per organization, per user), create, `create_many`, show, delete, and `make_default`
* Groups: list, count, assignable, create, show, update, delete (soft), and their users. Group memberships: list, assignable, create, `create_many`, show, delete, and `make_default`. Only agents and admins can be members

## Pagination

Lists page by offset with `page` and `per_page` (at most 100), answering full `next_page` and `previous_page` URLs and `count`. Lists that support cursors switch to cursor mode when any `page[...]` parameter is present: `page[size]` (at most 100), `page[after]`, `page[before]`, answering `meta: { has_more, after_cursor, before_cursor }` and `links: { next, prev }`. `node-zendesk` asks for `page[size]=100` and follows the links.

## Search

`GET /search?query=` answers `results` with `result_type`, `facets: null`, and offset pages up to 1,000 results (beyond that, 422 `Invalid search: Requested response size was greater than Search Response Limits`). `GET /search/count` counts, and `GET /search/export?query=&filter[type]=ticket` pages by cursor without the limit.

The query parser is hand written and never refuses a query:

* Free text and `"quoted phrases"`, `-` to negate, and `*` as a wildcard
* `keyword:value`, `keyword<value`, `keyword>value`, `keyword<=value`, `keyword>=value`
* `type:ticket`, `type:user`, `type:organization`, `type:group` (every type without one)
* Tickets: `status` and `priority` (ordered, so `status<solved` works), `ticket_type`, `tags`, `subject`, `description`, `comment`, `assignee`, `requester`, `submitter`, `cc`, `follower`, `commenter` (`me`, `none`, an id, an email, or a name), `group`, `organization`, `via`, `external_id`, `satisfaction`, `fieldvalue`, and the dates `created`, `updated`, `solved`, and `due_date` (`2026-01-01`, an ISO time, or a relative time such as `4hours` or `2days`)
* Users: `name`, `email`, `role`, `organization`, `group`, `tags`, `phone`, `external_id`, `notes`, `details`, `is_verified`, `is_suspended`
* Organizations: `name`, `tags`, `domain`, `external_id`, `details`, `notes`. Groups: `name`
* `order_by:` and `sort:` in the query, or `sort_by` and `sort_order` parameters

Terms on the same keyword with `:` are alternatives (`status:open status:pending`); everything else must hold together.

## Views

`GET /views`, `GET /views/active`, `GET /views/:id`, `POST /views` (admins), `PUT /views/:id`, `DELETE /views/:id`, `GET /views/:id/tickets`, `GET /views/:id/execute` (rows and columns), `GET /views/:id/count`, `GET /views/count_many?ids=`, and `POST /views/preview`. Views take `all` and `any` conditions and an `output` with `columns`, `sort_by`, and `sort_order`. Five default views ("Your unsolved tickets", "Unassigned tickets", and others) are seeded.

## Triggers

`GET /triggers`, `GET /triggers/active`, `GET /triggers/:id`, `POST /triggers`, `PUT /triggers/:id`, and `DELETE /triggers/:id` (admins). After every ticket create and update, active triggers run in order until none fires, each at most once.

* Conditions: `update_type` (`Create`, `Change`), `status`, `priority`, `type`, `group_id`, `assignee_id`, `requester_id`, `organization_id`, `current_tags`, `comment_is_public`, `comment_includes_word`, `subject_includes_word`, `role`, `via_id`, and `custom_fields_{id}`, with `is`, `is_not`, `less_than`, `greater_than`, `changed`, `not_changed`, `value`, `value_previous`, `includes`, `not_includes`, `present`, and `not_present`
* Actions: `status`, `priority`, `type`, `group_id`, `assignee_id` (`current_user` works), `set_tags`, `current_tags`, `remove_tags`, `custom_fields_{id}`, and `notification_webhook` with `[webhook_id, body]`. The body takes placeholders such as `{{ticket.id}}`, `{{ticket.title}}`, `{{ticket.status}}`, `{{ticket.url}}`, `{{ticket.requester.email}}`, and `{{current_user.name}}`, escaped for JSON when the webhook sends JSON

## Webhooks

* `GET /webhooks` (cursor pages, `filter[name_contains]`, `filter[status]`, `sort`), `POST /webhooks` (201), `GET /webhooks/:id`, `PUT` and `PATCH /webhooks/:id` (204), `DELETE /webhooks/:id`
* `GET /webhooks/:id/signing_secret` and `POST /webhooks/:id/signing_secret` (rotate)
* `POST /webhooks/test` sends a request and answers what the endpoint said. Without `webhook_id` it is signed with Zendesk's static test secret `dGhpc19zZWNyZXRfaXNfZm9yX3Rlc3Rpbmdfb25seQ==`
* `GET /webhooks/:id/invocations` and `GET /webhooks/:id/invocations/:invocation_id/attempts`

A webhook subscribes either to `conditional_ticket_events` (trigger notifications) or to event types: `zen:event-type:ticket.created`, `ticket.status_changed`, `ticket.priority_changed`, `ticket.comment_added`, `ticket.tags_changed`, `ticket.agent_assignment_changed`, `ticket.custom_field_changed`, `ticket.soft_deleted`, `ticket.csat_received`, `user.created`, `organization.created`, and the other ticket, user, and organization events. Event bodies have Zendesk's shape (`type`, `subject: "zen:ticket:{id}"`, `detail`, `event`, `account_id`, `time`, `zendesk_event_version: "2022-11-06"`).

Each request carries `X-Zendesk-Webhook-Id`, `X-Zendesk-Webhook-Invocation-Id`, `X-Zendesk-Account-Id`, `X-Zendesk-Webhook-Signature-Timestamp`, and `X-Zendesk-Webhook-Signature`, which is `base64(HMAC-SHA256(signing_secret, timestamp + raw_body))`:

```ts theme={null}
import { createHmac } from "node:crypto";

const timestamp = req.headers["x-zendesk-webhook-signature-timestamp"];
const expected = createHmac("sha256", signingSecret).update(timestamp + rawBody).digest("base64");
const valid = expected === req.headers["x-zendesk-webhook-signature"];
```

A 409 answer is retried three times; a 429 or 503 with `Retry-After` of 60 seconds or less is retried after that delay.

## Seed config

```json theme={null}
{
  "zendesk": {
    "subdomain": "acme",
    "password_access": true,
    "solved_close_after_ms": 345600000,
    "rate_limit": 700,
    "owner": {
      "name": "Admin",
      "email": "admin@example.com",
      "password": "admin-password"
    },
    "groups": [
      {
        "name": "Tier 2"
      }
    ],
    "agents": [
      {
        "name": "Alex Agent",
        "email": "alex@example.com",
        "groups": ["Support"],
        "password": "alex-password"
      },
      {
        "name": "Ada Admin",
        "email": "ada@example.com",
        "role": "admin"
      }
    ],
    "organizations": [
      {
        "name": "Globex",
        "domain_names": ["globex.example"],
        "tags": ["enterprise"]
      }
    ],
    "end_users": [
      {
        "name": "Casey Customer",
        "email": "casey@globex.example"
      }
    ],
    "ticket_fields": [
      {
        "title": "Plan",
        "type": "tagger",
        "options": [
          {
            "name": "Free",
            "value": "plan_free"
          },
          {
            "name": "Pro",
            "value": "plan_pro"
          }
        ]
      },
      {
        "title": "Order number",
        "type": "integer"
      }
    ],
    "api_tokens": [
      {
        "token": "baseline-zendesk-api-token",
        "description": "Local development"
      }
    ],
    "oauth_clients": [
      {
        "name": "My app",
        "identifier": "my_app",
        "secret": "my-app-secret",
        "redirect_uris": ["http://localhost:3000/zendesk/callback"]
      }
    ],
    "oauth_tokens": [
      {
        "token": "baseline-zendesk-oauth-token",
        "user": "alex@example.com",
        "scopes": ["read", "write"],
        "client": "my_app"
      }
    ],
    "webhooks": [
      {
        "name": "Ticket events",
        "endpoint": "http://localhost:3000/zendesk/webhooks",
        "subscriptions": ["zen:event-type:ticket.created", "zen:event-type:ticket.status_changed"],
        "signing_secret": "baseline-zendesk-secret"
      }
    ],
    "triggers": [
      {
        "title": "Tag urgent tickets",
        "conditions": {
          "all": [
            {
              "field": "priority",
              "operator": "is",
              "value": "urgent"
            }
          ]
        },
        "actions": [
          {
            "field": "current_tags",
            "value": "escalated"
          }
        ]
      }
    ],
    "views": [
      {
        "title": "Urgent",
        "all": [
          {
            "field": "priority",
            "operator": "is",
            "value": "urgent"
          }
        ]
      }
    ],
    "tickets": [
      {
        "subject": "Printer is on fire",
        "comment": "The printer in room 4 is smoking.",
        "requester": "casey@globex.example",
        "assignee": "alex@example.com",
        "group": "Support",
        "priority": "high",
        "tags": ["hardware"],
        "custom_fields": [
          {
            "title": "Plan",
            "value": "plan_pro"
          }
        ],
        "comments": [
          {
            "author": "alex@example.com",
            "body": "Looking into it.",
            "public": false
          }
        ]
      }
    ]
  }
}
```

Every account starts with the owner (`Admin <admin@example.com>` unless `owner` says otherwise), a `Support` group, the system ticket fields, and the default views. Seeded tickets do not run triggers or send webhooks. `csat: false` turns satisfaction ratings off.

## Inspector

`GET /_inspector` shows tickets (open one for its comments and JSON), users, organizations and groups, views and triggers, webhooks with their recent invocations, and API tokens and OAuth clients and tokens.

## Limits

The Help Center, Chat, Talk, Sell, macros, automations other than closing solved tickets, SLAs, ticket forms, the Requests API, attachments and uploads, incremental exports, side conversations, and the email channel.

## Conformance

| | |
| - | - |
| Routes | 169 supported or partial, 0 planned |
| Route gate | fail |
| Spec gate | no public spec |
| SDKs tested | `node-zendesk@6.0.1` |
| Dual mode | not yet |

See [CONFORMANCE.md](https://github.com/deploybaseline/cli/blob/main/CONFORMANCE.md) for every provider.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.