--- 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=` 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": ""} } ] ``` - **Pitfall**: Sending a standard JSON object (e.g., `{"owner": {"identifier": ""}}`) 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": ""}`) or the sub-path (`"path": "owner/identifier", "value": ""`) 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":}]` — 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":""}}]`, 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= 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": }`. - **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":}]` 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.