# /syncro — Syncro PSA ticket management

Create, update, close, comment on, and bill tickets in Syncro PSA.

## Usage

```
/syncro                                  Show open tickets summary
/syncro ticket <number>                  View ticket details + comments
/syncro create <customer> <subject>      Create new ticket
/syncro update <number> <status>         Update ticket status
/syncro close <number>                   Close/resolve a ticket
/syncro comment <number> <text>          Add a comment to a ticket
/syncro bill <number>                    Add billable time and create invoice
/syncro search <query>                   Search tickets by subject/customer
/syncro customers <query>                Search customers
/syncro move-appointment <customer>      Find and reschedule an existing appointment
```

## API Configuration

**Base URL:** `https://computerguru.syncromsp.com/api/v1`
**API Key:** per-user tokens in SOPS vault — see "Get API key" below
**Rate limit:** 180 requests/minute per IP
**Docs:** https://api-docs.syncromsp.com/

## Hard Rules (violations have occurred — no exceptions)

**All work-time billing MUST go through `timer_entry → charge_timer_entry`.** Bare `add_line_item` for time-bearing work bypasses Syncro's time tracking and breaks reporting (hours per client, tech productivity, prepay burn). Bare `add_line_item` is reserved for non-time items only (hardware, flat-fee services). Even warranty/free work needs a time entry — set `billable: false`. Only cancelled tickets are exempt. Mike caught the bare-`add_line_item` bug across 31 tickets on 2026-04-30; it was repeated on 3 more tickets on 2026-05-01 — see `.claude/memory/feedback_syncro_timer_first.md`.

**JSON payloads to curl: use heredoc with `--data-binary @-`, not `/tmp/*.json` files.** On Windows the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so a payload written by Write may not be the file curl reads. Heredoc with `<<'JSON'` (single-quoted to suppress bash variable expansion inside the payload) avoids the file handoff entirely. See `.claude/memory/feedback_tmp_path_windows.md` — caused a wrong-comment incident on ticket #32225 on 2026-05-01 (rogue payload from a prior session).

**Before any POST:** Always show the full payload to the user and wait for explicit confirmation. This applies to tickets, comments, line items, and invoices — including hidden/internal notes.

**After any ambiguous POST result** (null fields, jq error, curl error, timeout): Do NOT retry. GET the resource first to confirm whether the action succeeded. Syncro has no idempotency on any endpoint — one POST always creates one record. Duplicate tickets and comments cannot be deleted via API; comments require manual GUI removal.

**Ticket response shape:** `{"ticket": {...}}` — always use `.ticket.id`, never `.id`. The flat-object jq pattern silently returns nulls and looks like failure when it isn't.

**Billing:** Always ask for minutes and labor type before adding any line item. Never assume a default.

**Emergency/after-hours billing — check prepaid first:** Before adding a `26184` (Emergency) line item, `GET /customers/<id>` and read `prepay_hours`. If `prepay_hours > 0`, the customer has a prepaid block — bill `26118` (Onsite) at `quantity × 1.5` instead (prepaid debits by quantity, not by dollars). Never stack `26118` + `26184` for the same hours — the Emergency product rate already has the 1.5× multiplier baked in. Verified 2026-04-23 on ticket #32203 (Desert Auto Tech) after Winter caught the bug.

**Prepaid customers — ALL billing (not just emergency):** `GET /customers/<id>` → `prepay_hours` before creating ANY invoice for a prepaid customer. When you bill a prepaid customer using a billable labor product (remote / onsite / in-shop / web), Syncro automatically deducts from their prepay block and the invoice total shows $0.00. The line item name is annotated "- Applied X Prepay Hours". This is correct behavior — do NOT treat a $0.00 invoice as an error. Verify the deduction by re-fetching `customer.prepay_hours` after invoicing and confirming it dropped by `quantity`.

**`9269129` (Labor - Prepaid Project Labor) is EXEMPT — it does NOT deduct from prepay blocks:** Despite the name, this product is categorized as Exempt Labor at $0.00 and contains no prepay-deduction logic. Billing a prepaid customer with this product results in a $0.00 invoice AND no block decrement — silent accounting drift. Discovered 2026-05-04 (see `feedback_syncro_labor_type.md`). NEVER use `9269129` for normal or prepaid work. Only use it if explicitly directed. The correct approach for prepaid customers is a billable labor product matching the delivery channel (remote / onsite / in-shop / web).

