# Zendesk

Source module: `app/connectors/zendesk.py`. Kind: **facts**. Display name: "Zendesk".

## Purpose

Pulls tickets, ticket audits and satisfaction ratings as **facts**: the evidence the `zendesk_ai_automated_resolution` contract checks claims against. Zendesk AI agents bill per automated resolution, but the automated-resolutions report is not exposed through the API; it is downloaded from Zendesk and uploaded as the claims of a reconciliation (`POST /v1/reconciliations/{id}/claims`). This connector never produces claims.

## Auth and scopes

| Item | Value |
|---|---|
| Method | Preferred: an OAuth client access token sent as `Authorization: Bearer` (Admin Center → Apps and integrations → APIs → OAuth clients; grant read scopes only). Until 2027-04-30 also: API token with HTTP Basic auth, `base64("{email}/token:{api_token}")`. Both against `https://{subdomain}.zendesk.com`. Zendesk retires API tokens on 2027-04-30; connections on an API token should move to an OAuth token before then by re-authenticating with the new credential. |
| Credentials stored (encrypted) | `access_token`, or `email` + `api_token`; `subdomain` may be stored either in credentials or in non-secret `config`; optional `base_url` |
| Least-privilege scopes | A dedicated read-only agent whose token grants **`tickets:read`**, **`ticket_audits:read`**, **`satisfaction_ratings:read`**, **`users:read`** (used only to obtain the requester's email for hashing and the audit author's role). No write scopes. |
| Connection test | `GET /api/v2/users/me.json` (name and role recorded in `health`) |
| Non-secret config | `subdomain` |

## Fields pulled and why

| Field | Used for |
|---|---|
| `ticket.id`, `created_at`, `updated_at`, `status` | Subject id, opening message timestamp, watermark |
| `ticket.via.channel` | Channel for exclusions |
| `ticket.requester_id` → `users[].email` | HMAC-hashed into `customer_hid` |
| `ticket.satisfaction_rating.score` | CSAT fact (`good`/`bad`) |
| `audit.id`, `created_at`, `author_id` → `users[].role` | Fact timestamps and actor role (`end-user`, `agent`, `admin`, `system`) |
| `audit.events[]` of type `Comment` (`public` flag) | Customer message vs human reply |
| `audit.events[]` of type `Change` on `status` (`previous_value`, `value`) | Status changes and reopens |
| `audit.events[]` of type `Change` on `assignee_id` | Escalation |

Comment bodies are not stored.

## What is hashed / never stored

- Requester email → `customer_hid` (HMAC-SHA256, per-tenant key); the email is discarded.
- Comment bodies, requester names, phone numbers, custom fields, tags and internal notes are not persisted (`public = false` comments are skipped entirely).
- The API token is stored only as ciphertext.

## Incremental sync and watermark

- Tickets: cursor-based incremental export `GET /api/v2/incremental/tickets/cursor.json?start_time=<watermark epoch>&include=users`, following `after_url` until `end_of_stream`.
- For each ticket the connector fetches `GET /api/v2/tickets/{id}/audits.json` (one request per ticket).
- Watermark = maximum ticket `updated_at`. Upserts are idempotent; re-syncing a ticket refreshes its facts.

## Rate-limit handling

Shared `HttpClient`: honors `Retry-After` on `429`, exponential backoff (`min(60, 2^attempt)` s) on `429`/`5xx`, 6 attempts, immediate `reauth` failure on `401`/`403`. Zendesk's incremental export is itself rate-limited to a small number of requests per minute; long backfills take proportionally long.

## Fact types produced

Subject kind: `ticket`. Every fact carries `customer_hid` and `channel`.

| `fact_type` | When | Extra attributes | `raw_ref` |
|---|---|---|---|
| `customer_message` | Ticket creation (`part: "description"`); public `Comment` event whose author role is `end-user` | — | `zendesk:ticket:{id}:created`, `zendesk:audit:{id}:{audit_id}:{i}` |
| `human_reply` | Public `Comment` event whose author role is `agent` or `admin` | `author: "agent"` | `zendesk:audit:…` |
| `status_change` | `Change` event on `status` | `from`, `to` (raw Zendesk statuses, e.g. `open`, `pending`, `solved`, `closed`), `actor: customer|system|agent` | `zendesk:audit:…` |
| `reopen` | Status change from `solved`/`closed` to `open`/`pending`/`new` | `by: <role>` | `zendesk:audit:…:reopen` |
| `escalation` | `Change` event on `assignee_id` with a non-empty value | `to: "human"` | `zendesk:audit:…` |
| `csat_response` | `satisfaction_rating.score` is `good` or `bad` | `score: 1|0`, `scale: 1`, `sentiment: positive|negative` | `zendesk:csat:{id}` |

## Claims

None. Export the automated-resolutions report from Zendesk and upload it to the reconciliation for the period; map the ticket id column to `subject_id` as `zendesk_ticket_{ticket_id}` and the resolution timestamp to `claimed_at`. The mapping is remembered for the vendor.

## Subject id format

`zendesk_ticket_{ticket_id}` for both claims and facts. Internal-agent spans that refer to Zendesk tickets should use the same format (see `docs/otel-convention.md`).

## Known limitations

- Claims come only from the uploaded automated-resolutions report; Zendesk offers no API for it.
- The satisfaction rating carries no timestamp in the ticket payload; the connector uses the ticket's `updated_at`.
- One audits request per ticket (N+1); very large backfills are slow.
- Assignment to a group without an assignee is not treated as an escalation; assignment from one agent to another is.
- Comment bodies are not stored, so transcript assist cannot use Zendesk data unless a CSV with `keep_text` is uploaded.
