91 lines
2.6 KiB
Markdown
91 lines
2.6 KiB
Markdown
# 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>[...]` |
|