**Line-item `price_retail` MUST be set explicitly:** Earlier guidance to "omit `price_retail` and let Syncro auto-calc from the product rate" was wrong — the rate does NOT populate automatically. Fetch it with `GET /products/<id>` → `.product.price_retail` and pass it on `add_line_item`. Omitting it leaves the line at $0.00 and the invoice posts at $0.00 (verified 2026-04-23 on #32203).

## Implementation

When invoked, use the Syncro REST API via `curl`. All requests include `?api_key=<key>` as query parameter (NOT in header — Syncro uses query param auth).

### Attribution rule (CRITICAL)

Every Syncro API call is attributed to the **owner of the API key**. Comments, line items, timer entries, and invoices created by the API are logged as the API user — regardless of who is running the command. So the skill MUST use a per-user API key that matches the actual tech running it, or comments will be misattributed.

| identity.json user | Syncro user | user_id |
|---|---|---|
| `mike` | Michael Swanson | 1735 |
| `howard` | Howard Enos | 1750 |

Keys are baked into the skill below. To add a new user: generate a token in Syncro → Admin → API Tokens, add a case to the key-select block, and store a backup copy in the vault at `msp-tools/syncro-<user>.sops.yaml`.

### Get API key

```bash
BASE="https://computerguru.syncromsp.com/api/v1"

# Per-user keys — actions in Syncro are attributed to the key owner
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
case "$USER_ID" in
  mike)   API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
  howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
  *)      echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
esac
```

### Ollama drafting

Ollama handles prose drafting for write operations. Claude reviews the output against the hard rules below, then presents a preview. User confirms. Claude executes.

**Availability check** — run once at the start of any write operation, reuse `$OLLAMA` for the rest of the session:

```bash
if curl -s -m 2 http://localhost:11434/api/tags >/dev/null 2>&1; then
    OLLAMA="http://localhost:11434"
elif curl -s -m 3 http://100.92.127.64:11434/api/tags >/dev/null 2>&1; then
    OLLAMA="http://100.92.127.64:11434"
else
    OLLAMA=""  # fallback: Claude drafts directly
fi
```

**Draft call:**

```bash
# Write prompt to a workspace path both the Write tool and Git Bash agree on
# (do NOT use /tmp on Windows — see Hard Rules: /tmp resolves differently in
# Write vs Git Bash). Use $CLAUDETOOLS_ROOT/.claude/tmp/ or pipe via heredoc.
PROMPT_FILE="$CLAUDETOOLS_ROOT/.claude/tmp/ollama_prompt.txt"
mkdir -p "$(dirname "$PROMPT_FILE")"
cat > "$PROMPT_FILE" <<'ENDPROMPT'
<prompt content here>
ENDPROMPT

if [ -n "$OLLAMA" ]; then
    DRAFT=$(PROMPT_FILE="$PROMPT_FILE" py -c "
import os, urllib.request, json, sys
prompt = open(os.environ['PROMPT_FILE']).read()
body = json.dumps({
  'model': 'qwen3:14b',
  'messages': [{'role': 'user', 'content': prompt}],
  'stream': False,
  'think': False
}).encode()
res = json.loads(urllib.request.urlopen(
    urllib.request.Request('$OLLAMA/api/chat', body), timeout=60
).read())
print(res['message']['content'])
")
else
    echo "[INFO] Ollama unavailable — Claude will draft directly."
    DRAFT=""
fi
```

**When to use Ollama:**
- Comment body drafting (`/syncro comment`, `/syncro close`, billing resolution notes)
- Billing `description` field (line item billing narrative)
- Ticket initial description during `/syncro create`

**When NOT to use Ollama:**
- JSON field selection (product_id, quantity, price_retail) — Claude owns this using the local rate table and rules
- Read operations (GET)
- Auth, credential, or security decisions

#### Billing draft prompt template

```
You are a Syncro PSA billing assistant. Draft a resolution comment and billing description.

TICKET #<id>: <subject>
CUSTOMER: <customer_name>
TECH: <tech_name>
WORK DONE: <user description of work>
LABOR: <product_name> — <minutes> min (<quantity> hrs) @ $<price_retail>/hr = $<total>

Rules:
- comment_body must use <br> for line breaks. Do NOT use <ul> or <li> — they do not render.
- Keep it professional and factual. No filler phrases.
- line_item_description is one plain-text line, billing-facing.

Return ONLY valid JSON, no prose before or after:
{
  "comment_subject": "Resolution",
  "comment_body": "<HTML with <br> line breaks>",
  "line_item_description": "<one line plain text>",
  "preview": "<2-3 sentence plain-text summary for tech review>"
}
```

#### Comment draft prompt template

```
You are a Syncro PSA tech assistant. Draft a ticket comment.

TICKET #<id>: <subject>
CUSTOMER: <customer_name>
NOTE: <user's note or description>
VISIBILITY: <"Internal only" | "Customer-visible">

Rules:
- Use <br> for line breaks. Do NOT use <ul> or <li>.
- Professional and factual. No filler.

Return ONLY valid JSON:
{
  "subject": "Update",
  "body": "<HTML with <br> line breaks>",
  "preview": "<plain text for tech review>"
}
```

#### Claude review checklist (always run before presenting to user)

Whether the draft came from Ollama or Claude wrote it directly:

1. `price_retail` matches the local rate table for the selected `product_id`
2. `quantity` = minutes ÷ 60 — verify the arithmetic (e.g. 45 min = 0.75, not 0.77)
3. Computed total = `price_retail × quantity` — matches what was communicated to user
4. If labor_type is `emergency` and `prepay_hours > 0`: product must be `26118`, qty must be actual_hours × 1.5
5. `comment_body` uses `<br>`, not `<ul>/<li>`
6. No internal notes or credential data in a customer-visible comment body

If a check fails: correct it and note the fix in the preview so the user can see what changed.

#### Fallback behavior

If `OLLAMA` is empty (neither endpoint reachable): Claude drafts the comment body and billing description directly from the same variables. All other logic — review checklist, confirmation, execution — is identical. Announce `[INFO] Ollama unavailable — drafting directly.`

---

### Adding a per-user key

1. User logs into Syncro → Admin → API Tokens → New (`/api_tokens/new`)
2. Type: Integration API Token (or Custom with all standard scopes: asset/customer/ticket/invoice/payment read+write+delete, worksheet add+manage+delete, chat + script.execute)
3. Copy the token once (Syncro only shows it on creation)
4. Encrypt to vault:
   ```bash
   cat > $VAULT_ROOT/msp-tools/syncro-<user>.sops.yaml <<YAML
   kind: api-key
   name: Syncro (<Full Name>)
   subdomain: computerguru
   api-base-url: https://computerguru.syncromsp.com/api/v1
   api-docs: https://api-docs.syncromsp.com/
   status: active
   owner: <user>
   syncro_user_id: <id>
   tags: [msp-tools, per-user]
   credentials:
       credential: <TOKEN>
   notes: Per-user Syncro API token for <Full Name>. Created YYYY-MM-DD.
   YAML
   # MUST run from vault root so sops picks up .sops.yaml
   (cd "$VAULT_ROOT" && sops --encrypt --in-place "msp-tools/syncro-<user>.sops.yaml")
   ```
5. Commit + push vault repo.

### Endpoints reference

#### Tickets

| Operation | Method | Endpoint | Body |
|---|---|---|---|
| List tickets | GET | `/tickets?status=<status>&per_page=25` | — |
| Get ticket | GET | `/tickets/<id>` | — |
| Create ticket | POST | `/tickets` | see full create workflow below |
| Update ticket | PUT | `/tickets/<id>` | `{"status": "In Progress", "priority": "..."}` |
| Delete ticket | DELETE | `/tickets/<id>` | — |

**Ticket statuses:** `New`, `In Progress`, `Waiting on Customer`, `Waiting on Vendor`, `Scheduled`, `Resolved`, `Invoiced`, `Closed`

**Priority format** (number-prefixed string): `"1 High"`, `"2 Normal"`, `"3 Low"`, `"4 Urgent"`
Default: `"2 Normal"`. Use `"4 Urgent"` for emergency/after-hours.

**Problem types (Issue Type dropdown — use closest match, else "Not determined"):**
`API`, `Email`, `Emergency Service`, `File Services / Permissions`, `Hardware`, `Maintenance`,
`New User / M365 Account Creation`, `New User / Workstation Deployment`, `Not determined`,
`Onsite`, `Other`, `Phone/VOIP`, `Remote`, `Security`, `Server Migration`, `Service Request`,
`Software`, `Website`

**Appointment types:**

| Name | ID | location_type |
|---|---|---|
| In Shop | 4321 | shop |
| Onsite | 4322 | customer |
| Phone Call | 4323 | pre_defined |
| Reminder | 193053 | manual_entry |
| Remote | 59289 | pre_defined |

**Tech user IDs:** Mike = 1735, Howard = 1750, Winter = 1737, Rob = 1760

#### Appointments

| Operation | Method | Endpoint | Notes |
|---|---|---|---|
| List (today) | GET | `/appointments?start_at=YYYY-MM-DD` | Filter by date; use `.summary` to match customer |
| Get | GET | `/appointments/<id>` | Returns `{"appointment": {...}}` |
| Create | POST | `/appointments` | Used in ticket creation flow (Call 3) |
| Move / edit | PUT | `/appointments/<id>` | Verified 2026-04-24 — updates `start_at`/`end_at` |
| Delete | DELETE | `/appointments/<id>` | Not yet verified |

**Finding an appointment by customer:** `GET /appointments?start_at=<date>` returns all appointments — filter client-side with `select(.summary | test("customer name"; "i"))` or `select(.ticket.customer_id == N)`. The `customer_id` query param does not filter correctly.

**Move workflow:**
1. `GET /appointments?start_at=<date>` — find appointment ID
2. Confirm new date/time with user
3. `PUT /appointments/<id>` with `{"start_at": "ISO8601", "end_at": "ISO8601"}`
4. Verify response: `.appointment.start_at` matches intended time

**Response shape:** `{"appointment": {...}}` — parse as `.appointment.id`, `.appointment.start_at`, etc.

---

### Ticket creation workflow (full — 3 API calls)

Ticket creation in Syncro maps to three separate API calls. Gather all inputs first, show a full preview, wait for confirmation, then execute in order.

#### Step 1 — Gather inputs

Collect in one pass (do not ask field by field):

| # | Field | Notes |
|---|---|---|
| 1 | **Subject** | Brief title: reason for the ticket |
| 2 | **Issue Type** (`problem_type`) | From dropdown above; "Not determined" if unclear |
| 3 | **Priority** | "2 Normal" default; "4 Urgent" for emergencies |
| 4 | **Description** | Expanded detail — becomes the "Initial Issue" comment body |
| 5 | **Do Not Email** | Suppress customer notification on ticket create? (yes for internal/reminder tickets) |
| 6 | **Due Date** | ISO date |
| 7 | **Assigned Tech** | Who owns the ticket. Defaults to API key owner if not specified (mike → 1735, howard → 1750). MUST always be included in the POST payload — never omit. |
| 8 | **Contact** | Look up from `GET /customers/{id}` → `.contacts[]`; show list, ask user to pick |
| 9 | **Address/Site** | `address_id` — also comes from customer contacts with address data |
| 10 | **Appointment Type** | From table above; omit section if no appointment needed |
| 11 | **Location** | Free text; usually blank unless onsite at non-primary address |
| 12 | **Start Time** | ISO8601 datetime; omit if no scheduled appointment |
| 13 | **End Time** | Default: start + 90 minutes |
| 14 | **Appointment Owner** | Usually same as assigned tech; noted for calendar attribution (not a separate API field — inherits from ticket `user_id`) |
| 15 | **Do Not Invite** | If not onsite, suppress calendar invite — note: not directly controllable via API; inform user if they need this set manually |
| 16 | **Asset** | Search `GET /customer_assets?customer_id=N&query=<name>` if a specific device is involved |

#### Step 2 — Look up customer data

Before showing the preview, fetch what you need:

```bash
# Get contacts and addresses
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{contacts: [.customer.contacts[] | {id, name, address1, email}]}'

# Search assets
curl -s "${BASE}/customer_assets?customer_id=${CUST_ID}&query=<name>&api_key=${API_KEY}" | jq '[.assets[] | {id, name, asset_type}]'
```

#### Step 3 — Show preview and confirm

Display the full ticket before posting. Include all populated fields. Wait for explicit confirmation.

```
TICKET PREVIEW
--------------
Customer:     <name>
Subject:      <subject>
Issue Type:   <problem_type>
Priority:     <priority>
Description:  <description>
Due Date:     <due_date>
Assigned To:  <tech name>
Contact:      <contact name>
Address:      <address>
Do Not Email: <yes/no>

APPOINTMENT
-----------
Type:         <type name>
Start:        <start_at>
End:          <end_at>  (90 min)
Location:     <location or blank>

ASSET:        <asset name or none>

Confirm? (yes/no)
```

#### Step 4 — Execute (after confirmation)

**Call 1 — Create ticket:**

```bash
RESP=$(curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "customer_id": N,
  "subject": "...",
  "problem_type": "...",
  "status": "New",
  "priority": "2 Normal",
  "user_id": N,
  "due_date": "YYYY-MM-DD",
  "contact_id": N,
  "address_id": N,
  "start_at": "ISO8601",
  "end_at": "ISO8601",
  "asset_ids": [N]
}
JSON
)
TICKET_ID=$(echo "$RESP" | jq -r '.ticket.id')
CUST_ID=$(echo "$RESP" | jq -r '.ticket.customer_id')
```

Omit null/blank fields from the payload before piping. The `'JSON'` quoting on the heredoc opener is required — it suppresses bash variable and backtick expansion inside, which matters when descriptions contain `$` (passwords, prices, regex, etc.).

**Call 2 — Post initial description as "Initial Issue" comment:**

```bash
curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Initial Issue",
  "body": "<the full description>",
  "hidden": false,
  "do_not_email": true
}
JSON
# Parse: .comment.id  (NOT .id — see Hard Rules)
```

Set `do_not_email: true` if "Do Not Email" was checked; `false` otherwise.

**Call 3 — Create appointment (only if start_at provided):**

```bash
curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "ticket_id": N,
  "customer_id": N,
  "appointment_type_id": N,
  "start_at": "ISO8601",
  "end_at": "ISO8601",
  "location": ""
}
JSON
```

Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed.

**Payload handoff: prefer heredoc with `--data-binary @-` and `<<'JSON'` quoting** — never use `/tmp/<file>.json` for piping payloads from the Write tool to curl. On Windows, the Write tool resolves `/tmp/foo.json` to `C:\tmp\foo.json` while Git Bash resolves it to `%LOCALAPPDATA%\Temp\foo.json` — different real directories, so curl reads a different (or stale) file than Write created. Heredoc avoids the file handoff entirely, and the `'JSON'` quoting prevents bash from expanding `$` characters inside the payload (passwords, regex, jq queries, etc.). See `.claude/memory/feedback_tmp_path_windows.md` for the full failure mode.

#### Comments

| Operation | Method | Endpoint | Body |
|---|---|---|---|
| Add comment | POST | `/tickets/<id>/comment` | `{"subject": "Update", "body": "...", "hidden": false, "do_not_email": false}` |

**Comment fields (verified):**
- `subject` — required; comment header (e.g., "Update", "Resolution", "Internal Note")
- `body` — required; comment text (HTML supported)
- `hidden` — bool; if true, internal-only (customer can't see)
- `do_not_email` — bool; if true, suppresses customer email notification
- `tech` — string; overrides the authenticated user's name shown on the comment

**Drafting comment bodies:** Use Ollama (comment draft prompt template above) to generate `body` content. Run Claude review checklist. Present preview and wait for confirmation before POST. Fallback to Claude direct draft if `$OLLAMA` is empty.

**Silently ignored (do not use):** `product_id`, `minutes_spent`, `bill_time_now` — accepted but not saved. Verified 2026-04-21.

**CRITICAL — response wrapper:** POST /comment returns `{"comment": {"id": ..., "subject": ..., ...}}` — NOT a flat object. Always parse as `.comment.id`, `.comment.created_at`, etc. Using `.id` returns null and looks like failure even when the comment posted successfully. This caused duplicate comments on 2026-04-22 (#32185) and 2026-04-23 (#32142) — both times the POST succeeded but null `.id` triggered a retry.

```bash
# Correct pattern — always check .comment.id
RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Update",
  "body": "...",
  "hidden": false,
  "do_not_email": false
}
JSON
)
echo "$RESP" | jq '{id: .comment.id, subject: .comment.subject, created_at: .comment.created_at}'
```

**CRITICAL — duplicate prevention:** The server has no idempotency. One POST = one comment, always. Duplicates are caused by calling the endpoint twice (retry after a perceived timeout, double tool invocation, etc.). **Never retry a POST /comment without first GET /tickets/{id} to confirm the comment did not already land.** When verifying, search all comments by subject — do not rely on `[-3:]` tail. The `Idempotency-Key` header is silently ignored.

```bash
# Correct verification pattern after ambiguous response
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
  jq '.ticket.comments[] | select(.subject == "Your Subject Here") | {id, created_at}'
```

**Comments cannot be deleted via API.** No DELETE endpoint exists in the Syncro API for comments — confirmed against official swagger spec. Duplicate comments require manual removal in the GUI.

**Do NOT wrap body in `{"comment": {...}}`** — returns 422 "Body can't be blank". POST flat JSON directly.

#### Customers

| Operation | Method | Endpoint |
|---|---|---|
| List/search | GET | `/customers?query=<search>&per_page=25` |
| Get customer | GET | `/customers/<id>` |
| Create customer | POST | `/customers` |

#### Billable Line Items

There are two verified mechanisms for putting a billable charge on a ticket. They are NOT interchangeable.

**Default — `timer_entry → charge_timer_entry` (REQUIRED for any work that has a time component):**

This is the documented billing path. It records hours into Syncro's time-tracking system AND creates the line item, so reporting (hours per client, tech productivity, prepay burn rate, average resolution time) stays accurate. Bare `add_line_item` skips the time-tracking system and leaves Syncro showing `00:00:00` worked even though the invoice posts correctly — which is what produced the 31-ticket gap on 2026-04-30 and three more on 2026-05-01.

| Operation | Method | Endpoint |
|---|---|---|
| Create timer | POST | `/tickets/<id>/timer_entry` |
| Charge timer (creates line item) | POST | `/tickets/<id>/charge_timer_entry` |
| Update timer | PUT | `/tickets/<id>/update_timer_entry` |
| Delete timer | POST | `/tickets/<id>/delete_timer_entry` |
| List timers (on a ticket) | GET | `/tickets/<id>` → `.ticket.ticket_timers` |

**CRITICAL — response shapes are FLAT:** Both `POST /timer_entry` and `POST /charge_timer_entry` return a flat object — `{"id": N, "ticket_id": ..., "product_id": ..., ...}` — NOT wrapped in `{"timer": {...}}` or `{"timer_entry": {...}}`. Parse as `.id` directly. The wrapped pattern silently returns `null`, breaks `charge_timer_entry` ("Not found"), and triggers a duplicate-timer retry. Hit on ticket #32253 on 2026-05-05; recovery via `delete_timer_entry`. Verified shape:

```json
// POST /tickets/{id}/timer_entry response
{"id": 39031258, "ticket_id": 109895882, "user_id": 1750, "start_time": "...", "end_time": "...",
 "recorded": false, "billable": true, "notes": "...", "product_id": 26118,
 "comment_id": null, "ticket_line_item_id": null, "active_duration": 1800, ...}

// POST /tickets/{id}/charge_timer_entry response (also flat)
{"id": 39031258, "recorded": true, "ticket_line_item_id": 42313052, ...}
```

**CRITICAL — duplicate prevention:** Syncro has no idempotency on `/timer_entry`. **Never retry the POST without first GET `/tickets/{id}` and inspecting `.ticket.ticket_timers[]`.** The standalone `GET /ticket_timers?ticket_id=N` query parameter does NOT filter — it returns the entire global timer history. Use the ticket object instead.

```bash
# Verification pattern after ambiguous timer_entry response
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
  jq '.ticket.ticket_timers[] | select(.recorded == false) | {id, start_time, end_time, product_id, notes}'
```

If duplicates exist, delete the older one(s) before charging:
```bash
curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"timer_entry_id": <older_duplicate_id>}
JSON
# Returns: {"success": true}
```

```bash
# 1. Create timer entry — records hours in Syncro's time-tracking system.
#    For warranty / no-charge work, set "billable": false (time still records).
#    Capture .id directly — response is FLAT (see above).
TIMER_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "start_at": "ISO8601",
  "end_at": "ISO8601",
  "notes": "...",
  "billable": true,
  "product_id": 1190473
}
JSON
)
TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.id')

# 2. Charge the timer — sets recorded:true and auto-generates a linked line
#    item with the timer's product_id and computed quantity (hours).
curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"timer_entry_id": ${TIMER_ID}}
JSON

# 3. Verify the auto-generated line item picked up the rate. Syncro sometimes
#    creates the line at $0.00 even though the product has a price_retail set
#    (same root cause as the bare add_line_item bug). If price_retail is 0,
#    patch it via update_line_item.
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | jq '.ticket.line_items[] | {id, product_id, quantity, price_retail}'

# 4. (only if needed) Patch a $0 line:
curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "ticket_line_item_id": NNN,
  "price_retail": 150.00
}
JSON

# Delete timer (rarely needed):
curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"timer_entry_id": N}
JSON
# Returns: {"success": true}
```

`charge_timer_entry` produces the line item; you do NOT call `add_line_item` afterward — that would double-bill.

**Fallback — bare `add_line_item` (NON-TIME items only):**

Use this ONLY when there is genuinely no labor time component to bill — selling a hardware product, a flat-fee service, a recurring subscription line. For ANY work with a time component, including warranty/free work (where time should record at `billable: false`), use the timer path above. Cancelled tickets are the only exemption from creating a time entry.

| Operation | Method | Endpoint |
|---|---|---|
| Add line item | POST | `/tickets/<id>/add_line_item` |
| Remove line item | POST | `/tickets/<id>/remove_line_item` |
| Update line item | PUT | `/tickets/<id>/update_line_item` |

```bash
# Non-time line item (hardware, flat-fee). Always include price_retail —
# the API does not auto-apply product rates.
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "product_id": 1190473,
  "quantity": 1,
  "price_retail": 150.00,
  "name": "Hardware - Replacement Drive",
  "description": "Item description",
  "taxable": true
}
JSON

# Remove
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"ticket_line_item_id": 12345}
JSON
# Returns: {"success": true, "message": ""}
```

**add_line_item required fields** (also apply to the auto-generated line from `charge_timer_entry` — verify after charging and patch via `update_line_item` if needed):
- `name` — required (422 if missing)
- `description` — required (422 if missing)
- `product_id` — product ID (labor product table below for time-based work, or any other product for hardware / flat-fee items)
- `quantity` — units of the product. For labor products, this is decimal hours (0.5 = 30 min, 1.0 = 1 hour). For hardware, the unit count.
- `price_retail` — **must always be set explicitly**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00. Syncro does NOT auto-calculate rates via API even though it does in the web UI. Omitting it leaves the line at $0.00 and the invoice generates at $0 (verified 2026-04-23 on #32203). Always pass the rate from the table below.
- `taxable` — **always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted. Use `false` for labor, `true` for taxable hardware.

**Do NOT remove ticket line items after invoicing.** Leave them on the ticket — the "Add/View Charges" button and billing verification by techs depends on seeing line items there.

**Labor product IDs and rates** (rates pulled from Syncro API 2026-04-24):

| product_id | Name | price_retail ($/hr) | Notes |
|---|---|---|---|
| `1190473` | Labor - Remote Business | `150.00` | Standard remote work |
| `26118` | Labor - Onsite Business | `175.00` | Base onsite rate |
| `573881` | Labor - In Shop Business | `150.00` | Hardware brought into ACG's shop |
| `26184` | Labor - Emergency or After Hours Business | `262.50` | **1.5× onsite; time-and-a-half baked into the rate.** Non-prepaid customers only. Do NOT stack with `26118` for the same hours. |
| `1049360` | **Labor- Warranty work** | `0.00` | **Use this for ANY warranty / no-charge work.** Do NOT use a billable labor product + `billable: false` or a patched price. See `feedback_syncro_warranty_product.md`. |
| `9269129` | Labor - Prepaid Project Labor | `0.00` | **DO NOT USE for normal or prepaid work.** Exempt Labor category — does NOT deduct from `prepay_hours` block despite the name. Billing a prepaid customer with this product gives a $0.00 invoice AND silently skips the block decrement. Verified 2026-05-04 (see `feedback_syncro_labor_type.md`). Only use if explicitly directed. |
| `9269124` | Labor - Internal Labor | `0.00` | Non-billable internal ACG time (not customer-facing). |
| `26117` | Fee - Travel Time | `40.00` | Per travel event (not hourly) |
| `68055` | Labor - Website Labor | `150.00` | Website-related work |

`price_retail` is the per-unit rate. Line item total = `price_retail × quantity`. **Rates are determined by the product selected** — never patch `price_retail` on a line item to convert one product into another (e.g. don't take Remote Labor at $150 and patch to $0 to mimic warranty). If a line's dollar amount is wrong, the wrong `product_id` was picked — undo, pick the correct product, redo. The only legitimate `update_line_item price_retail` use is the Syncro auto-gen-zero recovery (when an auto-generated line came in at $0 instead of the product's intended rate).

**Emergency / after-hours billing branches by whether customer has prepaid labor:**

Check: `GET /customers/<id>` → `.customer.prepay_hours` (string; `"0.0"` means no prepaid, any non-zero means prepaid block exists).

| `prepay_hours` | Regular hours | Emergency / after-hours |
|---|---|---|
| `0` / null (no prepaid) | delivery-channel product, qty = actual_hours | `26184`, qty = actual_hours (rate already 1.5×) |
| `> 0` (has prepaid block) | delivery-channel product, qty = actual_hours | delivery-channel product, qty = actual_hours × **1.5** |

"Delivery-channel product" = `1190473` remote, `26118` onsite, `573881` in-shop, `68055` web — match to how work was actually delivered.

**Rationale (Winter, 2026-04-23):** Prepaid blocks debit by QUANTITY, not dollars. To charge time-and-a-half against a prepaid block we bump the quantity to 1.5× on the Onsite product rather than switching to the Emergency product — switching would double-count because the Emergency product has the 1.5× already built into its dollar rate.

**Example — 2 hour emergency onsite job:**
- Non-prepaid customer: one line of 2.0 hrs × `26184` @ $262.50 → $525.00 billed
- Prepaid customer: one line of 3.0 hrs × `26118` @ $175.00 → debits 3 hrs from prepaid block

Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after a stack of 1hr `26118` + 1hr `26184` for a single hour of emergency work — the $ doubled because the 1.5× was applied twice.

#### Timer Entries (time tracking reference)

| Operation | Method | Endpoint |
|---|---|---|
| Add timer | POST | `/tickets/<id>/timer_entry` |
| Charge timer → line item | POST | `/tickets/<id>/charge_timer_entry` |
| Update timer | PUT | `/tickets/<id>/update_timer_entry` |
| Delete timer | POST | `/tickets/<id>/delete_timer_entry` |
| List timers (on a ticket) | GET | `/tickets/<id>` → `.ticket.ticket_timers` |

Both `POST /timer_entry` and `POST /charge_timer_entry` return FLAT objects — parse `.id` directly. See "Billable Line Items → Default" above for the full response-shape note and duplicate-prevention pattern.

#### Invoices

| Operation | Method | Endpoint | Body |
|---|---|---|---|
| List invoices | GET | `/invoices?per_page=25` | — |
| Get invoice | GET | `/invoices/<id>` | — |
| Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` |
| Delete invoice | DELETE | `/invoices/<id>` | — |

**"Make Invoice" flow:** `POST /invoices` pulls all line items currently on the ticket into the invoice. Line items are produced by `charge_timer_entry` (the default path for time-based work) or by bare `add_line_item` (the fallback path for non-time items). A bare timer entry that has not been charged is NOT pulled in — the timer must be converted to a line item via `charge_timer_entry` first.

**Note:** The `POST /invoices` response body does not include `line_items` — do `GET /invoices/{id}` to verify line items transferred correctly.

### Display formatting

When showing ticket lists, format as:

```
#32164  New        Jerry Burger           Own cloud thing again
#32163  New        LeeAnn Parkinson       Remote - Jim cant access his email
#32162  Invoiced   Len's Auto Brokerage   Server upgrade
```

When showing ticket detail, include:
- Ticket number, subject, status, priority
- Customer name + contact
- Created date, due date, last updated
- Assigned tech
- Comments (most recent first, truncated to last 5)
- Line items / billing status

### Billing workflow

**ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default.**
**ALWAYS show a preview of the comment + timer entry to the user before posting. Wait for confirmation.**
**ALWAYS read `customer.prepay_hours` before billing ANY work** — not just emergency. Prepaid customers get $0.00 invoices with block deductions; non-prepaid customers get dollar invoices. Knowing this before you start prevents false "zero invoice" panic mid-workflow.
**ALWAYS bill via `timer_entry → charge_timer_entry`. Bare `add_line_item` for time-based work bypasses Syncro's time-tracking system and is forbidden — see Hard Rules.**

When `/syncro bill <number>` is called:
1. `GET /tickets/{id}` for ticket detail, then `GET /customers/{customer_id}` to read `prepay_hours`
2. Check Ollama availability (see "Ollama drafting" above) — do this once, reuse `$OLLAMA`
3. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal / warranty)"
4. Decide product + quantity using the emergency-branching table above:
   - Non-prepaid + emergency → product `26184`, qty = actual hours
   - Prepaid + emergency     → product `26118`, qty = actual hours × 1.5
   - Warranty / no-charge    → product **`1049360` (Labor- Warranty work)**, `billable: true`, qty = actual hours. Do NOT pick a regular labor product with `billable: false` — Syncro silently overrides the flag and generates a billable line. (Verified 2026-05-06 on #32225 — see `feedback_syncro_warranty_product.md`.)
   - Otherwise               → per `--labor` mapping below, qty = actual hours
5. Look up `price_retail` from the local rate table (do NOT fetch live — rates are baked in)
6. Compute `start_at` and `end_at` for the timer (use ISO8601; the `end_at − start_at` interval should equal `quantity` hours so Syncro's reporting math matches what you bill)
7. Send billing draft prompt to Ollama (or draft directly if `$OLLAMA` is empty) — see prompt template above
8. Run Claude review checklist on the draft output
9. Present preview to user: product, quantity, rate, computed total, comment body, timer notes / line item description. Wait for confirmation.
10. Post resolution comment: `POST /tickets/{id}/comment`
11. Create timer entry: `POST /tickets/{id}/timer_entry` with `start_at`, `end_at`, `billable` (true for paid work, false for warranty/no-charge), `product_id`, `notes`. Capture the returned timer ID.
12. Charge the timer: `POST /tickets/{id}/charge_timer_entry` with `{"timer_entry_id": N}` — this records the time AND auto-generates a linked line item with the timer's `product_id` and computed `quantity` (hours).
13. Verify the auto-generated line item picked up the rate: `GET /tickets/{id}` and inspect the new entry in `.ticket.line_items[]`. If `price_retail` came in at `0.00` (Syncro sometimes drops it on auto-generated lines), patch it: `PUT /tickets/{id}/update_line_item` with `{"ticket_line_item_id": N, "price_retail": <rate>}`.
14. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}`
15. Verify invoice: `GET /invoices/{id}` → confirm line items transferred. **For prepaid customers, `.invoice.total` will be $0.00 — this is correct.** The line item name is annotated "- Applied X Prepay Hours" and the block is debited. Confirm by re-fetching `customer.prepay_hours` and checking it dropped by `quantity`. For non-prepaid customers, `.invoice.total` must equal `qty × price_retail`.
16. Update ticket status to `Invoiced`

**If `.invoice.total` comes back $0.00** (auto-generated line item went in with null price and you missed step 13): `PUT /tickets/{id}/update_line_item` with `price_retail` on each item, then `DELETE /invoices/{bad_id}` and re-POST `/invoices`. Recovery verified on #32203 (2026-04-23).

**Correct pattern:**
```bash
# Step 1: Post resolution comment
curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Resolution",
  "body": "...",
  "hidden": false,
  "do_not_email": false
}
JSON

# Step 2: Create timer entry — records hours in Syncro's time-tracking system.
# Convert minutes to decimal hours (60 min = 1.0, 30 min = 0.5, 45 min = 0.75).
# Set start_at/end_at so end - start equals the billed duration.
# For warranty / no-charge work, set "billable": false (time still records).
TIMER_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "start_at": "2026-05-01T13:00:00-07:00",
  "end_at":   "2026-05-01T14:00:00-07:00",
  "notes": "Resolved customer issue — see ticket comment for detail.",
  "billable": true,
  "product_id": 1190473
}
JSON
)
TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.id')  # response is FLAT — see "response shapes" note above

# Step 3: Charge the timer — creates the linked line item automatically.
curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"timer_entry_id": ${TIMER_ID}}
JSON

# Step 4: Verify auto-generated line item — price_retail should equal the
# product rate. If it's 0.00, patch with update_line_item before invoicing.
LINE=$(curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
  jq '.ticket.line_items | map(select(.product_id == 1190473)) | last')
echo "$LINE" | jq '{id, product_id, quantity, price_retail}'

# Step 5: (only if price_retail came in at 0) patch it
LINE_ID=$(echo "$LINE" | jq -r '.id')
curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_line_item_id": ${LINE_ID}, "price_retail": 150.00}
JSON

# Step 6: Create invoice
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_id": ${ID}, "customer_id": ${CUST}, "category": "Standard"}
JSON
)
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')

# Step 7: Verify line items transferred and total is correct
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | \
  jq '{total: .invoice.total, line_items: .invoice.line_items}'

# Step 8: Mark ticket Invoiced
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"status": "Invoiced"}
JSON
```

The two heredocs that interpolate `${TIMER_ID}` / `${LINE_ID}` / `${ID}` / `${CUST}` / `${INVOICE_ID}` use unquoted `<<JSON` (allows expansion). The static-payload heredocs use `<<'JSON'` (single-quoted, no expansion) so any `$` inside the body — passwords, regex, jq queries — comes through as a literal. Pick the right form per heredoc.

`--labor` maps to product IDs: `remote` → 1190473, `onsite` → 26118, `emergency` → 26184, `project` → 9269129, `internal` → 9269124, `travel` → 26117, `website` → 68055

**Override:** `emergency` becomes `26118` with `quantity × 1.5` when the customer has `prepay_hours > 0`. See the Emergency billing branching table above. The override applies to the timer's `product_id` field and the timer's interval (set `end_at − start_at` to `actual_hours × 1.5` so the auto-generated line gets the right quantity).

### Error handling

- 401: API key invalid or expired
- 404: ticket/customer/invoice not found
- 422: validation error (show the error message from response body)
- 429: rate limited (wait 60s and retry)

### Integration with session logs

When closing a ticket (`/syncro close`), offer to create a session log entry in `clients/<customer>/session-logs/` documenting what was resolved. Pull the ticket subject, comments, and resolution into a structured log.
