256 lines
34 KiB
Markdown
256 lines
34 KiB
Markdown
---
|
||
name: connectwise-msp
|
||
description: Management of ConnectWise PSA API for VistaNet
|
||
---
|
||
|
||
# ConnectWise PSA API Management
|
||
|
||
Comprehensive guide for interacting with the ConnectWise PSA API, specifically for VistaNet's environment.
|
||
|
||
## Authentication
|
||
- **Authentication Format**: `Basic base64(companyidpublickey:privatekey)`. Based on successful session testing, the `+` symbol in the reference is part of the public key itself. The correct concatenation for the auth string is `companyidpublickey:privatekey` (e.g., `VNI+rtDj...:privatekey`). Do NOT add an extra `+` between the company ID and public key.
|
||
- **Requirement**: A valid `clientId` header must be passed alongside the authorization. The API also requires `Accept: application/vnd.connectwise.com+json; version=2022.1` or `Accept: application/json` for proper response handling.
|
||
- **Connection Helper**: Use the provided wrapper script in `scripts/cw_api.py` as the PRIMARY method for all ConnectWise API calls. It handles authentication, CID injection, JSON sanitization, and correct header construction automatically. Raw curl commands are error-prone due to header formatting and base64 encoding edge cases (e.g., newline handling in base64 output).
|
||
- **Interpreter**: Run `cw_api.py` with `/usr/bin/python3` — the system python carries `requests`; the Hermes tooling python and ad-hoc project venvs on this host do not, so the import fails there. Importing it as a module from other working directories also needs it on the path — `PYTHONPATH=/home/tech/.hermes/scripts` (a module copy lives there) or `cd` into this skill dir; a bare `from cw_api import cw_request` from a scratch cwd raises ModuleNotFoundError.
|
||
- **Base URL Pattern**: For the VistaNet instance, the API path typically includes the release version (e.g., `https://cw.vistanetinc.com/v4_6_release/apis/3.0`). Verify the exact version if requests return 404.
|
||
- **Endpoints Reference**: Local copy of OpenAPI spec at `references/cwendpoints.json` for authoritative endpoint discovery.
|
||
- **Developer Guide**: HTML reference guide at `references/CWDeveloperNetworkGuide.htm` for detailed API logic and patterns.
|
||
- **Note**: The `+` symbol must be literal in the string before base64 encoding.
|
||
|
||
|
||
|
||
## Troubleshooting & Pitfalls
|
||
|
||
- **Email Delivery Failures**: If `himalaya message send` fails with a panic (`index out of bounds`), this is a known bug in the tool's mail parser. Fall back to providing the report as a Markdown table directly in the chat interface to ensure the user has the data.
|
||
- **API Permission Errors (403 Forbidden)**: Even with valid `clientId` and Basic Auth, certain endpoints like `/company/tickets` may return `403 Forbidden`. This is typically a role-based permission issue within ConnectWise PSA. If this occurs, notify the user that the API member lacks the necessary permissions to fetch company-wide ticket lists.
|
||
- **system/* surface for the VNI keypair (updated live 2026-10-04 after admin granted Members Inquire)**: `system/members` (list + by-id) → ✅ 200; **`system/members/{id}/skills` → ✅ 200 — the working endpoint for member skills listings** (loop over active members, sleep ~0.2s). Still 403 "Security": `system/skills` (catalog), `system/myAccount`, legacy `apis/2.0/Members`, Reports API `/Data` execution (GET /system/reports name-listing still 200; MemberSkills/Data now 404 — report retired after catalog cleanup that consolidated versioned entries like 'Windows Server 2016+'). Catalog listing unavailable — read skill names from expanded skill objects in per-member responses instead.
|
||
- **Distinguishing auth-format vs permission failures**: wrong auth string (e.g. literal `+` added when pubkey already starts with `+`) yields **401 "Incorrect Authorization header format"**; missing security role yields **403 "Security"** with a 200 on control endpoints. If control endpoint returns 200 and target returns 403, it is permissions — do not keep trying auth permutations.
|
||
- **n8n Native ConnectWise PSA Node on Agreement Endpoints**: The native `@mspcopilot/n8n-nodes-connectwise.connectWisePsa` node may return `403 Forbidden` when calling agreement endpoints (`/agreement/agreements`) even though the same credentials work via raw HTTP Request and `/finance/agreements`. When building n8n workflows for agreement/reconciliation tasks, prefer raw **HTTP Request** nodes using the `Connectwise API` credential and the `/finance/...` path family.
|
||
|
||
|
||
|
||
## Security Bulletins & Version Triage
|
||
|
||
- **Product-line gate**: ScreenConnect, Automate, and PSA have separate CVE
|
||
streams. Verify which products the stack actually runs before assigning
|
||
exposure from a ConnectWise headline — this stack runs no ScreenConnect
|
||
(BeyondTrust/Bomgar covers remote sessions) and no Automate, so those
|
||
bulletins are read-and-ignore.
|
||
- **On-prem means no auto-remediation**: bulletin guidance is written as
|
||
"cloud instances update automatically; on-premise apply the release
|
||
yourself" — on-prem PSA inherits every affected-versions clause ("all
|
||
versions prior to X") with no ConnectWise safety net.
|
||
- **After any bulletin/upgrade**: raise PSA Desktop clients to the matching
|
||
release build (server and client render the same content and are both
|
||
listed as affected modules) and make users sign out and back in —
|
||
pre-upgrade sessions keep the vulnerability state until re-auth.
|
||
- **On-prem-only attack surface exists**: some advisories are
|
||
on-premise-only (the Windows MSMQ/TCP-1801 execution advisory was) — treat
|
||
PSA server listening ports as permanent in-scope surface for
|
||
Nexpose/Sophos and never DNAT them to the WAN.
|
||
- **Functional release notes are SSO-gated**: docs.connectwise.com release
|
||
notes (e.g. `ConnectWise_PSA_(Manage)_Release_Notes/..._2026_Release_Notes`)
|
||
have no public mirror; every anonymous path returns an SSO login page.
|
||
Read them with a vaulted ConnectWise login via the browser tool, or in the
|
||
ConnectWise Home release-notes panel during the upgrade.
|
||
|
||
## Finance Endpoints & Patterns
|
||
- **Agreement Status**: Use the `agreementStatus` property to determine if an agreement is Active, Expired, etc.
|
||
- **Filtering Agreements**: Server-side conditions for `companyId` (e.g., `company/id={id}`) can be unreliable or omit certain agreement types. If agreements are expected but not found, fallback to searching by name using a broad partial match (e.g., `/finance/agreements?conditions=name like '%ClientName%'`).
|
||
- **Agreement Search Path Trap**: `GET /agreement/agreements` may return `403 Forbidden` even when the API member has Finance/Agreement Product read rights. On this instance the working search path is **`GET /finance/agreements`** with `conditions=name='...' or name='...'`. The header and credential requirements are identical; only the path differs. If one path 403s, try the other.
|
||
- **Agreement company filter IS reliable here**: unlike `/service/tickets` and `/company/configurations` (whose companyId params are silently ignored), `GET /finance/agreements?conditions=company/id={id}` returns exactly that company's agreements — but note the `fields` param interacts with sub-objects: requesting `fields=id,summary,...,company` strips nested objects on some endpoints, so drop `fields` when nested data is needed.
|
||
- **Agreement Additions**: Use `GET /finance/agreements/{id}/additions` to retrieve line-item details for usage-based billing. Note: The product name/identifier is stored in `product.identifier` rather than `product.name`.
|
||
- **Usage-Based Billing**: Agreements with a `billAmount` of `0.0` may still be active and billed based on actual usage/license counts. Check recent invoices if the agreement amount is zero but the service is known to be active.
|
||
- **Backup Billing Reconciliation**: When comparing VSPC actual usage to ConnectWise billed usage, the canonical flow is:
|
||
1. `GET /finance/agreements?conditions=name contains 'Veeam'` plus the `Veem` typo variant, then cross-check generic-named Backup-type agreements whose additions carry VEEAM product identifiers (name filters alone miss several active agreements).
|
||
2. For each agreement: `GET /finance/agreements/{id}/additions`.
|
||
3. Filter additions for live lines: `billCustomer='Billable'`, `extPrice>0`, `cancelledDate` empty or future; sum per COMPANY (not per agreement) since On-Site + Off-Site agreements share consumption.
|
||
4. Pull VSPC actual usage live via the `/uiapi` endpoints (see the vspc-billing-audit skill) — the legacy CSV snapshots in Nextcloud go stale.
|
||
5. See `references/backup-billing-reconciliation.md` for a complete n8n workflow pattern.
|
||
|
||
## n8n Workflow Patterns for ConnectWise
|
||
|
||
### Agreement Additions with Raw HTTP Request
|
||
When the native ConnectWise PSA node is blocked or behaves differently from raw API calls, use HTTP Request nodes with the `Connectwise API` (`httpBasicAuth`) credential:
|
||
|
||
1. Add `clientId` and `companyId` headers explicitly.
|
||
2. Use `GET https://cw.vistanetinc.com/v4_6_release/apis/3.0/finance/agreements` with query parameter `conditions=name='Veeam Off-Site Backup' or name='MSP On-Site Backup'`.
|
||
3. Follow with `GET /finance/agreements/{{ $json.id }}/additions`.
|
||
4. On both nodes enable **Continue on Fail** and **Always Output Data** (`options.continueOnFail: true`, `options.alwaysOutputData: true`) so empty or failed additions do not kill the execution chain.
|
||
5. Add a **Code** node to merge additions back onto the parent agreement object, because the HTTP Request node replaces the input item with its response:
|
||
```js
|
||
const results = [];
|
||
for (let i = 0; i < $input.all().length; i++) {
|
||
const additionItem = $input.all()[i];
|
||
const agreementItem = $("Fetch CW Backup Agreements").all()[i];
|
||
const agreement = agreementItem ? agreementItem.json : {};
|
||
let additions = [];
|
||
if (Array.isArray(additionItem.json)) additions = additionItem.json;
|
||
else if (additionItem.json && typeof additionItem.json === "object") additions = [additionItem.json];
|
||
results.push({ json: { ...agreement, additions } });
|
||
}
|
||
return results;
|
||
```
|
||
6. **Credential assignment**: HTTP Request nodes do not auto-assign in n8n SDK-created workflows. Open the workflow canvas and manually select the `Connectwise API` credential on each HTTP Request node.
|
||
|
||
## Company & Configuration Lookup Patterns
|
||
### Finding a Company by Name
|
||
Exact name matching (e.g., `name contains "ABCO & Sons"`) often returns empty because the ConnectWise `name` field may use ampersands, commas, or abbreviations.
|
||
- **Reliable Pattern**: Search with a broad partial match first (e.g., `name contains "ABCO"`), then inspect results to identify the correct company by its `identifier`.
|
||
- **Once identified**, cache the `company.id` for all subsequent queries.
|
||
|
||
### Configuration & Agreement Retrieval
|
||
To find infrastructure or service details for a client:
|
||
- **Configurations**: `/company/configurations?companyId={id}` — use this to list devices, ISP configs, switches, printers, workstations, servers.
|
||
- **Agreements**: `/finance/agreements?conditions=company/id={id}` — use this for contracts, bandwidth plans, backup agreements, etc.
|
||
- **Agreement Types**: Filter by `type.name` (e.g., `ISP Bandwidth/Fiber`, `MSP On-Site Backup`) to narrow results.
|
||
- **Key Fields**:
|
||
- Configs: `name`, `type.name`, `status.name`, `ipAddress`, `modelNumber`, `vendorNotes`
|
||
- Agreements: `name`, `agreementStatus`, `type.name`, `billAmount`, `startDate`, `nextInvoiceDate`
|
||
|
||
## Critical Pitfalls & Workarounds
|
||
### 1. Unreliable Conditional Filtering
|
||
The `conditions` parameter (e.g., `owner/identifier="SBrown"`) is inconsistent and often returns empty lists or ignores negative operators like `!=`.
|
||
- **Reliable Approach**: Fetch results using `pageSize` and filter JSON locally in Python. Note: The `companyId` parameter on `/service/tickets` is **silently ignored** by the API and returns tickets from all companies. Always filter client-side by `company.id` after fetching. See `references/ticket_fetching.md` for the working pattern.
|
||
- **Filtering Pitfall**: Server-side `conditions` for identifiers (e.g., `owner/identifier='SBrown'`) require the value to be enclosed in single or double quotes. Failing to do so results in an `ApiFindCondition` syntax error.
|
||
- **DateTime conditions require square brackets**: date/time literals in `conditions` (e.g., `/schedule/entries` date filters) must be enclosed in `[...]` — bare ISO strings 400 with `Unrecognized value`. Working: `dateStart >= [2026-10-02T00:00:00Z]`.
|
||
- **Configurations Endpoint Mirroring**: The `companyId` **query param** on `/company/configurations` is **also silently ignored**, returning configs from all companies — BUT the `conditions` string DOES work here (verified 2026-10-20: `conditions=company/id=<companyId>` returned only that company's configs). Prefer the conditions form; fall back to client-side filtering by `company.id` after fetching. This mirrors the ticket endpoint param behavior, not the conditions behavior.
|
||
- **Scaling Pitfall**: Local filtering of large datasets often causes timeouts.
|
||
- **Defining "Open" Tickets**: For this user, an "open" ticket is defined by meeting ALL of these criteria: `closedFlag=false`, status name does NOT start with `>` (e.g., `>Closed`, `>Completed`), and `dateResolved` is NULL/absent.
|
||
- **CRITICAL API MIRRORING BUG**: The general list endpoint (`/service/tickets`) and `closedFlag=false` filter frequently route to a legacy mirror/cache (returning tickets from ~2011) and omit current production tickets.
|
||
- **Production Data Bypass (Tickets)**: The legacy mirror bug is resolved for tickets. However, it PERSISTS for the `/schedule/entries` endpoint.
|
||
- **Calendar Workaround**: Member-filtered schedule queries (`member/id={id} AND dateStart >= [UTC] AND dateStart <= [UTC]`) have returned current production data — but ALWAYS sanity-check returned `dateStart` values. If entries show 2011-era dates, the mirror bug is active for `/schedule/entries`; fall back to requesting a manual CSV/PDF calendar export from the user.
|
||
- **Contact Email Extraction**: To retrieve a contact's email address, do NOT look for an `emailAddress` field on the Contact object. Instead, fetch the `/company/contacts/{contactId}/communications` endpoint and locate the entry where `type.name == "Email"` or `communicationType == "Email"`; the address is stored in the `value` field.
|
||
- **Filtering Logic**: Once production data is retrieved, combine the following to reliably identify active work:
|
||
- **Client-side (Logic)**: Filter out tickets where the status name starts with `>` (e.g., `>Closed`, `>Completed`) AND tickets that have a `dateResolved` value.
|
||
- **Reporting Convention (Open Tickets)**:
|
||
- **Board**: VistaNet Help Desk
|
||
- **Retrieval**: Use `conditions=id >= [RecentID]` to avoid legacy mirroring.
|
||
- **Logic**: (Status NOT starting with `>` AND `dateResolved` is NULL)
|
||
- **Ordering**: Oldest to Newest (`dateEntered`)
|
||
- **Fields**: Ticket #, Company, Status, Summary
|
||
- **Importance ranking (user-accepted)**: when asked to list tickets by importance, sort by priority `sort` asc, then `isInSla=false` first, then ticket id asc.
|
||
- **Reply shape**: lead with a status count table computed from the CURRENT fetched list (Counter, never a prior report's numbers), flag SLA breaches inline on each row, and close with a short changes-since-last-report note when a prior report ran in the same session.
|
||
- **All times shown to the user are Pacific**: API timestamps are Z-suffixed UTC — convert (UTC-7 PDT / UTC-8 PST) before presenting. API writes (schedule entries, time entries) stay UTC.
|
||
- **Disappeared ticket**: when a previously-listed ticket drops out, read its detail before reporting it gone — `closedFlag` can stay false while status goes `>`-prefixed with `dateResolved` set (resolved, not vanished).
|
||
- **Owner Identification Pitfall**: The `member` object is frequently missing from the top-level ticket JSON in certain versions of the API.
|
||
- **Reliable Identification**: The ticket owner(s) are stored in the `resources` field as a comma-separated string of member identifiers (e.g., `"resources": "BRiffel, SBrown"`).
|
||
- **Filtering Logic**: To identify tickets owned by a specific technician, split the `resources` string by commas, strip whitespace, and check for an exact match of the identifier.
|
||
|
||
### 2. Authentication Format
|
||
The API uses Basic Auth with a specific concatenated string.
|
||
- **Format**: `Authorization: Basic base64(companyidpublickey:privatekey)`
|
||
- **Crucial**: The `+` symbol is part of the public key itself. The correct concatenation is `companyidpublickey:privatekey`. If the public key starts with a `+`, it is included as part of the key string. Do NOT add an extra `+` between the company ID and the public key.
|
||
- **Requirement**: A valid `clientId` header must be passed alongside the authorization. If the `clientId` is incorrect or disabled, the API returns a `401 Unauthorized` even if the Basic Auth string is correct.
|
||
- **Credential Storage**: Credentials and technical metadata are stored in `/home/tech/connectwise_reference.md` and `~/.secrets/connectwise`. To avoid Hermes runtime credential masking (which truncates keys to `prefix...suffix`), always read credentials from disk at runtime rather than embedding them in prompts or script strings.
|
||
|
||
### 3. Updating Tickets (JSON Patch)
|
||
ConnectWise PSA uses the JSON Patch (RFC 6902) format for `PATCH` requests.
|
||
- **Format**: The payload must be a JSON array of operation objects.
|
||
- **Working Example (Updating Owner)**:
|
||
```json
|
||
[
|
||
{
|
||
"op": "replace",
|
||
"path": "/owner",
|
||
"value": {"identifier": "<memberIdentifier>"}
|
||
}
|
||
]
|
||
```
|
||
- **Pitfall**: Sending a standard JSON object (e.g., `{"owner": {"identifier": "<memberIdentifier>"}}`) will result in a `400 Bad Request` stating that the input is not in the expected array format.
|
||
- **Pitfall (field-specific)**: The sub-property rule is not universal. For `/owner`, replacing the whole object (`/owner` → `{"identifier": "<memberId>"}`) or the sub-path (`"path": "owner/identifier", "value": "<memberIdentifier>"`) both work when the payload is a native Python list. For ticket **status**, the sub-property path IS the verified working pattern: `[{"op":"replace","path":"status/id","value":<statusId>}]` — resolve the numeric ID first via `GET /service/boards/{boardId}/statuses` (VistaNet Help Desk board = 1; "In Progress" = id 1, "On Hold" = 10, "Waiting Client Response" = 439, "Scheduled" = 13; status names starting with `>` are closed-type). Match the user's requested status name case-insensitively against the actual board status names before patching — the board may not carry their exact wording (no "Waiting On Client" status exists; the semantic equivalent is "Waiting Client Response"): apply the equivalent and say so in the reply.
|
||
- **Pitfall (payload type)**: `json_data` must be a **native list/dict**, never a pre-encoded JSON string — a string arrives quoted and the API 400s with "not in the expected array format for a JSON Patch document" even when the string itself is a valid array (observed on owner PATCH 2026-09-29; the skill doc's stringified example is illustrative, not literal).
|
||
- **Owner assignment workflow**: GET the ticket first to capture before-state (owner/resources/status), PATCH `[{"op":"replace","path":"/owner","value":{"identifier":"<id>"}}]`, then re-GET and confirm the changed field. The PATCH response already echoes the full updated `owner` object (numeric id + identifier + name), but the read-back is the verification. Side effect: the API auto-adds the new owner to the comma-separated `resources` string and leaves the former owner listed there — expect it and report it as part of the change; do not treat it as a manual resources edit.
|
||
- **Verification**: After patching, read the ticket back (e.g., `GET /service/tickets/{id}?fields=id,status`) and confirm the changed field.
|
||
|
||
### 4. Scheduling Logic
|
||
Creating schedule entries requires specific fields that differ from standard ticket updates:
|
||
- **Date Fields**: Use `dateStart` and `dateEnd` (ISO 8601 UTC).
|
||
- **Object Linking**: Use `objectId` and `ticketType: "S"` (uppercase, for Service Tickets) instead of `chargeTo`.
|
||
- **Required Type**: The `type` field is required. Use `"type": {"id": 4}` (Service Ticket schedule type).
|
||
- **Span**: Use `"span": {"id": 2}` (identifier "B") — accepted on this instance; `"span": {"id": 1}` (identifier "N") also appears on production ticket entries. Either is accepted.
|
||
- **Pitfall**: The `ScheduleEntry` object does NOT support a `description` field. Including one in the request payload will cause a `400 Bad Request` stating the member could not be found on the object.
|
||
|
||
#### Ticket-less Calendar Meetings (no ticket to attach)
|
||
`objectId` is REQUIRED on POST /schedule/entries (400 MissingRequiredField without it), and the API resolves any nonzero objectId as a TICKET id — POSTing objectId=<some other record's id> silently links the entry to whatever ticket has that number and overwrites `name` with that ticket's summary, no matter what name you sent. There is no "personal activity" objectId; do not try company or member ids as a workaround.
|
||
- **Working pattern**: `"type": {"id": 7}` (identifier "M" = Meeting), `"objectId": 0`, `"span": {"id": 2}`. With objectId 0, `name` is free-form and honored.
|
||
- **Discovering schedule types**: `GET /schedule/types` returns a 403 Security error on this instance, and `GET /calendar/generalTypes` returns empty. Find real types by sampling production entries: `GET /schedule/entries?conditions=dateStart >= [recent UTC]` and tally the `type` objects (observed: 4='S' Service, 7='M' Meeting, 1='C', 6='V', 2='X'), then model the payload on a real entry of that type.
|
||
- **Defaults matching the user's existing entries**: `status {"id": 2}` (Firm), `where {"id": 4}` (Remote), `reminder {"id": 4}` (15 min), `member {"id": <memberId>}`.
|
||
- **Wrong-entry cleanup**: `DELETE /schedule/entries/{id}` works and returns an empty body; verify with GET (expect NotFound) before recreating. Never leave a mislinked entry on the user's calendar.
|
||
- **Auto-created placeholder entries**: ticket creation auto-generates `ScheduleEntry` rows per resource with `dateStart`/`dateEnd` = null (observed 3 on creation, id-adjacent). These are resource-assignment artifacts, not calendar blocks — when auditing a ticket's schedule, filter/skip null-date entries before counting real scheduled work.
|
||
- **Always verify**: re-GET the member's day window after creating any schedule entry and confirm id/times/type/name. POST returns 200 even when the entry landed on the wrong object — a successful response is not proof of a correct entry.
|
||
|
||
### 5. Permission Gaps
|
||
Certain endpoints (like `/changelog` or `/service/boards/{id}/tickets`) may return `403 Forbidden` even if the API member can read individual tickets. This is a role-based permission issue in ConnectWise.
|
||
- **Member Lookup Gap**: The `/system/members` endpoint also requires elevated security permissions. If a `403 Forbidden` is returned there, fall back to resolving member identifiers from ticket-level `resources` fields or from the `owner` object returned on individual ticket reads (which does not require the `/system/members` permission).
|
||
- **Member ids**: never guess a numeric id — resolve unknown members from `member.id` embedded on their notes, time entries, or ticket owner objects (or `GET system/members`); verify against a live object before use in a PATCH.
|
||
- **Member skills live state (2026-10-04, recommendations applied by user in CW)**: skill coverage is 7/12 humans — the covered members' per-member skill lists span Microsoft 365, Veeam B&R, Sophos Firewalls, n8n/Workflow Automation, Certificate Management, AI Integration/Engineering, RingCentral, VMware, Exchange 2016+, Quotewerks, Ingram Micro Procurement, KnowBe4 and Desktop/Printers (member names and per-member counts redacted from the hub copy; two profiles still skillless: one needing billing/agreement skills, one subcontractor profile). Skill-based triage routing can target the 7 covered members; 13 API/integration members correctly carry no skills.
|
||
|
||
### 6. Ticket Activity & Last Author Inspection
|
||
To determine who last touched a ticket — useful for triaging stale tickets or verifying whether the owner was the last person to update it:
|
||
|
||
#### Endpoint Chain (try in order)
|
||
1. **Ticket Notes** (primary): `GET /service/tickets/{id}/notes?sort=dateCreated desc&pageSize=1&fields=createdBy,enteredBy,dateCreated`
|
||
2. **Documents** (fallback): `GET /service/tickets/{id}/documents?sort=createdDate desc&pageSize=1&fields=createdBy,enteredBy,createdDate`
|
||
3. **Time Entries** (final fallback): `GET /time/entries?conditions=chargeToId={id}&sort=timeEnd desc&pageSize=1&fields=enteredBy,member,timeEnd`
|
||
|
||
#### Author Normalization Pitfall
|
||
The author field name varies by endpoint (`createdBy`, `enteredBy`, `member`). The value may be a string or a member dict. Always normalize:
|
||
```python
|
||
author = note.get('createdBy') or note.get('enteredBy')
|
||
if isinstance(author, dict):
|
||
author = author.get('identifier') or author.get('name')
|
||
author = str(author).strip()
|
||
```
|
||
|
||
#### Notes Field Gotchas
|
||
- Notes use **`dateCreated`**, not `dateEntered` — filtering notes on `dateEntered` silently matches nothing. `orderBy` on the notes endpoint is rejected (400 InvalidOrderBy); sort client-side. Time entries DO use `dateEntered`.
|
||
- **Notes GET returns oldest-first**: `GET /service/tickets/{id}/notes` lists from the oldest note regardless of pageSize. To verify a POST landed, fetch with a pageSize at/above the note count and match the returned note `id` (the new note is the last element) — a small pageSize spot-check shows only the oldest notes and makes a successful POST look lost.
|
||
- Contact-authored notes (email-to-ticket) have no `member` object — only `contact`. Exclude them, along with the owner's own notes, when counting other-technician activity.
|
||
- Automated integration notes (e.g. VNI Bot) are members but not technicians — report them separately from human-tech activity.
|
||
- A ticket with recent activity can return 0 notes and 0 time entries; check `dateResponded`/`slaStatus` on the ticket detail (which carries no `lastUpdate` object on this instance).
|
||
|
||
#### Typical Workflow
|
||
- Fetch open tickets for a given owner (e.g. SBrown) using the robust patterns above.
|
||
- For each ticket, run the endpoint chain and capture the most recent author.
|
||
- Compare the last author against the ticket owner. If they differ, the ticket has been touched by someone else since the owner last updated it.
|
||
- This produces a clean "tickets needing your attention" list.
|
||
|
||
### 7. Alert-Storm Triage (validated live 2026-10-06)
|
||
- **Dedupe rule that works**: group open P1s by `(company, lowercase unescaped summary)`; keeper = highest id (newest); each closed dup gets an internal pointer note, then status → the alerts board's `>`-closed status (board 6 id=109). 42 closes in one run, 0 failures at ~0.3s pacing.
|
||
- **Ticket POST bodies that worked**: `priority: {"id": 6}` = "Priority 1 - Critical"; `status: {"id": 107}` = board 6 "reviewed". `/service/priority` (singular) is a 404; get valid priority ids from any live ticket's `priority` object. `status: {name}` in a POST body fails.
|
||
- **Ticket CREATE pitfalls (validated live 2026-10-07, VistaNet Help Desk board)**: `summary` hard cap = 100 chars (400 MaxLengthField, exact error text returned). `status: {"name": "New"}` DID work on the Help Desk board create (the board-6 failure note above is board-specific). `initialDescription` does NOT read back as the ticket's description (GET shows empty) — it silently becomes INTERNAL note #1 (detailDescriptionFlag). Priority id 4 does NOT exist on this instance; pull valid priority ids from any live ticket (`priority id 8 = Priority 3 - Medium`, id 6 = Priority 1 - Critical). Contact on create: `"contact": {"id": N}` accepted fine.
|
||
- **Ticket description length (user preference, 2026-10-07)**: keep the initial description to a FEW SENTENCES maximum — symptom, root cause, resolution in brief. Full diagnostics detail goes in as a separate internal note, not in the create payload.
|
||
- **Contact lookup**: `GET /company/contacts?conditions=company/id={id} AND lastName='...'` works; `defaultEmail` is NOT a queryable condition field (400 ApiFindCondition) — filter by company + name instead.
|
||
- **Child linking**: `PATCH /service/tickets/{id}` with `[{"op":"add","path":"/parentTicketId","value":<parent>}]` returns 200, but `parentTicketId` has NO readback (never in GET/field projections, `hasChildTicket` stays False) and `/service/tickets/{id}/children(Conditions)` endpoints 404 on v4_6_release. Verification via API is impossible — always also post an internal "Child of parent #X" note on each child; that note is the durable, human-visible wiring.
|
||
- **Link-slip hazard**: when deduping, map keeper vs dup carefully — linking the *closed dup* id instead of its keeper is the natural mistake (happened in first pass); log every PATCH target and reconcile against the dedupe plan afterward.
|
||
- **Runbook**: 1) fetch open P1s via `closedFlag=false` + priority sort==1, 2) build dedupe plan (protect genuinely distinct alerts), 3) note-then-close dups, 4) create one parent per per-client cascading failure family with a child-list note, 5) PATCH parentTicketId per child + child notes, 6) verify: keepers still open, dups closed, fresh-P1 count = original − closed. Scratch scripts: `cw_triage_plan.json` / `cw_close_dupes.py` / `cw_create_parents2.py` / `cw_verify_final2.py` pattern.
|
||
The API occasionally returns responses containing non-printable control characters that cause standard JSON parsers (like Python's `json.loads`) to fail with `Invalid control character`.
|
||
- **Reliable Fix**: Clean the response text using regex before parsing: `re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', response_text)`.
|
||
- **Scaling Workaround**: When fetching large lists, avoid `pageSize=1000`. Large blocks are more likely to contain a corrupting character. Fetch in small batches (e.g., 100-200 records) using ID ranges (e.g., `conditions=id>=110000 AND id<110200`). This isolates corruption to a single small batch rather than failing the entire request.
|
||
|
||
### Ticket Merge
|
||
- **True merge unavailable**: `POST /service/tickets/{parentId}/merge` exists (TicketMerge schema requires `mergeTicketIds` array + `status` object) but returns **403 Security** for the VNI API member — do not retry.
|
||
- **Working fallback**: internal merge-pointer note on the ticket being merged away → PATCH status to the board's `>`-closed status (`>Closed` = board 1 id 17; dateResolved and closedFlag set automatically) → internal note on the parent recording the merge. Verify with a GET (status/closedFlag/dateResolved) and notes read-back on both tickets.
|
||
- The dedup rule: automated alarm tickets on other boards (e.g. Managed Backups, board 30, no tech owner) are NOT merge candidates for a Help Desk ticket unless the user names them.
|
||
|
||
### 8. Time Entries, Ticket Notes & Documentation Audits
|
||
Time entries and notes are SEPARATE objects, and for this user a time entry does NOT count as a note: when the ask is "document this" or "do I have notes on X", only ticket notes satisfy it.
|
||
- **Create time entry**: `POST /time/entries` — payload needs `company{id}`, `member{id}`, `chargeToId` (ticket #), `chargeToType: "ServiceTicket"`, `locationId`, `businessUnitId`, `workType{id}`, `workRole{id}`, `timeStart`/`timeEnd` (ISO 8601 UTC), `actualHours`, `billableOption` ("Billable"/"NoCharge"), `notes`. VistaNet working defaults: locationId 11, businessUnitId 1, workType 3 (Regular), workRole 4 (Systems Engineer). Convert local PDT times to UTC by adding 7 hours.
|
||
- **Pitfall**: `addToDetailDescriptionFlag: true` on a time entry does NOT create a ticket note on this instance. If the text must appear on the ticket, POST the note separately.
|
||
- **Create ticket note**: `POST /service/tickets/{id}/notes` with `text` + `internalFlag`/`detailDescriptionFlag`/`internalAnalysisFlag`/`resolutionFlag` (internal IR/triage notes: `internalFlag: true` + `internalAnalysisFlag: true`). Do NOT send `customerVisibleFlag` — ServiceNote has no such member and the API rejects the whole request with "Could not find member 'customerVisibleFlag' on object of type 'ServiceNote'".
|
||
- **cw_request payload pitfall**: `cw_request(method, endpoint, params=None, json_data=None)` — passing the payload as the third positional argument sends it as URL query `params`, not a JSON body, which returns a silent 400 on note POSTs. Always pass write payloads with the `json_data=` keyword.
|
||
- **Attribution**: API-created notes are stamped with the API member (TinMan), not an arbitrary author — tell the user when posting a note on their behalf.
|
||
- **Response shapes**: `cw_api.cw_request` returns `(data, status_code)` tuples and list endpoints can nest the payload one level deeper — normalize before any `.get()` or attribute loops.
|
||
- **Documentation audit**: to answer "was my scheduled work documented", GET `/schedule/entries` filtered by `member/id={id} AND dateStart >= [UTC] AND dateStart <= [UTC]`, then per ticket GET notes AND timeEntries; count only notes attributed to the member (identifier match). Report per ticket — notes present / time-entry-only / nothing — BEFORE proposing fixes.
|
||
- Exact working payloads and the normalization helper: `references/ticket-time-notes.md`.
|
||
- **Full activity requires BOTH endpoints**: a ticket can have few notes but rich time entries (e.g. multi-day builds logged entirely as one tech's time entries). When asked for "the details/notes" on a ticket, pull `/notes` AND `/time/entries?conditions=chargeToId={id}` before concluding history is thin — and report time-entry work separately from notes.
|
||
- **Plaintext credentials on tickets**: time-entry notes and customer-visible notes sometimes contain passwords/license keys. When summarizing or displaying a ticket, flag any credential material found and note which notes are customer-visible vs internal — do not silently repeat secrets in chat output; show them once with a hygiene warning.
|
||
|
||
## Related References
|
||
- `references/ticket-time-notes.md` — Verified POST payloads for time entries and ticket notes, status PATCH, schedule-day documentation audit pattern.
|
||
- `references/ticket_fetching.md` — Robust ticket retrieval patterns (avoids legacy mirror bug, ID-range paging, client-side filtering) plus the full-corpus note-text search recipe (`childconditions` on `notes/text` is silently ignored by `/service/tickets/search` — scan per-ticket notes instead).
|
||
- `references/isp-bandwidth-lookup.md` — Agreement and configuration lookup for ISP bandwidth/client infrastructure.
|
||
- `references/meeting-agenda-ticket-correlation.md` — How to search ConnectWise for existing tickets when a user mentions client-specific agenda items (locations, projects, systems).
|
||
- `references/backup-billing-reconciliation.md` — Complete n8n workflow pattern for comparing CW backup agreement additions to VSPC usage via Postgres registry and Nextcloud usage CSV.
|