` / `` |
+
+**If you have Markdown content to post, render it to HTML first.** Do not paste raw Markdown into the `html` field — the result is unreadable bold-wrapped-asterisks like `**Heading**` → "\*\*Heading\*\*" in bold.
+
+If you only need plain text with auto-linkable URLs, use the `text` field instead of `html`.
+
+> **Don't be misled by HappyFox's "Markdown Support" feature** ([release note](https://headwayapp.co/happyfox-helpdesk-release-notes/markdown-support-while-adding-ticket-replies-79174)). It is a compose-box typing shortcut for human agents — type `**bold**` in the rich-text editor and the editor converts it to HTML as you type. It does **not** make the API or the ticket viewer render stored markdown. The `html` field is always treated as HTML; the `text` field as plain text. There is no markdown content-type or markdown-rendering toggle on the API side.
+
+
+### Add Staff Update (Reply)
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/staff_update/" \
+ -d '{
+ "staff": 1,
+ "html": "Thank you for contacting us. We are looking into this.
",
+ "update_customer": true,
+ "status": 2
+ }' | jq
+```
+
+**Required fields:**
+- `staff`: ID of the agent adding the reply
+
+**Optional fields:**
+- `html` or `plaintext`: Reply content
+- `update_customer`: `true`/`false` - send email notification to contact
+- `status`: Status ID to change ticket status
+- `priority`: Priority ID to change priority
+- `assignee`: Agent ID to reassign (use `null` for unassigned)
+- `time_spent`: Time spent in minutes
+- `cc`, `bcc`: Comma-separated email addresses
+- `tags`: Comma-separated tags
+
+**Important:** Setting `update_customer: false` only prevents email notification - the reply is still visible to the customer in the support portal. To create a truly private note invisible to customers, use the `/staff_pvtnote/` endpoint instead.
+
+### Add Private Note
+
+
+To create a private/internal note that is NOT visible to customers, you MUST use the `/staff_pvtnote/` endpoint.
+Do NOT use `/staff_update/` with `visible_only_staff` or `private` parameters — those parameters are silently ignored. `/staff_update/` **always** creates a customer-visible reply.
+
+**How to verify after posting:** the GET response distinguishes them via `message.message_type`:
+- `"p"` → private note (staff-only)
+- `null` → customer-visible reply
+
+If you ever post what was meant to be a private note and find `message_type: null` afterwards, you have leaked internal content to the customer. There is no edit/delete API to fix it (see "Write-safety" above) — you'll need staff-UI cleanup. Always sanity-check `message_type` on the next GET after posting sensitive internal content.
+
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/staff_pvtnote/" \
+ -d '{
+ "staff": 1,
+ "text": "Internal note: Customer is a VIP."
+ }' | jq
+```
+
+**Optional fields:**
+- `text` or `html`: Note content (use `text` for plain text, `html` for formatted)
+- `alert`: Who to notify about this private note
+ - `s`: Alert all ticket subscribers
+ - `c`: Alert all agents in ticket's category
+ - Agent ID (integer): Alert specific agent
+
+**Identifying private notes in responses:**
+When fetching ticket details, private notes have `"message_type": "p"` in the updates array, while regular staff replies have `"message_type": null`.
+
+### Update Ticket Properties Only
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/staff_update/" \
+ -d '{
+ "staff": 1,
+ "status": 4,
+ "priority": 3,
+ "assignee": 2
+ }' | jq
+```
+
+**Custom fields** can be updated via the same endpoint using the `t-cf-` form-field convention (same as ticket creation — see "Create a Ticket" above). For text fields pass the raw string; for choice (enumeration) fields pass the choice's numeric `id`:
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/staff_update/" \
+ -d '{
+ "staff": 1,
+ "t-cf-3": "FDE-50 — https://linear.app/all-hands-ai/issue/FDE-50/..."
+ }' | jq
+```
+
+
+**Side-effect to know about:** every `POST /staff_update/` creates an entry in the ticket's `updates[]` array, even when the payload contains no `html`/`text` body and no tracked-change field (status, priority, assignee, tags, due_date). Property-only and custom-field-only writes therefore leave a `message: null` audit row with all `*_change` fields set to `null`. Empirically verified — including for custom-field changes, which do **not** populate `custom_field_change` (that field stays `null`).
+
+Concretely, this no-op audit entry:
+
+- is invisible in the customer portal (no message body to render),
+- triggers no email (no body to send), regardless of `update_customer`,
+- still appears in the staff timeline as a from-staff event,
+- bumps the ticket's `last_updated_at`.
+
+If you need to set many properties or custom fields, batch them into a single `/staff_update/` call to avoid stacking multiple no-op rows. There is no `PATCH /ticket//` for silent property writes on v1.1.
+
+
+### Update Ticket Tags
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/update_tags/" \
+ -d '{
+ "add": "urgent, escalated",
+ "remove": "pending-review",
+ "staff_id": 1
+ }' | jq
+```
+
+### Move Ticket to Another Category
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/move/" \
+ -d '{
+ "staff_id": 1,
+ "target_category_id": 2,
+ "move_note": "Moving to appropriate department",
+ "assign_to": 3
+ }' | jq
+```
+
+### Delete Ticket
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/delete/" \
+ -d '{
+ "staff_id": 1
+ }' | jq
+```
+
+## Lookup Endpoints
+
+### List Categories
+
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/categories/" | jq
+```
+
+### List Staff/Agents
+
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/staff/" | jq
+```
+
+### List Statuses
+
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/statuses/" | jq
+```
+
+### List Priorities
+
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/priorities/" | jq
+```
+
+### List Ticket Custom Fields
+
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/ticket_custom_fields/" | jq
+```
+
+### List Contact Custom Fields
+
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/user_custom_fields/" | jq
+```
+
+## Working with Custom Fields
+
+Custom fields use a special format in payloads:
+- Ticket custom fields: `t-cf-`
+- Contact custom fields: `c-cf-`
+
+Get the ID from the custom fields list endpoints.
+
+**Value formats by type:**
+- Text: `"string value"`
+- Number: `123` or `123.45`
+- Dropdown: `` (integer)
+- Multiple choice: `[, ]`
+- Date: `"yyyy-mm-dd"`
+
+**Example with custom fields:**
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/tickets/" \
+ -d '{
+ "name": "Jane Smith",
+ "email": "jane@example.com",
+ "category": 1,
+ "subject": "Product inquiry",
+ "text": "I have questions about your product.",
+ "t-cf-1": "Enterprise",
+ "t-cf-3": 2,
+ "c-cf-5": "2024-12-01"
+ }' | jq
+```
+
+## Attaching Files to Updates
+
+HappyFox's v1.1 API accepts file attachments on **ticket creation**, **staff updates**, and **private notes**. The contract (per the [Tickets API docs](https://support.happyfox.com/kb/article/1039-tickets-endpoint/)):
+
+| Aspect | Value |
+|---|---|
+| Field name | `attachments` (plural — repeat for multiple files) |
+| Content-Type | `multipart/form-data` (required when attachments are present) |
+| Total size cap | 25 MB combined across all files in one request |
+| File type | No restrictions |
+| File encoding | HappyFox uses the file's declared encoding, else UTF-8 |
+| Supported endpoints | `POST /tickets/`, `POST /ticket//staff_update/`, `POST /ticket//staff_pvtnote/` |
+
+For inline images embedded in `html` (e.g. `
`), use the separate `POST /api/1.1/json/ticket-inline-attachment/` endpoint (single file, returns a temporary URL to drop into the `src` attribute).
+
+### Recipe: curl (preferred)
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -F "staff=1" \
+ -F "update_customer=true" \
+ -F "html=Attaching the patch for your review.
" \
+ -F "attachments=@/path/to/first.diff;type=text/x-diff" \
+ -F "attachments=@/path/to/second.log;type=text/plain" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/staff_update/" | jq
+```
+
+Two attachments above — repeat `-F "attachments=@..."` once per file. Each modifier (`;type=`, `;filename=`) is optional; HappyFox falls back to the OS-detected MIME type and the basename of the path.
+
+### Recipe: Python stdlib (fallback)
+
+Use this when `curl -F` fails with `(26) Failed to open/read local data from file/application` — see the caveat below. No third-party dependencies required.
+
+```python
+import base64, mimetypes, os, urllib.request, uuid
+from pathlib import Path
+
+API_KEY, AUTH_CODE = os.environ["HF_API_KEY"], os.environ["HF_AUTH_CODE"]
+BASE = os.environ["HF_BASE_URL"]
+
+TICKET = "TICKET_NUMBER"
+FILES = [Path("/path/to/first.diff"), Path("/path/to/second.log")]
+FIELDS = {"staff": "1", "update_customer": "true",
+ "html": "Attaching files for your review.
"}
+
+boundary = f"----HappyFox{uuid.uuid4().hex}"
+CRLF = "\r\n"
+body = b""
+for name, value in FIELDS.items():
+ body += (f"--{boundary}{CRLF}"
+ f'Content-Disposition: form-data; name="{name}"{CRLF}{CRLF}'
+ f"{value}{CRLF}").encode()
+for fp in FILES:
+ mime = mimetypes.guess_type(str(fp))[0] or "application/octet-stream"
+ body += (f"--{boundary}{CRLF}"
+ f'Content-Disposition: form-data; name="attachments"; filename="{fp.name}"{CRLF}'
+ f"Content-Type: {mime}{CRLF}{CRLF}").encode()
+ body += fp.read_bytes() + CRLF.encode()
+body += f"--{boundary}--{CRLF}".encode()
+
+req = urllib.request.Request(
+ f"{BASE}/api/1.1/json/ticket/{TICKET}/staff_update/", data=body, method="POST",
+ headers={
+ "Authorization": "Basic " + base64.b64encode(f"{API_KEY}:{AUTH_CODE}".encode()).decode(),
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
+ "Content-Length": str(len(body)),
+ })
+print(urllib.request.urlopen(req, timeout=30).read().decode())
+```
+
+### Verification
+
+Per "Write-safety" above, GET the ticket after posting and confirm the new update carries the files (HappyFox echoes `id` and `filename` for each attachment, plus a short-lived signed S3 `url` valid for ~5 minutes):
+
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/" | \
+ jq '.updates[-1] | {update_id, message_type,
+ attachments: [.message.attachments[]? | {id, filename, url}]}'
+```
+
+### Caveat: `curl -F` can fail with error 26 in containerized environments
+
+
+`curl -F "attachments=@/path/file"` has been observed to fail with
+`curl: (26) Failed to open/read local data from file/application`
+even on small, readable files in some containerized sandboxes (verified with
+curl 8.14.1 + libcurl/8.14.1 OpenSSL/3.5.5 on a Debian-derived image). The
+file opens fine with `cat`, `wc`, `ls`, and Python; only the libcurl
+multipart reader trips on it.
+
+If you hit this, **do not retry curl** — the failure is deterministic on
+that toolchain — fall through to the Python stdlib recipe above. The
+underlying root cause hasn't been pinned down; this is documented as an
+empirical workaround.
+
+
+## Status Behaviors
+
+| Behavior | Description |
+|----------|-------------|
+| `pending` | Ticket is open and active |
+| `completed` | Ticket is closed/resolved |
+
+## Sort Options for Ticket Lists
+
+| Sort Key | Description |
+|----------|-------------|
+| `created` | By creation date (descending) |
+| `createa` | By creation date (ascending) |
+| `updated` | By last update (descending) |
+| `updatea` | By last update (ascending) |
+| `priorityd` | By priority (descending) |
+| `prioritya` | By priority (ascending) |
+| `statusd` | By status (descending) |
+| `statusa` | By status (ascending) |
+| `due` | By due date |
+| `unresponded` | Unresponded tickets first |
+
+**Example:**
+```bash
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/tickets/?sort=priorityd" | jq
+```
+
+## End-to-End Workflow: Create and Update a Ticket
+
+### Step 1: Get required IDs
+
+```bash
+# Get category ID
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/categories/" | jq '.[0]'
+
+# Get staff ID for assignment
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/staff/" | jq '.[0]'
+
+# Get status IDs
+curl -s -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ "$HF_BASE_URL/api/1.1/json/statuses/" | jq
+```
+
+### Step 2: Create the ticket
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/tickets/" \
+ -d '{
+ "name": "Customer Name",
+ "email": "customer@example.com",
+ "category": 1,
+ "subject": "Need assistance",
+ "text": "Please help with my issue.",
+ "priority": 2,
+ "assignee": 1
+ }' | jq '.id, .display_id'
+# Save the ticket number from the response
+```
+
+### Step 3: Add a reply and update status
+
+```bash
+curl -s -X POST -u "$HF_API_KEY:$HF_AUTH_CODE" \
+ -H "Content-Type: application/json" \
+ "$HF_BASE_URL/api/1.1/json/ticket/TICKET_NUMBER/staff_update/" \
+ -d '{
+ "staff": 1,
+ "html": "Issue has been resolved. Please let us know if you need anything else.
",
+ "update_customer": true,
+ "status": 4
+ }' | jq '.status.name'
+```
+
+## Documentation
+
+- [HappyFox API Overview](https://support.happyfox.com/kb/article/360-api-for-happyfox/)
+- [Tickets API Endpoints](https://support.happyfox.com/kb/article/1039-tickets-endpoint/)
+- [Creating API Credentials](https://support.happyfox.com/kb/article/476-create-api-key-auth-code-happyfox/)