34 KiB
name, description
| name | description |
|---|---|
| connectwise-msp | 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 iscompanyidpublickey:privatekey(e.g.,VNI+rtDj...:privatekey). Do NOT add an extra+between the company ID and public key. - Requirement: A valid
clientIdheader must be passed alongside the authorization. The API also requiresAccept: application/vnd.connectwise.com+json; version=2022.1orAccept: application/jsonfor proper response handling. - Connection Helper: Use the provided wrapper script in
scripts/cw_api.pyas 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.pywith/usr/bin/python3— the system python carriesrequests; 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) orcdinto this skill dir; a barefrom cw_api import cw_requestfrom 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.jsonfor authoritative endpoint discovery. - Developer Guide: HTML reference guide at
references/CWDeveloperNetworkGuide.htmfor 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 sendfails 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
clientIdand Basic Auth, certain endpoints like/company/ticketsmay return403 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, legacyapis/2.0/Members, Reports API/Dataexecution (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.connectWisePsanode may return403 Forbiddenwhen 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 theConnectwise APIcredential 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
agreementStatusproperty 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/agreementsmay return403 Forbiddeneven when the API member has Finance/Agreement Product read rights. On this instance the working search path isGET /finance/agreementswithconditions=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/ticketsand/company/configurations(whose companyId params are silently ignored),GET /finance/agreements?conditions=company/id={id}returns exactly that company's agreements — but note thefieldsparam interacts with sub-objects: requestingfields=id,summary,...,companystrips nested objects on some endpoints, so dropfieldswhen nested data is needed. - Agreement Additions: Use
GET /finance/agreements/{id}/additionsto retrieve line-item details for usage-based billing. Note: The product name/identifier is stored inproduct.identifierrather thanproduct.name. - Usage-Based Billing: Agreements with a
billAmountof0.0may 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:
GET /finance/agreements?conditions=name contains 'Veeam'plus theVeemtypo variant, then cross-check generic-named Backup-type agreements whose additions carry VEEAM product identifiers (name filters alone miss several active agreements).- For each agreement:
GET /finance/agreements/{id}/additions. - Filter additions for live lines:
billCustomer='Billable',extPrice>0,cancelledDateempty or future; sum per COMPANY (not per agreement) since On-Site + Off-Site agreements share consumption. - Pull VSPC actual usage live via the
/uiapiendpoints (see the vspc-billing-audit skill) — the legacy CSV snapshots in Nextcloud go stale. - See
references/backup-billing-reconciliation.mdfor 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:
- Add
clientIdandcompanyIdheaders explicitly. - Use
GET https://cw.vistanetinc.com/v4_6_release/apis/3.0/finance/agreementswith query parameterconditions=name='Veeam Off-Site Backup' or name='MSP On-Site Backup'. - Follow with
GET /finance/agreements/{{ $json.id }}/additions. - 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. - 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:
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; - Credential assignment: HTTP Request nodes do not auto-assign in n8n SDK-created workflows. Open the workflow canvas and manually select the
Connectwise APIcredential 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 itsidentifier. - Once identified, cache the
company.idfor 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
- Configs:
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
pageSizeand filter JSON locally in Python. Note: ThecompanyIdparameter on/service/ticketsis silently ignored by the API and returns tickets from all companies. Always filter client-side bycompany.idafter fetching. Seereferences/ticket_fetching.mdfor the working pattern. - Filtering Pitfall: Server-side
conditionsfor identifiers (e.g.,owner/identifier='SBrown') require the value to be enclosed in single or double quotes. Failing to do so results in anApiFindConditionsyntax error. - DateTime conditions require square brackets: date/time literals in
conditions(e.g.,/schedule/entriesdate filters) must be enclosed in[...]— bare ISO strings 400 withUnrecognized value. Working:dateStart >= [2026-10-02T00:00:00Z]. - Configurations Endpoint Mirroring: The
companyIdquery param on/company/configurationsis also silently ignored, returning configs from all companies — BUT theconditionsstring 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 bycompany.idafter 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), anddateResolvedis NULL/absent. - CRITICAL API MIRRORING BUG: The general list endpoint (
/service/tickets) andclosedFlag=falsefilter 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/entriesendpoint. - Calendar Workaround: Member-filtered schedule queries (
member/id={id} AND dateStart >= [UTC] AND dateStart <= [UTC]) have returned current production data — but ALWAYS sanity-check returneddateStartvalues. 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.
- Production Data Bypass (Tickets): The legacy mirror bug is resolved for tickets. However, it PERSISTS for the
- Contact Email Extraction: To retrieve a contact's email address, do NOT look for an
emailAddressfield on the Contact object. Instead, fetch the/company/contacts/{contactId}/communicationsendpoint and locate the entry wheretype.name == "Email"orcommunicationType == "Email"; the address is stored in thevaluefield. - 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 adateResolvedvalue.
- Client-side (Logic): Filter out tickets where the status name starts with
- Reporting Convention (Open Tickets):
- Board: VistaNet Help Desk
- Retrieval: Use
conditions=id >= [RecentID]to avoid legacy mirroring. - Logic: (Status NOT starting with
>ANDdateResolvedis 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
sortasc, thenisInSla=falsefirst, 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 —
closedFlagcan stay false while status goes>-prefixed withdateResolvedset (resolved, not vanished). - Owner Identification Pitfall: The
memberobject 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
resourcesfield 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
resourcesstring 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 iscompanyidpublickey: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
clientIdheader must be passed alongside the authorization. If theclientIdis incorrect or disabled, the API returns a401 Unauthorizedeven if the Basic Auth string is correct. - Credential Storage: Credentials and technical metadata are stored in
/home/tech/connectwise_reference.mdand~/.secrets/connectwise. To avoid Hermes runtime credential masking (which truncates keys toprefix...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):
[ { "op": "replace", "path": "/owner", "value": {"identifier": "<memberIdentifier>"} } ] - Pitfall: Sending a standard JSON object (e.g.,
{"owner": {"identifier": "<memberIdentifier>"}}) will result in a400 Bad Requeststating 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 viaGET /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_datamust 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 updatedownerobject (numeric id + identifier + name), but the read-back is the verification. Side effect: the API auto-adds the new owner to the comma-separatedresourcesstring 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
dateStartanddateEnd(ISO 8601 UTC). - Object Linking: Use
objectIdandticketType: "S"(uppercase, for Service Tickets) instead ofchargeTo. - Required Type: The
typefield 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
ScheduleEntryobject does NOT support adescriptionfield. Including one in the request payload will cause a400 Bad Requeststating 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,nameis free-form and honored. - Discovering schedule types:
GET /schedule/typesreturns a 403 Security error on this instance, andGET /calendar/generalTypesreturns empty. Find real types by sampling production entries:GET /schedule/entries?conditions=dateStart >= [recent UTC]and tally thetypeobjects (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
ScheduleEntryrows per resource withdateStart/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/membersendpoint also requires elevated security permissions. If a403 Forbiddenis returned there, fall back to resolving member identifiers from ticket-levelresourcesfields or from theownerobject returned on individual ticket reads (which does not require the/system/memberspermission). - Member ids: never guess a numeric id — resolve unknown members from
member.idembedded on their notes, time entries, or ticket owner objects (orGET 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)
- Ticket Notes (primary):
GET /service/tickets/{id}/notes?sort=dateCreated desc&pageSize=1&fields=createdBy,enteredBy,dateCreated - Documents (fallback):
GET /service/tickets/{id}/documents?sort=createdDate desc&pageSize=1&fields=createdBy,enteredBy,createdDate - 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:
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, notdateEntered— filtering notes ondateEnteredsilently matches nothing.orderByon the notes endpoint is rejected (400 InvalidOrderBy); sort client-side. Time entries DO usedateEntered. - Notes GET returns oldest-first:
GET /service/tickets/{id}/noteslists 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 noteid(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
memberobject — onlycontact. 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/slaStatuson the ticket detail (which carries nolastUpdateobject 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'spriorityobject.status: {name}in a POST body fails. - Ticket CREATE pitfalls (validated live 2026-10-07, VistaNet Help Desk board):
summaryhard 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).initialDescriptiondoes 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;defaultEmailis 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, butparentTicketIdhas NO readback (never in GET/field projections,hasChildTicketstays 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.pypattern. The API occasionally returns responses containing non-printable control characters that cause standard JSON parsers (like Python'sjson.loads) to fail withInvalid 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}/mergeexists (TicketMerge schema requiresmergeTicketIdsarray +statusobject) 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 needscompany{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: trueon 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}/noteswithtext+internalFlag/detailDescriptionFlag/internalAnalysisFlag/resolutionFlag(internal IR/triage notes:internalFlag: true+internalAnalysisFlag: true). Do NOT sendcustomerVisibleFlag— 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 queryparams, not a JSON body, which returns a silent 400 on note POSTs. Always pass write payloads with thejson_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_requestreturns(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/entriesfiltered bymember/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
/notesAND/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 (childconditionsonnotes/textis 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.