# Support ↔ Zammad contract The minimum the Support context relies on from Zammad's REST API. Zammad owns the API; this records the slice we use and what we expect back. Verified against Zammad 7.2.1 (Helm chart 19.1.3) on 7 October 2026 by `contract_test.go`, the agent-only token of decision 0063 on 9 October 2026, and the development Zammad after its database was replaced by a copy of the team's production Zammad (groups, states and required fields of the real support teams) on 13 October 2026. ## Setup Zammad must have **The profile.** `gitops/manifests/pharos/base/beto/zammad.env` names, exactly as Zammad shows them, what this setup refers to: the intake group (`ZAMMAD_GROUP`), the groups a Pharos ticket may be moved to (`ZAMMAD_FOLLOW_GROUPS`), the value of Zammad's required ticket field `type` (`ZAMMAD_TICKET_TYPE`), the state a user's close sets (`ZAMMAD_CLOSE_STATE`), the states Pharos shows as closed (`ZAMMAD_CLOSED_STATES`) and the integration role (`ZAMMAD_ROLE`). Beto reads the first five from its `beto-zammad` ConfigMap and refuses to start without them. `gitops/manifests/zammad/chart/desk/setup-integration.py` applies the profile to Zammad, and with `--check` reports every difference without changing anything. Run the check after any change in Zammad's groups, states or fields, and after replacing Zammad's database (`gitops/manifests/zammad/README.md`). - A custom user attribute named **`pp_user_uuid`** (type text, max 64). Zammad rejects custom attribute names ending in `_id` or `_ids`, so `pp_user_id` is impossible. Agents and admins see it read-only on a customer's profile; nobody can edit it in Zammad's forms, because a changed value would make Beto create a second customer. (A `-all-` screen entry applies to everyone in Zammad, so the profile view names the permissions instead.) - A custom ticket attribute named **`pp_ticket_uuid`** (type text, max 64, hidden in the agent screens): the Pharos ticket ID, set by Beto when it files a ticket. The agent token can set it on create although agents do not see it, and a `condition` search finds it at once. - A **webhook and trigger "Pharos ticket sync"** (iteration 2 phase 2), with the shared secret from Beto's Kubernetes secret, never printed. See "Webhook". - **Restart Zammad's web and background processes after adding an attribute.** Until then Zammad silently drops it on create and update. The setup does it. - **States are looked up by exact name.** Zammad answers 422 for a close to `closed` when the state is called `Closed`. The team's Zammad names its states New, Open / In Progress, On Hold / Pending, Resolved (pending, closes itself later), Reopened, Closed, Cancelled and merged. - **Required fields apply to the API.** A ticket created through the API passes the same core-workflow checks as the agent's create screen: without `type` Zammad answers 422 "Missing required value for field 'type'!". - Elasticsearch: Zammad only searches custom attributes through it. Give it at least a 1 GB heap in a 2 GiB container; at 512 MB in 1 GiB it was killed for memory again and again, and searches timed out. - **An integration user that is an agent, not an administrator** (decision 0004). It holds one role with `ticket.agent` and `user_preferences.access_token` (only to create its own token). The role's group access is the list of Pharos groups: **read, create and change** on the intake group (`ZAMMAD_GROUP`), **read and change** on every group a Pharos ticket may be moved to (`ZAMMAD_FOLLOW_GROUPS`). No `overview`, no `full`. - **Email (accepted, 13 October 2026).** The team's groups all send email as the service desk address, and staff move resolved tickets back to the intake group, so Beto needs change access to groups with an email address. A stolen token could therefore send email as the service desk. Beto itself cannot: its route table allows only web messages. Rotate the token as below; a group without an email address would remove the risk. - Beto's API token, created by that user with `ticket.agent` only, expiring after about 100 days. Rotate it every 90 days, and at once on a suspected leak. - Never `admin.user`: it allows impersonating any user, Admins included, and assigning any role, so it amounts to full administration. ## Requests Every request sends `Authorization: Token token=` and `Accept: application/json`. The adaptor refuses anything not listed under [Allowed requests](#allowed-requests). | Purpose | Request | Fields we send | We read back | |---|---|---|---| | Find by Pharos user | `GET /api/v1/users/search?query=pp_user_uuid:""&limit=50` | `query`, `limit` | 200, a JSON array of users | | Find by email | `GET /api/v1/users/search?query=&limit=50` | `query`, `limit` | 200, a JSON array of users | | Create | `POST /api/v1/users` | `firstname`, `lastname`, `email`, `roles: ["Customer"]`, `pp_user_uuid` | 201, the user | | Read one customer | `GET /api/v1/users/` | | 200, the user (from the database, not the search index) | | Claim | `PUT /api/v1/users/` | `pp_user_uuid` only | 200, the user, now carrying the Pharos user's `pp_user_uuid`; other fields unchanged | | Update contact | `PUT /api/v1/users/` | `firstname`, `lastname`, `email` only | 200, the user, still carrying the Pharos user's `pp_user_uuid` | | Allow or stop sign-in | `PUT /api/v1/users/` | `active` (`true` or `false`) only | 200, the user, still carrying the Pharos user's `pp_user_uuid` | From a user we read only `id` (number), `email` and `pp_user_uuid` (string or `null`). The response carries many more fields; we ignore them. Claim, update contact and allow/stop sign-in **read the customer first** and send the `PUT` only when it still belongs to that Pharos user (for Claim: no Pharos user yet, or this one). A customer re-pointed by hand, or claimed by another Pharos user since the search, gets no write: the adaptor returns `Rejected`. Zammad cannot make the `PUT` conditional, so the answer to the `PUT` is checked the same way; a change made by hand between the read and the write is reported as `Rejected` too. Verified on Zammad 7.2.1 (dev-iasonas2, 27 October 2026): a user `GET` carries only a weak `ETag` (a hash of the body, honoured for `If-None-Match`), but a `PUT` with a stale `If-Match` or a stale `updated_at` still answers 200. Verified with the agent-only token on Zammad 7.2.1 (dev-iasonas, 23 October 2026): - **Agents may change a customer's email, names and `active`.** Writing the same values again answers 200 and changes nothing. - **An email held by another user answers 422 "already used"**, whatever its letter case. From Create this is a race; from Update contact it is a conflict a person must resolve. - **`active: false` only stops the customer signing in to Zammad.** Their tickets stay; Beto can still file tickets for them, post to and read their tickets, and find them by email; Zammad still emails them agents' replies and its trigger notifications. `active: true` restores sign-in. ## Behaviour we rely on - **Search is fuzzy.** A 200 may contain other users; the adaptor keeps exact matches only (Pharos user ID exactly, email case-insensitively). An empty array means "not found". Two exact matches are treated as rejected data. - **Search lags behind writes by about 5 to 10 seconds** while Elasticsearch indexes. A customer created moments ago is not found yet. The flow converges: a repeated create gets "already used", RabbitMQ retries, and the cron retries after T if those retries run out. ## Answers and what Support does with them | Zammad answer | Example body | Adaptor result | Link | |---|---|---|---| | 2xx | | success | linked | | 422 duplicate email | `{"error":"Email address 'x' is already used for another user."}` | `ErrEmailTaken` | stays pending; policy runs again | | 422 other | `{"error":"Invalid email 'not-an-email'"}` | `Rejected` | conflict | | 400 | | `Rejected` | conflict | | 401 or 403 | `{"error":"Authentication required"}` (no or bad token: 403) | `Unreachable` | failed, cron retries, 24h alert | | 429 or 5xx, timeout, connection error | | `Unreachable` | failed, cron retries | | 404 and anything else | `{"error":"Couldn't find User with 'id'=\"999999\""}` | plain error | RabbitMQ retries | Error bodies have `error` and `error_human`; the adaptor prefers `error_human`. ## Tickets (decision 0063, iteration 2) Ticket requests are made **as the integration agent, for one customer**. The token reaches every ticket in the Pharos groups, so **Beto keeps customers apart**: every request is pinned to the linked customer (see Allowed requests), and every ticket Zammad returns is checked for that customer before it is shown or changed. Nothing acts on behalf of anyone; Zammad refuses `From` and `X-On-Behalf-Of` without `admin.user`. | Purpose | Request | We read back | |---|---|---| | Find filed | `POST /api/v1/tickets/search?expand=true&per_page=10&page=1` with `condition {"ticket.customer_id": {operator: "is", pre_condition: "specific", value: [id]}, "ticket.pp_ticket_uuid": {operator: "is", value: }}` | the ticket an earlier attempt filed, or `[]`; answered from the database, so no index lag | | File | `POST /api/v1/tickets?expand=true` with `title`, `group` (the intake group), `type` (the profile's ticket type), `customer_id`, `pp_ticket_uuid`, `article {subject, body, type: "web", internal: false, content_type: "text/plain", sender: "Customer", origin_by_id}` | 201: `id`, `number`, `title`, `state`, `customer_id`, `pp_ticket_uuid`, `created_at`, `updated_at`; then Messages gives the opening article (lowest ID) | | List | `POST /api/v1/tickets/search?expand=true&per_page=100&page=1` with `condition {"ticket.customer_id": {operator: "is", pre_condition: "specific", value: [id]}}`, `sort_by: updated_at`, `order_by: desc` | a JSON array of that customer's tickets in the Pharos groups | | Open | `GET /api/v1/tickets/?expand=true` | the ticket; **403** outside the Pharos groups | | Close | `GET /api/v1/tickets/?expand=true`, then `PUT /api/v1/tickets/?expand=true` with **only** `{"state": }` | 200: the ticket in the close state | | Post | `GET /api/v1/tickets/?expand=true` (must be open), then `POST /api/v1/ticket_articles?expand=true` with `ticket_id`, `body`, `type: "web"`, `internal: false`, `content_type: "text/plain"`, `sender: "Customer"`, `origin_by_id` | 201: the article, `sender: Customer` | | Messages | `GET /api/v1/ticket_articles/by_ticket/?expand=true` | `id`, `sender` (`Customer`/`Agent`/`System`), `from` (`Name` or `Name `), `internal`, `content_type`, `body`, `created_at` | | Re-read (sync) | Open, then Messages | the ticket's `state` and `updated_at`, and its articles in ID order; 403/404 or another customer means "not found" | | Access check | `GET /api/v1/users/me` (`role_ids`), `GET /api/v1/groups` (`id`, `name`), `GET /api/v1/roles/` (`group_ids`) | whether a role of the integration user still reads the intake group; asked only after a "not found" | Verified behaviour: - **Listing has no search index lag.** A `condition` without a text query is answered from Zammad's database, filtered by the token's read access; a new ticket is listed at once. - **Messages are credited to the customer.** With `sender: Customer` and `origin_by_id`, `from` is the customer (`Name `); `created_by` is the integration user. - **Groups.** The token creates tickets only in the intake group (403 elsewhere). Zammad's own triggers may move a ticket (the team's moves every Resolved ticket back to the intake group), so the intake group must stay among the Pharos groups. Moved between Pharos groups (L2 → L3 → L2) a ticket stays readable, listed, and open to replies and closing. Moved by staff to any other group it answers 403 and leaves the list; Beto reports it as not found. The token cannot move a ticket out of the Pharos groups. - **Zammad's own triggers add messages.** The team's Zammad answers every new ticket with a public auto reply (`sender: System`, type `email`) and adds internal emails to staff and to the customer (13 October 2026). They arrive from `System`, never as `Agent`, and Support shows only agents' public messages, so a Pharos user never sees them in Pharos (the customer may still receive Zammad's emails). - **Internal notes arrive in every conversation** (`internal: true`), because Beto reads as an agent; acting as the customer, Zammad used to remove them. Beto's filter is now the only thing hiding them from customers. - **Limits.** The token cannot act on behalf of anyone (`From` answers 403), cannot give a user the Admin role (Zammad ignores roles from agents), cannot change staff accounts (403; agents change customers only), and cannot send email from a group without an email address (422). It can from a group with one (see Email above). - Agent messages are `text/html` with scripts already removed (Beto converts them to text). A `state` named in `ZAMMAD_CLOSED_STATES` counts as closed; every other state is open, so the user may still answer a Resolved ticket. - Closing a closed ticket answers 200 and changes nothing; Zammad adds no message. Agents may reply to closed tickets, which reopens them; **Support does not post to closed tickets**: such a message fails. Filing, posting, closing and every read (the read before a post or a close, the re-read, the access check) retry timeouts, connection errors, 429 and 5xx within a budget (filing 20 s, posting 15 s, reading 10 s, closing 5 s; 5 s per attempt; 2 s for a close). A ticket Beto cannot read is hidden from its user (withdrawn) only when the access check says the integration user can still read the intake group. Group access is granted per role, so `users/me` alone shows none (verified on Zammad 7.2.1, 12 October 2026). If the role has lost the group, nothing is hidden, Beto logs a warning and the sync is treated as unanswered: a broken role must not hide every user's tickets. Filing first looks for the ticket by `pp_ticket_uuid`, so a timed-out filing is found rather than filed twice. A timed-out post is sent again and may leave a duplicate message (accepted, 12 October 2026). 400 and 422 are not retried. ## Webhook (iteration 2 phase 2) Zammad tells Beto that a Pharos ticket changed; Beto then re-reads it (Re-read above). The notice is only a hint, so nothing in it is trusted beyond the ticket ID. | Item | Value | |---|---| | Trigger | "Pharos ticket sync": on every create and update (`activator: action`, `execution_condition_mode: always`) of a ticket whose `pp_ticket_uuid` matches `^[0-9a-f-]{36}$` | | Webhook | `POST http://beto..svc.cluster.local:8081/v1/zammad/ticket-changed`, custom payload `{"pharosTicketId": "#{ticket.pp_ticket_uuid}"}` | | Signature | `X-Hub-Signature: sha1=` with the shared `ZAMMAD_WEBHOOK_SECRET` (Zammad offers SHA-1 only; as an HMAC it is still sound) | | Delivery | `X-Zammad-Delivery`: the job ID, the same on Zammad's retries. Beto derives the sync request's command ID from it, so a repeated notice records nothing new | | Retries | Zammad tries 5 times, 10 s, 20 s, 30 s and 40 s apart, and counts any 2xx as success. After that the reconcile job (every 5 minutes) catches up | | Answers | 202 recorded; 200 ignored (no Pharos ID, or a ticket Pharos does not know); 401 bad signature; 400 malformed; 413 over 16 KiB; 503 Beto could not record it (Zammad retries) | **Replay.** The signature covers the body, not `X-Zammad-Delivery`, so a captured notice could be sent again under new delivery IDs; each one records a sync request and costs a few Zammad reads. Only the `zammad` namespace can reach the port. Accepted (12 October 2026): merging notices that arrive close together could hide a real change until the reconcile job runs. Verified in Zammad 7.2.1's source (`app/jobs/trigger_webhook_job.rb`, `lib/user_agent.rb`, 12 October 2026): the signature covers the request body, the delivery header is the ActiveJob ID, and the retry schedule above. The webhook port accepts traffic from the `zammad` namespace only (`beto-zammad-ingress`) and serves nothing but this route. ## Allowed requests Enforced by `routes.go` (decision 0063): the adaptor refuses every other request before it reaches the network, and a unit test keeps this table and the code identical. `{id}` is a numeric ID. Nested body fields are joined with `/`. A field written `name = value` must be present with exactly that JSON value, in which `` is the linked customer the request is for, and `` and `` are the profile's `ZAMMAD_TICKET_TYPE` and `ZAMMAD_CLOSE_STATE`. "For a customer" means the request names one customer, whose numeric ID is required. | Method | Path | Query | Body fields | For a customer | |---|---|---|---|---| | GET | `/api/v1/users/search` | `query`, `limit` | | no | | POST | `/api/v1/users` | | `firstname`, `lastname`, `email`, `pp_user_uuid`, `roles = ["Customer"]` | no | | GET | `/api/v1/users/{id}` | | | no | | PUT | `/api/v1/users/{id}` | | `pp_user_uuid` | no | | PUT | `/api/v1/users/{id}` | | `firstname`, `lastname`, `email` | no | | PUT | `/api/v1/users/{id}` | | `active` | no | | POST | `/api/v1/tickets` | `expand` | `title`, `group`, `pp_ticket_uuid`, `type = ""`, `customer_id = ""`, `article/subject`, `article/body`, `article/type = "web"`, `article/internal = false`, `article/content_type = "text/plain"`, `article/sender = "Customer"`, `article/origin_by_id = ""` | yes | | POST | `/api/v1/tickets/search` | `expand`, `per_page`, `page` | `condition/ticket.customer_id/operator = "is"`, `condition/ticket.customer_id/pre_condition = "specific"`, `condition/ticket.customer_id/value = [""]`, `sort_by = "updated_at"`, `order_by = "desc"` | yes | | POST | `/api/v1/tickets/search` | `expand`, `per_page`, `page` | `condition/ticket.customer_id/operator = "is"`, `condition/ticket.customer_id/pre_condition = "specific"`, `condition/ticket.customer_id/value = [""]`, `condition/ticket.pp_ticket_uuid/operator = "is"`, `condition/ticket.pp_ticket_uuid/value` | yes | | GET | `/api/v1/tickets/{id}` | `expand` | | yes | | PUT | `/api/v1/tickets/{id}` | `expand` | `state = ""` | yes | | POST | `/api/v1/ticket_articles` | `expand` | `ticket_id`, `body`, `type = "web"`, `internal = false`, `content_type = "text/plain"`, `sender = "Customer"`, `origin_by_id = ""` | yes | | GET | `/api/v1/ticket_articles/by_ticket/{id}` | `expand` | | yes | | GET | `/api/v1/users/me` | | | no | | GET | `/api/v1/roles/{id}` | | | no | | GET | `/api/v1/groups` | | | no | Adding a request (attachments, for example) means a row here, a route in `routes.go`, and a contract test against a real Zammad. ## Checking it ```sh ZAMMAD_URL=http://127.0.0.1:18080 ZAMMAD_TOKEN_FILE=/private/zammad-token \ ZAMMAD_TEST_AGENT_LOGIN_FILE=/private/agent-login \ go test -tags=zammad -run TestContract ./contexts/support/adaptors/delivery/zammad/ ``` Run it against a test Zammad after any Zammad upgrade, permission change or change of the profile, after `gitops/manifests/zammad/chart/desk/setup-integration.py --check`. The test reads the profile from `gitops/manifests/pharos/base/beto/zammad.env`; a `ZAMMAD_*` variable overrides one setting. `ZAMMAD_TEST_AGENT_LOGIN` is `email password` of a human test agent with full access to the intake group, the follow groups (or `ZAMMAD_TEST_ESCALATION_GROUPS`) and one active group the integration may not see (`ZAMMAD_TEST_OUTSIDE_GROUP`, else the first such group Zammad lists). The agent adds replies and internal notes and moves tickets as support staff would. The test creates throwaway customers named `contract--*@example.org` and tickets for them, which stay in Zammad closed; Zammad's own triggers fire for them as for any ticket. It is not part of `pnpm verify`, because CI has no Zammad.