# /syncro — Syncro PSA ticket management Create, update, close, comment on, and bill tickets in Syncro PSA. ## Usage ``` /syncro Show open tickets summary /syncro ticket View ticket details + comments /syncro create Create new ticket /syncro update Update ticket status /syncro close Close/resolve a ticket /syncro comment Add a comment to a ticket /syncro bill Add billable time and create invoice /syncro search Search tickets by subject/customer /syncro customers Search customers /syncro move-appointment 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/` 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. **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/` → `.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=` 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-.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' 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 #: CUSTOMER: TECH: WORK DONE: LABOR: min ( hrs) @ $/hr = $ Rules: - comment_body must use
for line breaks. Do NOT use
    or
  • — 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": " line breaks>", "line_item_description": "", "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 #: CUSTOMER: NOTE: VISIBILITY: <"Internal only" | "Customer-visible"> Rules: - Use
    for line breaks. Do NOT use
      or
    • . - Professional and factual. No filler. Return ONLY valid JSON: { "subject": "Update", "body": " line breaks>", "preview": "" } ``` #### 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 `
      `, not `
        /
      • ` 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-.sops.yaml <) subdomain: computerguru api-base-url: https://computerguru.syncromsp.com/api/v1 api-docs: https://api-docs.syncromsp.com/ status: active owner: syncro_user_id: tags: [msp-tools, per-user] credentials: credential: notes: Per-user Syncro API token for . 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-.sops.yaml") ``` 5. Commit + push vault repo. ### Endpoints reference #### Tickets | Operation | Method | Endpoint | Body | |---|---|---|---| | List tickets | GET | `/tickets?status=&per_page=25` | — | | Get ticket | GET | `/tickets/` | — | | Create ticket | POST | `/tickets` | see full create workflow below | | Update ticket | PUT | `/tickets/` | `{"status": "In Progress", "priority": "..."}` | | Delete ticket | DELETE | `/tickets/` | — | **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/` | Returns `{"appointment": {...}}` | | Create | POST | `/appointments` | Used in ticket creation flow (Call 3) | | Move / edit | PUT | `/appointments/` | Verified 2026-04-24 — updates `start_at`/`end_at` | | Delete | DELETE | `/appointments/` | Not yet verified | **Finding an appointment by customer:** `GET /appointments?start_at=` 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=` — find appointment ID 2. Confirm new date/time with user 3. `PUT /appointments/` 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 | | 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=` 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=&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: Subject: Issue Type: Priority: Description: Due Date: Assigned To: Contact: Address:
        Do Not Email: APPOINTMENT ----------- Type: Start: End: (90 min) Location: ASSET: 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": "", "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/.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//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=&per_page=25` | | Get customer | GET | `/customers/` | | 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//timer_entry` | | Charge timer (creates line item) | POST | `/tickets//charge_timer_entry` | | Update timer | PUT | `/tickets//update_timer_entry` | | Delete timer | POST | `/tickets//delete_timer_entry` | | List timers (on a ticket) | GET | `/tickets/` → `.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": } 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 @- </add_line_item` | | Remove line item | POST | `/tickets//remove_line_item` | | Update line item | PUT | `/tickets//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 | | `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. | | `9269129` | Labor - Prepaid Project Labor | `0.00` | Debits from customer `prepay_hours` bank | | `9269124` | Labor - Internal Labor | `0.00` | Non-billable internal time | | `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`. **Emergency / after-hours billing branches by whether customer has prepaid labor:** Check: `GET /customers/` → `.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) | `26118`, qty = actual_hours | `26184`, qty = actual_hours (rate already 1.5×) | | `> 0` (has prepaid block) | `26118`, qty = actual_hours | `26118`, qty = actual_hours × **1.5** | **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//timer_entry` | | Charge timer → line item | POST | `/tickets//charge_timer_entry` | | Update timer | PUT | `/tickets//update_timer_entry` | | Delete timer | POST | `/tickets//delete_timer_entry` | | List timers (on a ticket) | GET | `/tickets/` → `.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/` | — | | Create from ticket | POST | `/invoices` | `{"ticket_id": N, "customer_id": N, "category": "Standard"}` | | Delete invoice | DELETE | `/invoices/` | — | **"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 choosing the labor product for emergency work.** **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 ` 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 → use the closest labor product (e.g. `1190473` remote, `26118` onsite) with `billable: false` on the timer; qty = actual hours - 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": }`. 14. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` 15. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail`. (For prepaid customers, the line totals against the prepay block — `.invoice.total` will reflect any non-prepaid items only.) 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 @- < 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//session-logs/` documenting what was resolved. Pull the ticket subject, comments, and resolution into a structured log.