# /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) **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 temp file to avoid quoting hell cat > /tmp/ollama_prompt.txt <<'ENDPROMPT' ENDPROMPT if [ -n "$OLLAMA" ]; then DRAFT=$(py -c " import urllib.request, json, sys prompt = open('/tmp/ollama_prompt.txt').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 curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d @/tmp/ticket_payload.json # Parse: TICKET_ID=$(... | jq -r '.ticket.id') # Parse: CUST_ID=$(... | jq -r '.ticket.customer_id') ``` Payload fields (omit null/blank): ```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] } ``` **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" \ -d @/tmp/comment_payload.json # Parse: .comment.id (NOT .id — see Hard Rules) ``` Payload: ```json { "subject": "Initial Issue", "body": "", "hidden": false, "do_not_email": true } ``` 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" \ -d @/tmp/appt_payload.json ``` Payload: ```json { "ticket_id": N, "customer_id": N, "appointment_type_id": N, "start_at": "ISO8601", "end_at": "ISO8601", "location": "" } ``` Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed. **Always use temp files for payloads** — never inline JSON in curl -d with ticket data (special characters, newlines in description will break the shell). #### 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" \ -d @/tmp/payload.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 Two verified ways to add billable time. Both produce ticket line items that transfer to invoices. **Option A — Direct line item (simpler):** | Operation | Method | Endpoint | |---|---|---| | Add line item | POST | `/tickets//add_line_item` | | Remove line item | POST | `/tickets//remove_line_item` | | Update line item | PUT | `/tickets//update_line_item` | ```bash # Add (always include price_retail — 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" \ -d '{"product_id": 1190473, "quantity": 0.5, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "Work description", "taxable": false}' # Remove curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"ticket_line_item_id": 12345}' # Returns: {"success": true, "message": ""} ``` **Option B — Timer then charge (for time-tracking workflows):** ```bash # 1. Create timer entry curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"start_at": "ISO8601", "end_at": "ISO8601", "notes": "...", "billable": true, "product_id": 1190473}' # 2. Charge timer — sets recorded:true and creates linked line item curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"timer_entry_id": N}' # Delete timer (if needed) curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"timer_entry_id": N}' # Returns: {"success": true} ``` **add_line_item required fields:** - `name` — required (422 if missing) - `description` — required (422 if missing) - `product_id` — labor product ID (see table below) - `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour) - `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: false` — **always set explicitly**; labor products default to no-tax in GUI but the API applies tax if this is omitted **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 | GET | `/ticket_timers?ticket_id=` | #### 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 `add_line_item` entries from the ticket into the invoice. Timer entries are NOT included. **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 + line items to the user before posting. Wait for confirmation.** **ALWAYS read `customer.prepay_hours` before choosing the labor product for emergency work.** 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)" 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 - 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. Send billing draft prompt to Ollama (or draft directly if `$OLLAMA` is empty) — see prompt template above 7. Run Claude review checklist on the draft output 8. Present preview to user: product, quantity, rate, computed total, comment body, line item description. Wait for confirmation. 9. Post comment: `POST /tickets/{id}/comment` 10. Add billable line item: `POST /tickets/{id}/add_line_item` with `product_id`, `quantity`, `price_retail`, `name`, `description`, `taxable: false` 11. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` 12. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail` 13. Update ticket status to `Invoiced` **If `.invoice.total` comes back $0.00** (line items went in with null price): `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 comment curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"subject": "Resolution", "body": "...", "hidden": false, "do_not_email": false}' # Step 2: Add billable line item (convert minutes to decimal hours) # 60 min = 1.0, 30 min = 0.5, 45 min = 0.75, etc. # Always include price_retail — Syncro does NOT auto-apply rates via API curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"product_id": 1190473, "quantity": 1.0, "price_retail": 150.00, "name": "Labor - Remote Business", "description": "...", "taxable": false}' # Step 3: Create invoice curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"ticket_id": '"${ID}"', "customer_id": '"${CUST}"', "category": "Standard"}' # Step 4: Verify line items transferred curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | jq '.invoice.line_items' # Step 5: Mark ticket Invoiced curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ -d '{"status": "Invoiced"}' ``` `--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. ### 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.