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,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>[...]` |