---
name: Syncro API plumbing — headers, endpoints, response shapes, idempotency
description: Technical mechanics for talking to the Syncro API — required Content-Type header, the no-idempotency rule (always GET before retry), response wrappers, the add_line_item endpoint shape, HTML rendering, and the (now historical) timer_entry response shape.
metadata:
  type: feedback
---

Rules only. Incident detail, verbatim quotes, ticket numbers, and dates live in [[feedback_syncro_history]] — read on-demand when judging an edge case. Billing/product rules: [[feedback_syncro_billing]]. Workflow rules: [[feedback_syncro_workflow]].

---

## 1. Content-Type header is required on every POST/PUT

Always include `-H "Content-Type: application/json"`. Without it, curl sends `application/x-www-form-urlencoded` and Syncro returns a **400 HTML page** (not JSON). Applies to comments, tickets, line items, estimates, updates.

Ticket comment payloads also need the `subject` field:
```json
{"subject":"...","body":"...","hidden":true,"do_not_email":true}
```

---

## 2. No idempotency — ALWAYS GET before retrying any POST

Syncro has **no idempotency on any endpoint**. One `POST` always creates one record, regardless of whether the client saw an error. A jq parse error, curl error, timeout, or weird-looking response does NOT mean the POST failed — verify first.

**Verification before retry:**
- Comments: `GET /tickets/{id}` and search `.ticket.comments[] | select(.subject == "…")`. Check ALL comments, not just `[-3:]`.
- Tickets: `GET /customers/{id}/tickets` before retrying.
- Line items: `GET /tickets/{id}` → `.ticket.line_items[]`.

**Response wrappers — CRITICAL for jq:**
- `POST /tickets` → `{"ticket": {...}}` → use `.ticket.id`.
- `POST /comment` → `{"comment": {...}}` → use `.comment.id`.

**Hardening:** Write payload JSON to a temp file (e.g. `tmp/syncro_comment.json`) before posting. Avoids shell quoting/encoding failures that masquerade as POST failures on requests that actually succeeded.

Comments cannot be deleted via API — duplicates require manual GUI removal.

---

## 3. HTML formatting in comments

Use `<br>` for line breaks. Do **NOT** use `<ul>` or `<li>` — Syncro's renderer collapses them into one line. For bulleted lists:
```html
<p>
- Item one<br>
- Item two<br>
- Item three
</p>
```

---

## 4. add_line_item endpoint

`POST /api/v1/tickets/{internal_ticket_id}/add_line_item`

- Path uses the **internal ticket ID** (e.g. `111387456`), NOT the ticket number (`32339`). Wrong-ID variants (`/line_item`, `/line_items`, `PUT line_items_attributes`) all 404.
- Required fields: `name`, `description`, `quantity`, `price`, `taxable` (and `product_id` for catalog items). Missing `name`/`description` → 422.
- Response is **flat** — parse `.id` directly (no wrapper).

Example:
```bash
curl -X POST "$BASE/tickets/111387456/add_line_item?api_key=$KEY" \
  -H "Content-Type: application/json" \
  -d '{"product_id":1049360,"name":"Labor- Warranty work","description":"…","quantity":1,"price":0.0,"taxable":false}'
```

For ad-hoc API testing, use the internal ACG account only (customer ID `15353550`).

---

## 5. Timer response — HISTORICAL / SUPERSEDED

> Timers are no longer part of the ACG Syncro billing workflow (as of 2026-05-21 — see [[feedback_syncro_billing]] and [[feedback_syncro_history]]). Kept here for the rare manual-timer case.

`POST /tickets/{id}/timer_entry` returns a **FLAT** object — parse `.id`, NOT `.timer.id // .timer_entry.id` (both resolve to `null` and break `charge_timer_entry`, which can trigger a retry → duplicate). `charge_timer_entry` response is also flat: use `.ticket_line_item_id`.

Authoritative timer list for a ticket: `GET /tickets/{id}` → `.ticket.ticket_timers[]`. The standalone `/ticket_timers?ticket_id=N` returns global history, not filtered.

Cleanup duplicates: `POST /tickets/{id}/delete_timer_entry` with `{"timer_entry_id": N}`.
