# /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 ``` ## 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. | Vault entry | Syncro user | user_id | |---|---|---| | `msp-tools/syncro-howard.sops.yaml` | Howard Enos | 1750 | | `msp-tools/syncro.sops.yaml` | Michael Swanson | 1735 (current shared fallback) | When Mike generates his own per-user key, add `msp-tools/syncro-mike.sops.yaml` and demote the shared entry or remove it entirely. ### Get API key ```bash VAULT="$CLAUDETOOLS_ROOT/.claude/scripts/vault.sh" BASE="https://computerguru.syncromsp.com/api/v1" # Select key by identity.json user; fall back to shared key if per-user missing USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json") KEY_PATH="msp-tools/syncro-${USER_ID}.sops.yaml" if ! bash "$VAULT" list 2>/dev/null | grep -qx "${KEY_PATH}"; then echo "[WARN] No per-user Syncro key at ${KEY_PATH} — falling back to shared key. Actions will be attributed to the shared key owner." >&2 KEY_PATH="msp-tools/syncro.sops.yaml" fi API_KEY=$(bash "$VAULT" get-field "$KEY_PATH" credentials.credential) ``` Verify attribution before destructive operations: ```bash ME=$(curl -s "${BASE}/me?api_key=${API_KEY}" | jq -r '.user_name + " (user_id=" + (.user_id|tostring) + ")"') echo "Authenticated as: $ME" ``` ### 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` | `{"customer_id": N, "subject": "...", "problem_type": "...", "status": "New"}` | | 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` **Ticket fields (create/update):** - `customer_id` (required for create) - `subject` (required for create) - `problem_type` (string, free-form) - `status` (string, one of the statuses above) - `priority` (string) — set this; leave blank only if user says not to - `due_date` (ISO date) - `user_id` (assign to tech) — set this; Mike = 1735, Winter = 1737, Rob = 1760 - `contact_id` (customer contact) - `ticket_type_id` (ticket category) **Always set `user_id` and `priority` on create** unless the user says otherwise. Ask if unknown. - Assignee = whoever worked the ticket (Mike = 1735, Winter = 1737, Rob = 1760) - Priority = `Normal` by default; `Urgent` for emergency/after-hours tickets #### 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 **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 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, "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 list below) - `quantity` — decimal hours (0.5 = 30 min, 1.0 = 1 hour) - `price_retail` — **only price field that saves**; `price`, `retail_price`, `rate`, `price_cents` all silently ignored and leave line at $0.00. **Always set `price_retail` explicitly.** Omitting it leaves the line at $0.00 and the invoice generates at $0 (verified 2026-04-23 on #32203). Fetch the current rate with `GET /products/` → `.product.price_retail`, then pass that value on `add_line_item`. - `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** (rates verified 2026-04-23; always fetch live with `GET /products/` before billing): | Product ID | Name | Rate | Notes | |---|---|---|---| | `1190473` | Labor - Remote Business | — | standard remote work | | `26118` | Labor - Onsite Business | $175.00/hr | base onsite rate | | `26184` | Labor - Emergency or After Hours Business | $262.50/hr | **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 | — | debits from customer `prepay_hours` bank | | `9269124` | Labor - Internal Labor | — | | | `26117` | Fee - Travel Time | — | | | `68055` | Labor - Website Labor | — | | **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` → $525.00 billed - Prepaid customer: one line of 3.0 hrs × `26118` → debits 3 hrs from the prepaid block ($525.00 equivalent, drawn from prepay) Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after I stacked 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. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)" 3. 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 4. `GET /products/{product_id}` → `.product.price_retail` to fetch the current rate 5. Draft the comment body and show it to the user — with the product, quantity, rate, and computed total — for review before any POST 6. Post comment: `POST /tickets/{id}/comment` 7. Add billable line item: `POST /tickets/{id}/add_line_item` with `product_id`, `quantity`, `price_retail` (from step 4), `name`, `description`, `taxable: false` 8. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}` 9. Verify invoice: `GET /invoices/{id}` → confirm `.invoice.total` matches `qty × price_retail` 10. 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. 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, "name": "Labor - Remote Business", "description": "..."}' # 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.