Skip to main content
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 client (6.0.1).

At a glance

Start

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 <[email protected]>, a Support group, the system ticket fields, and the default views. Point node-zendesk at the emulator with endpointUri:

URL mapping

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

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. 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)):
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

Every account starts with the owner (Admin <[email protected]> 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

See CONFORMANCE.md for every provider.