ConnectWise PSA skills and tools under ops/connectwise (hub-sanitized 2026-11-01: client intel, internal workflow/credential/member/company ids, archived-page apiToken/sessionId stripped to placeholders)

This commit is contained in:
Hermes Agent committed 2026-11-01 13:34:58 -08:00
commit 9c96092034
36 files changed
+310875

No files matched your search

@@ -0,0 +1,171 @@
---
name: connectwise-reporting
description: Workflows for generating and automating ConnectWise PSA ticket reports via API and email.
---
# ConnectWise Ticket Reporting
This skill governs the extraction of ticket data from the ConnectWise PSA API and the delivery of formatted reports to technicians.
## Standard Workflow
1. **Authentication:** Use Basic Auth (Base64 encoded `companyid+publickey:privatekey`) and a valid `clientId`.
2. **Data Extraction:** Use the `/service/tickets` endpoint with specific conditions (e.g., `closedFlag=false` and `board/id=1`) to isolate open tickets.
3. **Filtering:** Filter results by `owner/identifier` to ensure technicians only see their own tickets.
4. **Formatting:** Generate an Outlook-optimized HTML table with inline CSS for status-based color coding.
5. **Delivery:** Deliver via the Himalaya CLI using a temporary file for the email body.
## Customer-Level Aggregation Workflow
When the user asks for "customers with open tickets" rather than a raw ticket list, aggregate by `company.id` and enrich each record with primary contact details.
**Steps:**
1. Query `/service/tickets?conditions=board/id=1 and closedFlag=false and dateEntered>'...'` with `pageSize=1000`.
2. Group by `company.id`:
- Count open tickets per company
- Track latest `_info.dateEntered`
3. Post-filter: Skip statuses starting with `>` (e.g., `>Completed`) to exclude resolved-but-not-closed tickets.
4. For each unique company, call `/company/companies/{id}`:
- Extract `defaultContact` (id + name)
- Extract `invoiceToEmailAddress` as fallback
5. Call `/company/contacts/{id}` for the default contact:
- Walk `communicationItems`
- Select item where `type.name == 'Email'` and `defaultFlag == true`
- Use `value` as the primary contact email
6. Sort by company name. Output: Company | Open Tickets | Primary Contact | Email.
**Board IDs:**
- VistaNet Help Desk = 1
- VistaNet Management = 25
- Managed Service Alerts = 6
**Date Range Condition:**
- `dateEntered>'YYYY-MM-DDTHH:MM:SSZ'` (UTC) works for any time window (past year, two years, etc.).
**Note:** Some companies have no default contact email in CW (e.g., BJ LaRossa, Far West Rice Inc.). Display `N/A` gracefully.\n\n## Implementation Patterns\n- **VistaNet Environment:** Use the internal base URL `https://cw.vistanetinc.com/v4_6_release/apis/3.0`. Public endpoints like `api-na.connectwise.com` are not reachable from this environment.\n- **The Master Script Pattern:** Avoid creating individual scripts for every user. Use a single master script that accepts `tech_identifier` and `tech_email` as command-line arguments.
- **Cron Execution:** Because the Hermes cron scheduler may not support arguments in the `script` field for `no_agent` jobs, use **launcher shell scripts** (e.g., `run_techname_report.sh`) to call the master script with the necessary arguments.
- **n8n Execution Data Extraction:** When retrieving data from `mcp__n8n__get_execution` for workflows that process many tickets (e.g., fetching all notes), the total payload often exceeds MCP limits and causes truncation. To avoid this, use the `nodeNames` parameter to target only the final result nodes (e.g., `Aggregate` or `Format Summary`).
- **n8n Data Parsing:** Be aware that n8n node outputs for ConnectWise often follow a `main: [[{json: ...}], [{json: ...}]]` structure (a list of lists), requiring an extra layer of iteration when parsing in Python.
- **n8n Native Node vs HTTP Request:** The native `@mspcopilot/n8n-nodes-connectwise.connectWisePsa` node handles credential management and some query building automatically. Prefer it for standard CRUD (Get Company, Get Many Tickets, Get Many Time Entries) when the API is healthy. Fall back to raw HTTP Request only when the native node lacks a needed operation or when debugging credential issues.
- **n8n Complex Report Generation:** Do NOT use a `Set` node for multi-line JavaScript logic (e.g., building HTML reports from aggregated data). Set nodes in `manual` mode only support simple field mapping expressions. Use a `Code` node with `mode: 'runOnceForAllItems'` for any logic requiring variables, loops, or string concatenation.
- **n8n POST Webhook Body Nesting:** In n8n, POST webhooks expose the JSON body under `$json.body`, not directly on `$json`. When a Code node passes through values from a POST webhook to downstream nodes, downstream expressions must reference `$json.fromEmail`, not `$json.body.fromEmail`, because the Code node flattens its output. However, inside the Code node itself, access the payload as `$('Webhook').first().json.body || $('Webhook').first().json`.
- **n8n Workflow: Customer Audit Pattern:** To build a reusable customer audit workflow in n8n: (1) Webhook trigger accepts `companyName` (and optional `toEmail`, `fromEmail`, `subject`, `startDate`, `endDate`), (2) native `connectWisePsa` node searches for company by name, (3) parallel branches fetch `ticket` (getAll) and `timeEntry` (getAll) using `company/id={{ $json.id }}`, (4) Merge node combines results, (5) Code node generates the final HTML report using `actualHours` for hour aggregation and optional `timeStart` timeframe filtering, (6) Email node sends parameterized report. See `references/n8n-customer-audit-workflow.md` for the full SDK code.
- **n8n Activation Requirement:** After updating a workflow via MCP (`update_workflow`), you MUST call `publish_workflow` to activate the new version. Until published, the old version continues executing.
- **Cross-Company Time Entry Aggregation:** To find "top N companies by billed hours" for a date range, do NOT query the company endpoint. Query `/time/entries?conditions=timeStart>[YYYY-MM-DDTHH:MM:SSZ]` with `pageSize=1000` across pages, then group by `company.id` in Python. Sum `actualHours` per company. Derive the lead tech by tracking `member.name` hours per company and picking whoever logged the most. The company record has `territoryManager` (sales/account manager) but **no built-in `leadTech` or `primaryEngineer` field** — the only meaningful lead tech is the person with the most billable hours. See `scripts/top-companies-by-hours.py`.
- **No `leadTech` Field on Company Record:** ConnectWise company records do NOT contain a field that stores the assigned lead technician. `territoryManager` is the account/sales manager. `customFields` contains phone brand, install date, etc. — none store lead tech. Do not waste time looking for it.
## Pitfalls & Lessons Learned
### "Length cannot be less than zero" — Server-Side API Crash
A `500 Internal Server Error` with the message `"Length cannot be less than zero. Parameter name: length"` is a known, low-level .NET `Substring()` exception in ConnectWise's own code.
- **It is not your fault.** The error is triggered by a malformed record on the server side (e.g., a string slice operation receiving `-1` from a failed character search).
- **It is not bypassable client-side.** Removing `pageSize`, `conditions`, or any parameter does not fix it — even the barest `/service/tickets` request fails.
- **It is likely data-dependent.** One corrupted ticket in the dataset causes the API to crash when generating the response list.
- **Workaround:** Report to ConnectWise support. For urgent access, query for a specific known-valid Ticket ID instead of the general list endpoint.
- **Reference:** Stack Overflow and ConnectWise support articles confirm this is a recurring server-side bug.
### Auth String Format
The Basic Auth string must be **exactly**: `companyid+publickey:privatekey` with a literal `+` between company ID and public key.
- **Correct:** `VNI+<PUBLIC_KEY>:<PRIVATE_KEY>`
- **Incorrect:** `VNI++<PUBLIC_KEY>:<PRIVATE_KEY>` (double `+`)
### Client ID Requirement
If you receive a `401 Unauthorized` with the message `"Incorrect or disabled client ID"`, it means the `clientId` header is missing or invalid. This is a separate requirement from Basic Auth. Check `references/raw-api-authentication.md` for the current valid `clientId`.
### Ticket Updates (PUT vs PATCH)
The `/service/tickets/{id}` endpoint does **not** support `application/json-patch+json`. To update a ticket (e.g., changing the owner), you must:
1. Fetch the current ticket object via `GET`.
2. Modify the desired fields in the JSON object.
3. Perform a full `PUT` request with `Content-Type: application/json`.
### Credential File Format
The ConnectWise credential file at `~/.secrets/connectwise` is a **multi-line `.env` file**, not a raw auth string.
- **File contents:** `CW_COMPANY_ID=VNI\nCW_PUBLIC_KEY=+rtDj...\nCW_PRIVATE_KEY=HcCOK...`
- **WRONG:** Base64-encoding the entire file text as the Authorization header.
- **RIGHT:** Parse the key=value pairs, construct `companyid+publickey:privatekey` from the extracted values, then Base64-encode that string. The public key already starts with `+`, so concatenation produces `VNI+rtDj...` — **do not add a second plus**.
### Time Entries Endpoint & Field
- **WRONG endpoint:** `/service/time` returns `403 Forbidden`.
- **RIGHT endpoint:** `/time/entries` (e.g., `GET /time/entries?conditions=company/id={id}&pageSize=1000`).
- **WRONG hour field:** `hours` is `None` or missing.
- **RIGHT hour field:** `actualHours`.
- **DateTime condition syntax:** Use **square brackets** for date values in query conditions: `dateEntered>[2025-08-11T02:47:43Z]`. Quoted strings (`'...'`) cause a `400 ApiFindCondition` error expecting a datetime value.
### Schedule Entries Mirror Bug
The `/schedule/entries` endpoint has a severe data-mirroring bug that affects both general list queries and ticket-linked queries.
**Observed behavior (confirmed in this session):**
- `GET /schedule/entries?conditions=member/id=<id>` returns entries from **2011–2012**, regardless of date filters.
- Adding date range conditions (e.g., `dateStart>[2026-08-01T00:00:00Z]`) does not help; the endpoint either returns the same stale 2011 data or an empty result.
- Querying via `objectId=<ticket_id>` also fails to return current data — it yielded empty results for 2026 ranges in testing.
- The `pageSize` parameter has no effect on the date filter behavior.
**Confirmed workarounds (none fully reliable):**
- There is **no known client-side workaround** for retrieving current/future schedule entries via the REST API.
- The bug is server-side and data-dependent. The API appears to be returning a static mirror of ancient historical entries.
- For urgent calendar access, use the ConnectWise web UI directly or export via the reporting module.
**What NOT to try:**
- Do not assume a ticket-linked `objectId` query will return current data — it does not in practice.
- Do not rely on `dateStart`/`dateEnd` conditions on `/schedule/entries` to filter to current dates.
### agreementrecap Date-Window Off-By-One (n8n)
`/finance/agreementrecap` returns 0 rows with a `< [{{ $today.plus(30,'Days') }}])` upper bound when agreements bill on the same day of next month (e.g. all monthly recaps due Nov 1 queried on Oct 1 = 31 days out, excluded by `< Oct 31`). Syntax is fine — each clause validates individually; `lastInvoiceAmount` is a *string* field so the quoted `'0.00'` literal is correct (numeric literal there → 400 ApiFindCondition).
- Fix: `< [{{ $today.plus(31,'Days') }}]` (exclusive) or `<= [{{ $today.plus(30,'Days') }}]`. Verified live: original → 0 rows, fixed → 154.
- The `/finance/agreementrecap/count` endpoint 403s with the reporting key; pull the list with `pageSize=1000` and count client-side instead.
### Reference File
The endpoint reference lives at `Documents/cwendpoints.json` (Nextcloud) or `references/api_details.md` in this skill; a current OpenAPI copy also sits at `/home/tech/.hermes/references/connectwise/cwendpoints.json` (schemas include AgreementRecap field types).
### Delivery Loops
Be cautious of the `deliver: "origin"` setting in cron jobs. If a script prints "Success" to stdout, Hermes will send that log as a second, plain-text message to the user. Set `deliver: "local"` to suppress these notifications while still allowing the script to send its own professional email.
### HTML Rendering
Outlook requires strict inline CSS. Always apply styles directly to `<tr>` and `<td>` elements; avoid relying on `<style>` blocks in the header.
### Search & Alert Patterns\nWhen searching for security incidents or "alerts" across specific customers, do not rely on a single keyword. Use a broad set of triggers: `cyber`, `attack`, `hack`, `alert`, `intrusion`, `malware`, `ransom`, `unauthorized`, `vpn`, `ot`, `scada`, `plc`, `down`, `unexpected`, `security`, `failure`.\n\n**Implementation:** Iterate through a list of target Company IDs and filter summaries using any of these keywords to identify potential security events in a high-noise environment.
### Per-Ticket Hours from Time Entries
When a report displays per-ticket hours alongside ticket metadata (e.g., a "Recent Tickets" table with an Hours column), do NOT use `ticket.hours` or `ticket.actualHours` directly. ConnectWise tickets do not reliably carry a summed hours field.
**Correct approach:**
1. Fetch time entries separately via `/time/entries?conditions=company/id={id}`.
2. Build a lookup map in the Code node: `ticketId -> sum(actualHours)`.
3. In the ticket loop, reference the map: `ticketHoursMap[ticket.id] || 0`.
**Example:**
```javascript
const ticketHoursMap = {};
for (const entry of timeEntries) {
const te = entry.json;
const ticketId = te.ticket?.id;
if (ticketId != null) {
ticketHoursMap[ticketId] = (ticketHoursMap[ticketId] || 0) + (te.actualHours || 0);
}
}
// In ticket row: ticketHoursMap[ticket.id] || 0
```
### Primary Contact Email Lookup
To find a company's primary contact email in CW, do **not** rely on `company.email` — that field does not exist. The correct path is:
1. `GET /company/companies/{id}` → `defaultContact.id`
2. `GET /company/contacts/{id}` → `communicationItems`
3. Select item where `type.name == 'Email'` and `defaultFlag == true` → `value`
`invoiceToEmailAddress` on the company record is a usable fallback but often points to billing/AP, not the primary operational contact.
### Inactive Customer Audit (Gap Analysis)
To identify active customers who have had no recorded time entries for a specific period (e.g., 90 days):
1. Fetch all companies with `status/name='Active'`.
2. Fetch all time entries since the target date using `timeStart>[YYYY-MM-DDTHH:MM:SSZ]`.
3. Construct a set of Company IDs from the time entries.
4. Filter the active companies list to exclude those in the time entry set.
5. Sort the resulting gap list by company name for the final report.
## Scheduled n8n AR Estimate Job (tallerhawk instance)
Workflow "Connectwise Estimate AR" (work n8n instance, id `<workflowId>`) runs weekly Thu ~18:00 UTC and appends one row per source to the n8n Data Table "Connectwise Pending AR" (id `<tableId>`, project `<projectId>`), columns Source/amount, Source ∈ {Aggreements (sic), Time, Parts} — weekly pending-AR estimate snapshots. UPDATE 2026-10-19: user enabled availableInMCP=true, so get_workflow_details now returns full JSON. Confirmed logic: cron `0 11 * * THU` (instance TZ=Pacific → 18:00 UTC) + GET webhook `/webhook/<webhook-path>`. Sources: Aggreements = `/finance/agreementrecap` ACTIVE, nextInvoiceAmount<>0, nextInvoiceDate in [today, +31d exclusive), lastInvoiceAmount<>'0.00' → sum nextInvoiceAmount; Time = open tickets (closedflag=false AND status/id<>80) → per-ticket time entries → billableOption=Billable AND ticketStatus notContains Closed → sum extendedInvoiceAmount; Parts = same tickets → /procurement/products → billable → sum extPrice. All three write rows to the data table and email "Estimated Pending AR" to the recipient list. Time/Parts are open-ticket-keyed ONLY — non-ticket work is not counted.
Actual billed-but-unpaid AR comes from `/finance/invoices?conditions=balance > 0` (Invoice has a live `balance` field); filter invoice date ≥ current year — older "Closed - Mailed/Emailed" invoices carry stale residual balances (decades of residue ~$7.7M that is NOT real AR; billing handoff status is "Billed to AccountEdge").
## Verification
- Verify that the API returns a list of tickets and not a dictionary with a `tickets` key (handle both cases in the parser).
- Confirm that the `HIMALAYA_BIN` path is absolute and the temporary email file is uniquely named per recipient to avoid race conditions.
@@ -0,0 +1,26 @@
# ConnectWise PSA Ticket Extraction Details
## API Endpoint
- **Path:** `/service/tickets`
- **Method:** `GET`
- **Critical Headers:**
- `Authorization: Basic <base64_auth>`
- `clientId: <client_id>`
- `Accept: application/json`
## Filtering Conditions
To isolate open tickets on a specific board for a specific owner, use the `conditions` query parameter:
`closedFlag=false AND board/id=1 AND owner/identifier='{tech_identifier}'`
## Data Structure Quirks
The API response can be inconsistent. It may return:
1. A raw list: `[...]`
2. A dictionary containing a list: `{"tickets": [...]}`
**Implementation Note:** Always check if the response is a list; if it's a dictionary, extract the `tickets` key before iterating.
## Email Styling (Outlook Optimized)
To prevent Outlook from stripping styles:
- Use `border-collapse: collapse;` on tables.
- Use inline `style` attributes on `<td>` and `<tr>` tags.
- Avoid external CSS or `<style>` blocks.
- Use a standard system font stack: `'Segoe UI', Tahoma, Geneva, Verdana, sans-serif`.
@@ -0,0 +1,74 @@
# ConnectWise Customer Aggregation Recipe
Reference: How to aggregate open tickets by customer and enrich with primary contact details.
## Use Case
- "List customers who have had open tickets in the past N years"
- "Show me every company with active Help Desk tickets and their primary contact email"
## API Traversal
```python
import base64
import requests
from datetime import datetime, timedelta
# Auth
creds = {...} # from ~/.secrets/connectwise
auth_str = f"{company_id}{public_key}:{private_key}"
encoded_auth = base64.b64encode(auth_str.encode()).decode()
headers = {
'Authorization': f'Basic {encoded_auth}',
'clientId': client_id,
'Company-Id': company_id,
'Content-Type': 'application/json'
}
# 1. Fetch tickets
base_url = 'https://cw.vistanetinc.com/v4_6_release/apis/3.0'
two_years_ago = (datetime.now() - timedelta(days=730)).strftime('%Y-%m-%dT%H:%M:%SZ')
conditions = f"board/id=1 and closedFlag=false and dateEntered>'{two_years_ago}'"
url = f"{base_url}/service/tickets?conditions={conditions}&pageSize=1000"
tickets = requests.get(url, headers=headers).json()
# 2. Aggregate by company
companies = {}
for t in tickets:
status_name = t.get('status', {}).get('name', '')
if status_name.startswith('>'):
continue
comp_id = t.get('company', {}).get('id')
if not comp_id:
continue
companies.setdefault(comp_id, {
'name': t['company']['name'],
'ticket_count': 0,
'latest_date': t['_info']['dateEntered']
})
companies[comp_id]['ticket_count'] += 1
if t['_info']['dateEntered'] > companies[comp_id]['latest_date']:
companies[comp_id]['latest_date'] = t['_info']['dateEntered']
# 3. Enrich with primary contact email
for comp_id, data in companies.items():
comp = requests.get(f"{base_url}/company/companies/{comp_id}", headers=headers).json()
contact_id = comp.get('defaultContact', {}).get('id')
data['contact_name'] = comp.get('defaultContact', {}).get('name', 'N/A')
data['email'] = comp.get('invoiceToEmailAddress', 'N/A')
if contact_id:
contact = requests.get(f"{base_url}/company/contacts/{contact_id}", headers=headers).json()
for item in contact.get('communicationItems', []):
if item.get('type', {}).get('name') == 'Email' and item.get('defaultFlag'):
data['email'] = item['value']
break
```
## Board IDs
- VistaNet Help Desk = 1
- VistaNet Management = 25
- Managed Service Alerts = 6
## Known Gaps
- Some companies (e.g., BJ LaRossa, Far West Rice Inc.) have no default contact email in CW. Display `N/A` gracefully.
- `invoiceToEmailAddress` on the company record is often billing/AP, not the operational contact.
@@ -0,0 +1,115 @@
# ConnectWise Customer Audit n8n Workflow
## Architecture
Webhook (`companyName`, `toEmail`, `fromEmail`, `subject`, `startDate`, `endDate`) → Native CW Node (`Get a company`) → Parallel Branches (Tickets + Time) → Merge → Code Node (HTML Report) → Send Email
## Workflow Details
| Property | Value |
|----------|-------|
| **Name** | ConnectWise Customer Ticket Time Audit |
| **ID** | `<workflowId>` |
| **Trigger** | Webhook (POST) |
| **URL** | `https://<n8n-host>/webhook/<webhook-path>` |
| **Active Version** | `be322bfd-5522-47e6-9198-6550940284a6` |
## Key Nodes
### 1. Webhook Trigger
- **Type:** `n8n-nodes-base.webhook` (v2.1)
- **Method:** `POST`
- **Path:** `<webhook-path>`
- **Expected JSON body:**
```json
{
"companyName": "Customer Name",
"toEmail": "recipient@example.com",
"fromEmail": "reports@vistanetinc.com",
"subject": "Customer Audit Report",
"startDate": "2025-01-01T00:00:00Z",
"endDate": "2025-12-31T23:59:59Z"
}
```
- All fields except `companyName` are optional.
- **Inside the Code node, access the payload as:** `$('Webhook').first().json.body || $('Webhook').first().json`
### 2. Get a company
- **Type:** `@mspcopilot/n8n-nodes-connectwise.connectWisePsa`
- **Resource:** `company`
- **Operation:** `get`
- **Company:** `__rl` mode with `value: "=?conditions=name like '%{{ $json.body?.companyName ?? $json.companyName }}%'"`
- ⚠️ This uses a search condition inside a `get` call, which may fail if the API returns `500 Length cannot be less than zero`.
### 3. Get many tickets
- **Type:** `connectWisePsa`
- **Resource:** `ticket`
- **Operation:** `getAll`
- **Return All:** `true`
- **Conditions:** `"=company/id={{ $json.id }}"`
### 4. Get many time entries
- **Type:** `connectWisePsa`
- **Resource:** `timeEntry`
- **Operation:** `getAll`
- **Return All:** `true`
- **Conditions:** `"=company/id={{ $json.id }}"`
### 5. Combine Data
- **Type:** `n8n-nodes-base.merge` (v3.2)
- **Mode:** `combineByPosition`
### 6. Generate HTML Report
- **Type:** `n8n-nodes-base.code` (v2)
- **Mode:** Must use `runOnceForAllItems`.
- **Logic:** Accesses upstream nodes via `$('Node Name')`, aggregates `actualHours`, filters open/closed by `status.name`, and passes through email params from the webhook payload.
- **Hour field:** `actualHours` (NOT `hours`)
- **Per-ticket hours:** Must build a `ticketHoursMap` by summing `actualHours` from time entries grouped by `ticket.id`. Do NOT read `ticket.actualHours` directly.
- **Timeframe filtering:** When `startDate`/`endDate` are provided, filter time entries by `timeStart`, then filter the ticket list to only show tickets with hours in the range. Sort by hours descending.
- **Return shape:**
```javascript
return [{ json: {
htmlReport,
totalBilledHours,
totalTickets,
openTickets,
closedTickets,
companyName,
fromEmail, // from webhook payload (flattened)
toEmail, // from webhook payload (flattened)
subject // from webhook payload (flattened)
} }];
```
### 7. Send an Email
- **Type:** `n8n-nodes-base.emailSend` (v2.1)
- **fromEmail:** `={{ $json.fromEmail ?? "reports@vistanetinc.com" }}`
- **toEmail:** `={{ $json.toEmail ?? "<recipient>" }}`
- **subject:** `={{ $json.subject ?? "Customer Audit Report" }}`
- **html:** `={{ $json.htmlReport }}`
## Critical Pitfalls
1. **Syntax Error in Set Node:** Putting multi-line JS into a `Set` node `manual` mode assignment causes `invalid syntax [item 0]`. Always use `Code` node for report generation.
2. **Time Entries in n8n:** The native `connectWisePsa` node for `timeEntry` returns `actualHours`, not `hours`. The Code node must aggregate `actualHours`.
3. **Per-Ticket Hours Must Be Rolled Up:** Do NOT use `ticket.hours` or `ticket.actualHours` in the report table. Build a lookup map by iterating time entries and summing `actualHours` per `ticket.id`.
4. **Company Search Bug:** The ConnectWise API `/company/companies` endpoint may crash with `500 Length cannot be less than zero`. There is no client-side workaround — it must be resolved by ConnectWise support.
5. **Node Name References in Code:** Ensure `$('Node Name')` references match the exact display names of the nodes on the canvas.
6. **Activation Required:** After updating a workflow via MCP, you must call `publish_workflow` to activate the new version. Until published, the old version continues executing. Verify by checking `versionId` vs `activeVersionId` in `get_workflow_details`.
7. **GET vs POST Webhook:** When switching a webhook from GET to POST, update both the HTTP method in the Webhook node AND the `$json` accessor in downstream nodes (`$json.query?.companyName` → `$json.body?.companyName`). The payload shape changes from query params to JSON body.
8. **POST Webhook Body Nesting:** Inside a Code node, POST webhook data is under `$('Webhook').first().json.body`, not directly on `$json`. Use `$('Webhook').first().json.body || $('Webhook').first().json` to handle both cases.
9. **Payload Fallthrough:** If a Code node passes values through to a downstream node (e.g., `fromEmail`, `toEmail`, `subject`), the downstream expressions must reference `$json.fromEmail`, not `$json.body.fromEmail`, because the Code node flattens the payload into its output JSON.
10. **Timeframe Filtering — Filter the Ticket List, Not Just Hours:** When a timeframe is active, filter the displayed ticket list to only tickets with time entries in the range. Do not show all tickets with 0.00 hours. Sort the filtered list by hours descending.
## SDK Code Reference
```javascript
import { workflow, node, trigger, merge } from '@n8n/workflow-sdk';
const webhookTrigger = trigger({ type: 'n8n-nodes-base.webhook', ... });
const searchCompany = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
const fetchTickets = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
const fetchTime = node({ type: '@mspcopilot/n8n-nodes-connectwise.connectWisePsa', ... });
const combineData = merge({ ... });
const generateReport = node({ type: 'n8n-nodes-base.code', ... });
export default workflow('cw-audit', 'ConnectWise Customer Audit')
.add(webhookTrigger).to(searchCompany)
.to(fetchTickets.to(combineData.input(0)))
.add(searchCompany).to(fetchTime.to(combineData.input(1)))
.add(combineData).to(generateReport);
```
@@ -0,0 +1,90 @@
# ConnectWise Raw API Authentication & Time Tracking
## Correct Auth Construction
The credential file at `~/.secrets/connectwise` is a multi-line `.env` file, NOT a raw string.
```
CW_COMPANY_ID=VNI
CW_PUBLIC_KEY=+<PUBLIC_KEY>
CW_PRIVATE_KEY=<PRIVATE_KEY>
```
**WRONG:** Base64-encoding the entire file contents as the Authorization header.
**RIGHT:** Parse the key=value pairs first.
```python
import base64
import os
with open(os.path.expanduser("~/.secrets/connectwise"), "r") as f:
keys = {}
for line in f.read().strip().splitlines():
if '=' in line and not line.startswith('#'):
k, v = line.split('=', 1)
keys[k.strip()] = v.strip()
company_id = keys['CW_COMPANY_ID']
public_key = keys['CW_PUBLIC_KEY']
private_key = keys['CW_PRIVATE_KEY']
# CRITICAL: public_key already starts with '+', so do NOT add another
auth_string = f"{company_id}+{public_key}:{private_key}"
if '++' in auth_string:
auth_string = auth_string.replace('++', '+')
encoded_auth = base64.b64encode(auth_string.encode()).decode()
headers = {
"Authorization": f"Basic {encoded_auth}",
"clientId": "<CLIENT_ID>",
"Content-Type": "application/json"
}
```
## Time Entries (Not `/service/time`)
| Wrong | Right |
|-------|-------|
| `/service/time` | `/time/entries` |
| `hours` field | `actualHours` field |
| `dateEntered>'...'` (quotes) | `dateEntered>[...]` (square brackets) |
### Working Example
```python
from datetime import datetime, timedelta
twelve_months_ago = (datetime.utcnow() - timedelta(days=365)).strftime('%Y-%m-%dT%H:%M:%SZ')
url = (
"https://cw.vistanetinc.com/v4_6_release/apis/3.0"
f"/time/entries?conditions=company/id={company_id} and dateEntered>[{twelve_months_ago}]"
"&pageSize=1000"
)
resp = requests.get(url, headers=headers, timeout=30)
entries = resp.json()
total_hours = sum(e.get('actualHours', 0) or 0 for e in entries)
```
## Base URL
- **VistaNet internal:** `https://cw.vistanetinc.com/v4_6_release/apis/3.0`
- **Public endpoints** (`api-na.connectwise.com`) are **not reachable** from this environment.
## Known Company IDs
| Company | ID |
|---------|----|
| `<Client A>` | `<companyId>` |
| `<Client B>` | `<companyId>` |
## Summary of Errors This Fixes
| Symptom | Root Cause | Fix |
|---------|-----------|-----|
| `500 Length cannot be less than zero` | Auth header was garbage (base64 of entire .env file) | Parse key=value pairs |
| `403 Forbidden` on `/service/time` | Wrong endpoint | Use `/time/entries` |
| `None` or `0` for hours | Wrong field name | Use `actualHours` |
| `400 ApiFindCondition` for datetime | Quoted string instead of bracket syntax | Use `dateEntered>[...]` |
@@ -0,0 +1,39 @@
# ConnectWise Schedule Entries Bug — Session Notes
## Bug Summary
The `/schedule/entries` endpoint is **non-functional** for retrieving current/future calendar data. It returns a static mirror of historical entries from 2011–2012 regardless of date filters.
## Reproduction Steps
1. Query: `GET /schedule/entries?conditions=member/id=<memberId>`
2. Result: Returns ~200 entries, all dated 2011-12-12 through 2012-03-27.
3. Add date filter: `conditions=member/id=<memberId> and dateStart>[2026-08-09T00:00:00Z]`
4. Result: **Empty response** (no entries at all).
5. Query by ticket ID: `conditions=objectId=121247`
6. Result: **Empty response** (no schedule entries returned for known active ticket).
## Attempted Workarounds (All Failed)
| Approach | Result |
|----------|--------|
| General list with `member/id` | Returns 2011 data |
| Date range filter `dateStart>[...]` | Empty response |
| Date range filter `startDate>[...]` | Empty response |
| Ticket-linked query `objectId=<id>` | Empty response |
| `pageSize=1000` | No effect on date filtering |
| Different member IDs | Same behavior |
## Server Response Characteristics
- The API returns HTTP 200 with a valid JSON array.
- No error codes or status messages indicating a problem.
- The data is simply wrong (stale by ~14 years).
## Impact
- **Cannot retrieve current calendar/schedule data via REST API.**
- No known programmatic workaround exists.
- Technicians must use the ConnectWise web UI or desktop client for calendar access.
## Alternative Data Sources Tested
- **Nextcloud CalDAV**: Successfully queried personal calendar (`/remote.php/dav/calendars/scott/personal/`). Returns actual current events. Requires separate credentials.
- **ConnectWise Activities (`/sales/activities`)**: Tested but returned empty results for date ranges.
## Recommendation
Do not attempt to build automation around `/schedule/entries` for current calendar data. Treat this endpoint as **read-only historical reference** until ConnectWise fixes the bug.
@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Fetch time entries for a ConnectWise customer and calculate total billed hours.
Usage:
python3 fetch-customer-time-entries.py "<Client Name>"
python3 fetch-customer-time-entries.py --company-id 20172 --days 365
Requires ~/.secrets/connectwise (multi-line .env file):
CW_COMPANY_ID=VNI
CW_PUBLIC_KEY=+...
CW_PRIVATE_KEY=...
"""
import argparse
import base64
import os
import sys
from datetime import datetime, timedelta
import requests
def _load_cw_client_id():
"""Resolve the ConnectWise clientId from env or ~/.secrets/connectwise."""
cid = os.getenv("CW_CLIENT_ID", "")
if cid:
return cid
path = os.path.expanduser("~/.secrets/connectwise")
if os.path.exists(path):
with open(path) as f:
for line in f:
if line.lstrip().startswith("CW_CLIENT_ID="):
return line.split("=", 1)[1].strip()
raise SystemExit(
"CW_CLIENT_ID missing: export CW_CLIENT_ID or add "
"CW_CLIENT_ID=<uuid> to ~/.secrets/connectwise"
)
def load_credentials():
path = os.path.expanduser("~/.secrets/connectwise")
if not os.path.exists(path):
print(f"Credential file not found: {path}")
sys.exit(1)
with open(path, "r") as f:
raw = f.read().strip()
keys = {}
for line in raw.splitlines():
if "=" in line and not line.startswith("#"):
k, v = line.split("=", 1)
keys[k.strip()] = v.strip()
company_id = keys.get("CW_COMPANY_ID", "")
public_key = keys.get("CW_PUBLIC_KEY", "")
private_key = keys.get("CW_PRIVATE_KEY", "")
if not all([company_id, public_key, private_key]):
print("Missing required keys in credential file")
sys.exit(1)
# Avoid double-plus: public key already starts with '+'
auth_string = f"{company_id}+{public_key}:{private_key}"
if "++" in auth_string:
auth_string = auth_string.replace("++", "+")
encoded = base64.b64encode(auth_string.encode()).decode()
return {
"company_id": company_id,
"auth_header": f"Basic {encoded}",
"client_id": _load_cw_client_id(),
}
def build_headers(creds):
return {
"Authorization": creds["auth_header"],
"clientId": creds["client_id"],
"Content-Type": "application/json",
}
def find_company_by_name(base_url, headers, name):
"""Search for a company by name using a LIKE query."""
url = f"{base_url}/company/companies?conditions=name like '%{name}%'"
resp = requests.get(url, headers=headers, timeout=15)
resp.raise_for_status()
companies = resp.json()
if not companies:
return None
if len(companies) > 1:
print(f"WARNING: Found {len(companies)} companies matching '{name}':")
for c in companies[:5]:
print(f" - {c.get('name')} (ID: {c.get('id')})")
return companies[0]
def fetch_time_entries(base_url, headers, company_id, days_back=365):
"""Fetch time entries for a company in the last N days."""
cutoff = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%dT%H:%M:%SZ")
# CRITICAL: Use square brackets for datetime values in conditions
conditions = f"company/id={company_id} and dateEntered>[{cutoff}]"
url = f"{base_url}/time/entries?conditions={conditions}&pageSize=1000"
resp = requests.get(url, headers=headers, timeout=30)
resp.raise_for_status()
return resp.json()
def main():
parser = argparse.ArgumentParser(description="Fetch CW time entries for a customer")
parser.add_argument("name", nargs="?", help="Company name to search for")
parser.add_argument("--company-id", type=int, help="Exact ConnectWise company ID")
parser.add_argument("--days", type=int, default=365, help="Number of days back to query")
args = parser.parse_args()
if not args.name and not args.company_id:
parser.print_help()
sys.exit(1)
creds = load_credentials()
headers = build_headers(creds)
base_url = "https://cw.vistanetinc.com/v4_6_release/apis/3.0"
if args.company_id:
company_id = args.company_id
company_name = f"ID {company_id}"
else:
company = find_company_by_name(base_url, headers, args.name)
if not company:
print(f"No company found matching '{args.name}'")
sys.exit(1)
company_id = company["id"]
company_name = company["name"]
print(f"Fetching time entries for {company_name} (ID: {company_id}) in last {args.days} days...")
entries = fetch_time_entries(base_url, headers, company_id, args.days)
# CRITICAL: The hour field is 'actualHours', not 'hours'
total_hours = sum(e.get("actualHours", 0) or 0 for e in entries)
print(f"\nTotal entries: {len(entries)}")
print(f"Total billed hours: {total_hours:.2f}")
if entries:
print("\nRecent entries:")
for e in entries[:5]:
ticket = e.get("ticket", {})
date = e.get("dateEntered", "")[:10]
hours = e.get("actualHours", 0) or 0
notes = (e.get("notes") or "No notes")[:60]
print(f" Ticket #{ticket.get('id', 'N/A')}: {hours}h on {date} — {notes}")
if __name__ == "__main__":
main()
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""
Cross-company top N by billed hours — past year or custom range.
Usage:
python top-companies-by-hours.py [DAYS] [TOP_N]
Arguments:
DAYS Number of days back to query (default: 365)
TOP_N Number of companies to show (default: 30)
Outputs a ranked table with company name, lead tech (whoever logged
most hours), and total billed actualHours.
Auth: parses ~/.secrets/connectwise (CW_COMPANY_ID, CW_PUBLIC_KEY, CW_PRIVATE_KEY)
"""
import base64
import requests
import sys
from datetime import datetime, timedelta
from collections import defaultdict
import os
def load_creds(path="~/.secrets/connectwise"):
with open(os.path.expanduser(path), "r") as f:
lines = f.read().strip().split("\n")
creds = {}
for line in lines:
if "=" in line:
k, v = line.split("=", 1)
creds[k] = v
return creds
def _load_cw_client_id():
"""Resolve the ConnectWise clientId from env or ~/.secrets/connectwise."""
cid = os.getenv("CW_CLIENT_ID", "")
if cid:
return cid
path = os.path.expanduser("~/.secrets/connectwise")
if os.path.exists(path):
with open(path) as f:
for line in f:
if line.lstrip().startswith("CW_CLIENT_ID="):
return line.split("=", 1)[1].strip()
raise SystemExit(
"CW_CLIENT_ID missing: export CW_CLIENT_ID or add "
"CW_CLIENT_ID=<uuid> to ~/.secrets/connectwise"
)
def get_headers(creds):
company_id = creds["CW_COMPANY_ID"]
public_key = creds["CW_PUBLIC_KEY"]
private_key = creds["CW_PRIVATE_KEY"]
auth_str = f"{company_id}{public_key}:{private_key}"
return {
"Authorization": f"Basic {base64.b64encode(auth_str.encode()).decode()}",
"clientId": _load_cw_client_id(),
"Content-Type": "application/json",
}
def fetch_time_entries(headers, days_back=365):
base_url = "https://cw.vistanetinc.com/v4_6_release/apis/3.0"
cutoff = (datetime.utcnow() - timedelta(days=days_back)).strftime("%Y-%m-%dT%H:%M:%SZ")
url = f"{base_url}/time/entries?conditions=timeStart%3E[{cutoff}]"
all_entries = []
page = 1
page_size = 1000
max_pages = 20
while page <= max_pages:
paged_url = f"{url}&pageSize={page_size}&page={page}"
try:
resp = requests.get(paged_url, headers=headers, timeout=30)
if resp.status_code != 200:
print(f"API error {resp.status_code}: {resp.text[:200]}", file=sys.stderr)
break
data = resp.json()
entries = data if isinstance(data, list) else data.get("data", [])
if not entries:
break
all_entries.extend(entries)
if len(entries) < page_size:
break
page += 1
except Exception as e:
print(f"Request error: {e}", file=sys.stderr)
break
return all_entries, cutoff
def aggregate(entries):
company_hours = defaultdict(float)
company_names = {}
company_member_hours = defaultdict(lambda: defaultdict(float))
for entry in entries:
comp = entry.get("company", {})
comp_id = comp.get("id")
if comp_id is None:
continue
hours = entry.get("actualHours", 0) or 0
company_hours[comp_id] += hours
company_names[comp_id] = comp.get("name", "Unknown")
member = entry.get("member", {})
member_name = member.get("name", "Unknown")
if member_name and member_name != "Unknown":
company_member_hours[comp_id][member_name] += hours
lead_techs = {
comp_id: max(members.items(), key=lambda x: x[1])[0]
for comp_id, members in company_member_hours.items()
}
return company_hours, company_names, lead_techs
def main():
days = int(sys.argv[1]) if len(sys.argv) > 1 else 365
top_n = int(sys.argv[2]) if len(sys.argv) > 2 else 30
creds = load_creds()
headers = get_headers(creds)
print(f"Fetching time entries from {days} days ago...", file=sys.stderr)
entries, cutoff = fetch_time_entries(headers, days)
print(f"Retrieved {len(entries)} entries", file=sys.stderr)
company_hours, company_names, lead_techs = aggregate(entries)
ranked = sorted(company_hours.items(), key=lambda x: x[1], reverse=True)[:top_n]
print(f"\n{'Rank':<6}{'Company':<50}{'Lead Tech':<25}{'Hours':>10}")
print("-" * 91)
for rank, (comp_id, hours) in enumerate(ranked, 1):
name = company_names.get(comp_id, "Unknown")
lead = lead_techs.get(comp_id, "N/A")
print(f"{rank:<6}{name:<50}{lead:<25}{hours:>10.2f}")
if __name__ == "__main__":
main()