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:
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>[...]` |
|
||||
Reference in new issue
Block a user