Files
claudetools/.claude/commands/syncro.md
Mike Swanson 5ec20ac9dd session log: Dataforth SMTP fix, GuruRMM GAGETRAK onboarding, Cloudflare grey-cloud, ticket #32142 billed
- Resolved calibration@dataforth.com SMTP AUTH per-mailbox block in Exchange Online
- Full Dataforth tenant onboarding (all 5 ComputerGuru apps consented)
- GuruRMM agent deployed on DF-GAGETRAK; diagnosed and fixed two issues:
  - rmm-api.azcomputerguru.com grey-clouded (Cloudflare was blocking WSS)
  - enrolled_agents auth gap workaround (site API key in AgentKey registry)
- Syncro ticket #32142 billed: 2 hrs prepaid, invoice #67447, status Invoiced
- syncro.md: fix .comment.id jq path (was .id, caused duplicate comments twice)
- tenants.md: Dataforth marked fully onboarded

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 09:20:00 -07:00

15 KiB

/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/

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.

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

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:

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:
    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 — 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.

# 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.

# 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

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
# 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):

# 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_retailonly 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: falsealways 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:

# 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.