814 lines
40 KiB
Markdown
814 lines
40 KiB
Markdown
# /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.
|
||
|
||
**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 |
|
||
| 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 |
|
||
| `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/<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) | `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/<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 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 <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 → 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": <rate>}`.
|
||
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 @- <<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.
|