306 lines
13 KiB
Markdown
306 lines
13 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
|
|
```
|
|
|
|
## 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/
|
|
|
|
## 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.
|
|
|
|
| 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-<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` | `{"customer_id": N, "subject": "...", "problem_type": "...", "status": "New"}` |
|
|
| 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`
|
|
|
|
**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/<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
|
|
|
|
**Silently ignored (do not use):** `product_id`, `minutes_spent`, `bill_time_now` — accepted but not saved. Verified 2026-04-21.
|
|
|
|
**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.** The `Idempotency-Key` header is silently ignored.
|
|
|
|
**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
|
|
|
|
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/<id>/add_line_item` |
|
|
| Remove line item | POST | `/tickets/<id>/remove_line_item` |
|
|
| Update line item | PUT | `/tickets/<id>/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. **Do not hardcode rates** — omit `price_retail` and Syncro auto-calculates from the product's configured rate.
|
|
- `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:**
|
|
- `1190473` — Labor - Remote Business (standard remote work)
|
|
- `26118` — Labor - Onsite Business
|
|
- `26184` — Labor - Emergency or After Hours Business
|
|
- `9269129` — Labor - Prepaid Project Labor
|
|
- `9269124` — Labor - Internal Labor
|
|
- `26117` — Fee - Travel Time
|
|
- `68055` — Labor - Website Labor
|
|
|
|
#### 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 | GET | `/ticket_timers?ticket_id=<id>` |
|
|
|
|
#### 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 `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 to the user before posting. Wait for confirmation.**
|
|
|
|
When `/syncro bill <number>` is called:
|
|
1. Get ticket details
|
|
2. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal)"
|
|
3. Draft the comment body and show it to the user for review before posting
|
|
4. Post comment: `POST /tickets/{id}/comment`
|
|
5. Add billable line item: `POST /tickets/{id}/add_line_item` with quantity in decimal hours, `price_retail`, `name`, `description`
|
|
6. Create invoice: `POST /invoices` with `{"ticket_id": N, "customer_id": N, "category": "Standard"}`
|
|
7. Verify invoice: `GET /invoices/{id}` to confirm line items transferred
|
|
8. Update ticket status to `Invoiced`
|
|
|
|
**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
|
|
|
|
### 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.
|