Files
claudetools/.claude/commands/syncro.md
Howard Enos d63dcde679 sync: auto-sync from HOWARD-HOME at 2026-05-06 15:10:59
Author: Howard Enos
Machine: HOWARD-HOME
Timestamp: 2026-05-06 15:10:59
2026-05-06 15:11:04 -07:00

41 KiB
Raw Blame History

/syncro — Syncro PSA ticket management

Create, update, close, comment on, and bill tickets in Syncro PSA.

Usage

/syncro                                  Show open tickets summary
/syncro ticket <number>                  View ticket details + comments
/syncro create <customer> <subject>      Create new ticket
/syncro update <number> <status>         Update ticket status
/syncro close <number>                   Close/resolve a ticket
/syncro comment <number> <text>          Add a comment to a ticket
/syncro bill <number>                    Add billable time and create invoice
/syncro search <query>                   Search tickets by subject/customer
/syncro customers <query>                Search customers
/syncro move-appointment <customer>      Find and reschedule an existing appointment

API Configuration

Base URL: https://computerguru.syncromsp.com/api/v1 API Key: per-user tokens in SOPS vault — see "Get API key" below Rate limit: 180 requests/minute per IP Docs: https://api-docs.syncromsp.com/

Hard Rules (violations have occurred — no exceptions)

All work-time billing MUST go through timer_entry → charge_timer_entry. Bare add_line_item for time-bearing work bypasses Syncro's time tracking and breaks reporting (hours per client, tech productivity, prepay burn). Bare add_line_item is reserved for non-time items only (hardware, flat-fee services). Even warranty/free work needs a time entry — set billable: false. Only cancelled tickets are exempt. Mike caught the bare-add_line_item bug across 31 tickets on 2026-04-30; it was repeated on 3 more tickets on 2026-05-01 — see .claude/memory/feedback_syncro_timer_first.md.

JSON payloads to curl: use heredoc with --data-binary @-, not /tmp/*.json files. On Windows the Write tool resolves /tmp/foo.json to C:\tmp\foo.json while Git Bash resolves it to %LOCALAPPDATA%\Temp\foo.json — different real directories, so a payload written by Write may not be the file curl reads. Heredoc with <<'JSON' (single-quoted to suppress bash variable expansion inside the payload) avoids the file handoff entirely. See .claude/memory/feedback_tmp_path_windows.md — caused a wrong-comment incident on ticket #32225 on 2026-05-01 (rogue payload from a prior session).

Before any POST: Always show the full payload to the user and wait for explicit confirmation. This applies to tickets, comments, line items, and invoices — including hidden/internal notes.

After any ambiguous POST result (null fields, jq error, curl error, timeout): Do NOT retry. GET the resource first to confirm whether the action succeeded. Syncro has no idempotency on any endpoint — one POST always creates one record. Duplicate tickets and comments cannot be deleted via API; comments require manual GUI removal.

Ticket response shape: {"ticket": {...}} — always use .ticket.id, never .id. The flat-object jq pattern silently returns nulls and looks like failure when it isn't.

Billing: Always ask for minutes and labor type before adding any line item. Never assume a default.

Emergency/after-hours billing — check prepaid first: Before adding a 26184 (Emergency) line item, GET /customers/<id> and read prepay_hours. If prepay_hours > 0, the customer has a prepaid block — bill 26118 (Onsite) at quantity × 1.5 instead (prepaid debits by quantity, not by dollars). Never stack 26118 + 26184 for the same hours — the Emergency product rate already has the 1.5× multiplier baked in. Verified 2026-04-23 on ticket #32203 (Desert Auto Tech) after Winter caught the bug.

Line-item price_retail MUST be set explicitly: Earlier guidance to "omit price_retail and let Syncro auto-calc from the product rate" was wrong — the rate does NOT populate automatically. Fetch it with GET /products/<id>.product.price_retail and pass it on add_line_item. Omitting it leaves the line at $0.00 and the invoice posts at $0.00 (verified 2026-04-23 on #32203).

Implementation

When invoked, use the Syncro REST API via curl. All requests include ?api_key=<key> as query parameter (NOT in header — Syncro uses query param auth).

Attribution rule (CRITICAL)

Every Syncro API call is attributed to the owner of the API key. Comments, line items, timer entries, and invoices created by the API are logged as the API user — regardless of who is running the command. So the skill MUST use a per-user API key that matches the actual tech running it, or comments will be misattributed.

identity.json user Syncro user user_id
mike Michael Swanson 1735
howard Howard Enos 1750

Keys are baked into the skill below. To add a new user: generate a token in Syncro → Admin → API Tokens, add a case to the key-select block, and store a backup copy in the vault at msp-tools/syncro-<user>.sops.yaml.

Get API key

BASE="https://computerguru.syncromsp.com/api/v1"

# Per-user keys — actions in Syncro are attributed to the key owner
USER_ID=$(jq -r '.user // empty' "$CLAUDETOOLS_ROOT/.claude/identity.json")
case "$USER_ID" in
  mike)   API_KEY="T259810e5c9917386b-52c2aeea7cdb5ff41c6685a73cebbeb3" ;;
  howard) API_KEY="Tde5174a6e9e312d14-02fd5bfe0f0ee40c87d027507c680e18" ;;
  *)      echo "[ERROR] Unknown user '$USER_ID' in identity.json — cannot select Syncro API key" >&2; exit 1 ;;
esac

Ollama drafting

Ollama handles prose drafting for write operations. Claude reviews the output against the hard rules below, then presents a preview. User confirms. Claude executes.

Availability check — run once at the start of any write operation, reuse $OLLAMA for the rest of the session:

if curl -s -m 2 http://localhost:11434/api/tags >/dev/null 2>&1; then
    OLLAMA="http://localhost:11434"
elif curl -s -m 3 http://100.92.127.64:11434/api/tags >/dev/null 2>&1; then
    OLLAMA="http://100.92.127.64:11434"
else
    OLLAMA=""  # fallback: Claude drafts directly
fi

Draft call:

# Write prompt to a workspace path both the Write tool and Git Bash agree on
# (do NOT use /tmp on Windows — see Hard Rules: /tmp resolves differently in
# Write vs Git Bash). Use $CLAUDETOOLS_ROOT/.claude/tmp/ or pipe via heredoc.
PROMPT_FILE="$CLAUDETOOLS_ROOT/.claude/tmp/ollama_prompt.txt"
mkdir -p "$(dirname "$PROMPT_FILE")"
cat > "$PROMPT_FILE" <<'ENDPROMPT'
<prompt content here>
ENDPROMPT

if [ -n "$OLLAMA" ]; then
    DRAFT=$(PROMPT_FILE="$PROMPT_FILE" py -c "
import os, urllib.request, json, sys
prompt = open(os.environ['PROMPT_FILE']).read()
body = json.dumps({
  'model': 'qwen3:14b',
  'messages': [{'role': 'user', 'content': prompt}],
  'stream': False,
  'think': False
}).encode()
res = json.loads(urllib.request.urlopen(
    urllib.request.Request('$OLLAMA/api/chat', body), timeout=60
).read())
print(res['message']['content'])
")
else
    echo "[INFO] Ollama unavailable — Claude will draft directly."
    DRAFT=""
fi

When to use Ollama:

  • Comment body drafting (/syncro comment, /syncro close, billing resolution notes)
  • Billing description field (line item billing narrative)
  • Ticket initial description during /syncro create

When NOT to use Ollama:

  • JSON field selection (product_id, quantity, price_retail) — Claude owns this using the local rate table and rules
  • Read operations (GET)
  • Auth, credential, or security decisions

Billing draft prompt template

You are a Syncro PSA billing assistant. Draft a resolution comment and billing description.

TICKET #<id>: <subject>
CUSTOMER: <customer_name>
TECH: <tech_name>
WORK DONE: <user description of work>
LABOR: <product_name> — <minutes> min (<quantity> hrs) @ $<price_retail>/hr = $<total>

Rules:
- comment_body must use <br> for line breaks. Do NOT use <ul> or <li> — they do not render.
- Keep it professional and factual. No filler phrases.
- line_item_description is one plain-text line, billing-facing.

Return ONLY valid JSON, no prose before or after:
{
  "comment_subject": "Resolution",
  "comment_body": "<HTML with <br> line breaks>",
  "line_item_description": "<one line plain text>",
  "preview": "<2-3 sentence plain-text summary for tech review>"
}

Comment draft prompt template

You are a Syncro PSA tech assistant. Draft a ticket comment.

TICKET #<id>: <subject>
CUSTOMER: <customer_name>
NOTE: <user's note or description>
VISIBILITY: <"Internal only" | "Customer-visible">

Rules:
- Use <br> for line breaks. Do NOT use <ul> or <li>.
- Professional and factual. No filler.

Return ONLY valid JSON:
{
  "subject": "Update",
  "body": "<HTML with <br> line breaks>",
  "preview": "<plain text for tech review>"
}

Claude review checklist (always run before presenting to user)

Whether the draft came from Ollama or Claude wrote it directly:

  1. price_retail matches the local rate table for the selected product_id
  2. quantity = minutes ÷ 60 — verify the arithmetic (e.g. 45 min = 0.75, not 0.77)
  3. Computed total = price_retail × quantity — matches what was communicated to user
  4. If labor_type is emergency and prepay_hours > 0: product must be 26118, qty must be actual_hours × 1.5
  5. comment_body uses <br>, not <ul>/<li>
  6. No internal notes or credential data in a customer-visible comment body

If a check fails: correct it and note the fix in the preview so the user can see what changed.

Fallback behavior

If OLLAMA is empty (neither endpoint reachable): Claude drafts the comment body and billing description directly from the same variables. All other logic — review checklist, confirmation, execution — is identical. Announce [INFO] Ollama unavailable — drafting directly.


Adding a per-user key

  1. User logs into Syncro → Admin → API Tokens → New (/api_tokens/new)
  2. Type: Integration API Token (or Custom with all standard scopes: asset/customer/ticket/invoice/payment read+write+delete, worksheet add+manage+delete, chat + script.execute)
  3. Copy the token once (Syncro only shows it on creation)
  4. Encrypt to vault:
    cat > $VAULT_ROOT/msp-tools/syncro-<user>.sops.yaml <<YAML
    kind: api-key
    name: Syncro (<Full Name>)
    subdomain: computerguru
    api-base-url: https://computerguru.syncromsp.com/api/v1
    api-docs: https://api-docs.syncromsp.com/
    status: active
    owner: <user>
    syncro_user_id: <id>
    tags: [msp-tools, per-user]
    credentials:
        credential: <TOKEN>
    notes: Per-user Syncro API token for <Full Name>. Created YYYY-MM-DD.
    YAML
    # MUST run from vault root so sops picks up .sops.yaml
    (cd "$VAULT_ROOT" && sops --encrypt --in-place "msp-tools/syncro-<user>.sops.yaml")
    
  5. Commit + push vault repo.

Endpoints reference

Tickets

Operation Method Endpoint Body
List tickets GET /tickets?status=<status>&per_page=25
Get ticket GET /tickets/<id>
Create ticket POST /tickets see full create workflow below
Update ticket PUT /tickets/<id> {"status": "In Progress", "priority": "..."}
Delete ticket DELETE /tickets/<id>

Ticket statuses: New, In Progress, Waiting on Customer, Waiting on Vendor, Scheduled, Resolved, Invoiced, Closed

Priority format (number-prefixed string): "1 High", "2 Normal", "3 Low", "4 Urgent" Default: "2 Normal". Use "4 Urgent" for emergency/after-hours.

Problem types (Issue Type dropdown — use closest match, else "Not determined"): API, Email, Emergency Service, File Services / Permissions, Hardware, Maintenance, New User / M365 Account Creation, New User / Workstation Deployment, Not determined, Onsite, Other, Phone/VOIP, Remote, Security, Server Migration, Service Request, Software, Website

Appointment types:

Name ID location_type
In Shop 4321 shop
Onsite 4322 customer
Phone Call 4323 pre_defined
Reminder 193053 manual_entry
Remote 59289 pre_defined

Tech user IDs: Mike = 1735, Howard = 1750, Winter = 1737, Rob = 1760

Appointments

Operation Method Endpoint Notes
List (today) GET /appointments?start_at=YYYY-MM-DD Filter by date; use .summary to match customer
Get GET /appointments/<id> Returns {"appointment": {...}}
Create POST /appointments Used in ticket creation flow (Call 3)
Move / edit PUT /appointments/<id> Verified 2026-04-24 — updates start_at/end_at
Delete DELETE /appointments/<id> Not yet verified

Finding an appointment by customer: GET /appointments?start_at=<date> returns all appointments — filter client-side with select(.summary | test("customer name"; "i")) or select(.ticket.customer_id == N). The customer_id query param does not filter correctly.

Move workflow:

  1. GET /appointments?start_at=<date> — find appointment ID
  2. Confirm new date/time with user
  3. PUT /appointments/<id> with {"start_at": "ISO8601", "end_at": "ISO8601"}
  4. Verify response: .appointment.start_at matches intended time

Response shape: {"appointment": {...}} — parse as .appointment.id, .appointment.start_at, etc.


Ticket creation workflow (full — 3 API calls)

Ticket creation in Syncro maps to three separate API calls. Gather all inputs first, show a full preview, wait for confirmation, then execute in order.

Step 1 — Gather inputs

Collect in one pass (do not ask field by field):

# Field Notes
1 Subject Brief title: reason for the ticket
2 Issue Type (problem_type) From dropdown above; "Not determined" if unclear
3 Priority "2 Normal" default; "4 Urgent" for emergencies
4 Description Expanded detail — becomes the "Initial Issue" comment body
5 Do Not Email Suppress customer notification on ticket create? (yes for internal/reminder tickets)
6 Due Date ISO date
7 Assigned Tech Who owns the ticket
8 Contact Look up from GET /customers/{id}.contacts[]; show list, ask user to pick
9 Address/Site address_id — also comes from customer contacts with address data
10 Appointment Type From table above; omit section if no appointment needed
11 Location Free text; usually blank unless onsite at non-primary address
12 Start Time ISO8601 datetime; omit if no scheduled appointment
13 End Time Default: start + 90 minutes
14 Appointment Owner Usually same as assigned tech; noted for calendar attribution (not a separate API field — inherits from ticket user_id)
15 Do Not Invite If not onsite, suppress calendar invite — note: not directly controllable via API; inform user if they need this set manually
16 Asset Search GET /customer_assets?customer_id=N&query=<name> if a specific device is involved

Step 2 — Look up customer data

Before showing the preview, fetch what you need:

# Get contacts and addresses
curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{contacts: [.customer.contacts[] | {id, name, address1, email}]}'

# Search assets
curl -s "${BASE}/customer_assets?customer_id=${CUST_ID}&query=<name>&api_key=${API_KEY}" | jq '[.assets[] | {id, name, asset_type}]'

Step 3 — Show preview and confirm

Display the full ticket before posting. Include all populated fields. Wait for explicit confirmation.

TICKET PREVIEW
--------------
Customer:     <name>
Subject:      <subject>
Issue Type:   <problem_type>
Priority:     <priority>
Description:  <description>
Due Date:     <due_date>
Assigned To:  <tech name>
Contact:      <contact name>
Address:      <address>
Do Not Email: <yes/no>

APPOINTMENT
-----------
Type:         <type name>
Start:        <start_at>
End:          <end_at>  (90 min)
Location:     <location or blank>

ASSET:        <asset name or none>

Confirm? (yes/no)

Step 4 — Execute (after confirmation)

Call 1 — Create ticket:

RESP=$(curl -s -X POST "${BASE}/tickets?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "customer_id": N,
  "subject": "...",
  "problem_type": "...",
  "status": "New",
  "priority": "2 Normal",
  "user_id": N,
  "due_date": "YYYY-MM-DD",
  "contact_id": N,
  "address_id": N,
  "start_at": "ISO8601",
  "end_at": "ISO8601",
  "asset_ids": [N]
}
JSON
)
TICKET_ID=$(echo "$RESP" | jq -r '.ticket.id')
CUST_ID=$(echo "$RESP" | jq -r '.ticket.customer_id')

Omit null/blank fields from the payload before piping. The 'JSON' quoting on the heredoc opener is required — it suppresses bash variable and backtick expansion inside, which matters when descriptions contain $ (passwords, prices, regex, etc.).

Call 2 — Post initial description as "Initial Issue" comment:

curl -s -X POST "${BASE}/tickets/${TICKET_ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Initial Issue",
  "body": "<the full description>",
  "hidden": false,
  "do_not_email": true
}
JSON
# Parse: .comment.id  (NOT .id — see Hard Rules)

Set do_not_email: true if "Do Not Email" was checked; false otherwise.

Call 3 — Create appointment (only if start_at provided):

curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "ticket_id": N,
  "customer_id": N,
  "appointment_type_id": N,
  "start_at": "ISO8601",
  "end_at": "ISO8601",
  "location": ""
}
JSON

Note: "Do Not Invite" (suppress calendar invite email) is not API-controllable. Tell the user to toggle it in the Syncro GUI if needed.

Payload handoff: prefer heredoc with --data-binary @- and <<'JSON' quoting — never use /tmp/<file>.json for piping payloads from the Write tool to curl. On Windows, the Write tool resolves /tmp/foo.json to C:\tmp\foo.json while Git Bash resolves it to %LOCALAPPDATA%\Temp\foo.json — different real directories, so curl reads a different (or stale) file than Write created. Heredoc avoids the file handoff entirely, and the 'JSON' quoting prevents bash from expanding $ characters inside the payload (passwords, regex, jq queries, etc.). See .claude/memory/feedback_tmp_path_windows.md for the full failure mode.

Comments

Operation Method Endpoint Body
Add comment POST /tickets/<id>/comment {"subject": "Update", "body": "...", "hidden": false, "do_not_email": false}

Comment fields (verified):

  • subject — required; comment header (e.g., "Update", "Resolution", "Internal Note")
  • body — required; comment text (HTML supported)
  • hidden — bool; if true, internal-only (customer can't see)
  • do_not_email — bool; if true, suppresses customer email notification
  • tech — string; overrides the authenticated user's name shown on the comment

Drafting comment bodies: Use Ollama (comment draft prompt template above) to generate body content. Run Claude review checklist. Present preview and wait for confirmation before POST. Fallback to Claude direct draft if $OLLAMA is empty.

Silently ignored (do not use): product_id, minutes_spent, bill_time_now — accepted but not saved. Verified 2026-04-21.

CRITICAL — response wrapper: POST /comment returns {"comment": {"id": ..., "subject": ..., ...}} — NOT a flat object. Always parse as .comment.id, .comment.created_at, etc. Using .id returns null and looks like failure even when the comment posted successfully. This caused duplicate comments on 2026-04-22 (#32185) and 2026-04-23 (#32142) — both times the POST succeeded but null .id triggered a retry.

# Correct pattern — always check .comment.id
RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Update",
  "body": "...",
  "hidden": false,
  "do_not_email": false
}
JSON
)
echo "$RESP" | jq '{id: .comment.id, subject: .comment.subject, created_at: .comment.created_at}'

CRITICAL — duplicate prevention: The server has no idempotency. One POST = one comment, always. Duplicates are caused by calling the endpoint twice (retry after a perceived timeout, double tool invocation, etc.). Never retry a POST /comment without first GET /tickets/{id} to confirm the comment did not already land. When verifying, search all comments by subject — do not rely on [-3:] tail. The Idempotency-Key header is silently ignored.

# Correct verification pattern after ambiguous response
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
  jq '.ticket.comments[] | select(.subject == "Your Subject Here") | {id, created_at}'

Comments cannot be deleted via API. No DELETE endpoint exists in the Syncro API for comments — confirmed against official swagger spec. Duplicate comments require manual removal in the GUI.

Do NOT wrap body in {"comment": {...}} — returns 422 "Body can't be blank". POST flat JSON directly.

Customers

Operation Method Endpoint
List/search GET /customers?query=<search>&per_page=25
Get customer GET /customers/<id>
Create customer POST /customers

Billable Line Items

There are two verified mechanisms for putting a billable charge on a ticket. They are NOT interchangeable.

Default — timer_entry → charge_timer_entry (REQUIRED for any work that has a time component):

This is the documented billing path. It records hours into Syncro's time-tracking system AND creates the line item, so reporting (hours per client, tech productivity, prepay burn rate, average resolution time) stays accurate. Bare add_line_item skips the time-tracking system and leaves Syncro showing 00:00:00 worked even though the invoice posts correctly — which is what produced the 31-ticket gap on 2026-04-30 and three more on 2026-05-01.

Operation Method Endpoint
Create timer POST /tickets/<id>/timer_entry
Charge timer (creates line item) POST /tickets/<id>/charge_timer_entry
Update timer PUT /tickets/<id>/update_timer_entry
Delete timer POST /tickets/<id>/delete_timer_entry
List timers (on a ticket) GET /tickets/<id>.ticket.ticket_timers

CRITICAL — response shapes are FLAT: Both POST /timer_entry and POST /charge_timer_entry return a flat object — {"id": N, "ticket_id": ..., "product_id": ..., ...} — NOT wrapped in {"timer": {...}} or {"timer_entry": {...}}. Parse as .id directly. The wrapped pattern silently returns null, breaks charge_timer_entry ("Not found"), and triggers a duplicate-timer retry. Hit on ticket #32253 on 2026-05-05; recovery via delete_timer_entry. Verified shape:

// POST /tickets/{id}/timer_entry response
{"id": 39031258, "ticket_id": 109895882, "user_id": 1750, "start_time": "...", "end_time": "...",
 "recorded": false, "billable": true, "notes": "...", "product_id": 26118,
 "comment_id": null, "ticket_line_item_id": null, "active_duration": 1800, ...}

// POST /tickets/{id}/charge_timer_entry response (also flat)
{"id": 39031258, "recorded": true, "ticket_line_item_id": 42313052, ...}

CRITICAL — duplicate prevention: Syncro has no idempotency on /timer_entry. Never retry the POST without first GET /tickets/{id} and inspecting .ticket.ticket_timers[]. The standalone GET /ticket_timers?ticket_id=N query parameter does NOT filter — it returns the entire global timer history. Use the ticket object instead.

# Verification pattern after ambiguous timer_entry response
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
  jq '.ticket.ticket_timers[] | select(.recorded == false) | {id, start_time, end_time, product_id, notes}'

If duplicates exist, delete the older one(s) before charging:

curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"timer_entry_id": <older_duplicate_id>}
JSON
# Returns: {"success": true}
# 1. Create timer entry — records hours in Syncro's time-tracking system.
#    For warranty / no-charge work, set "billable": false (time still records).
#    Capture .id directly — response is FLAT (see above).
TIMER_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "start_at": "ISO8601",
  "end_at": "ISO8601",
  "notes": "...",
  "billable": true,
  "product_id": 1190473
}
JSON
)
TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.id')

# 2. Charge the timer — sets recorded:true and auto-generates a linked line
#    item with the timer's product_id and computed quantity (hours).
curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"timer_entry_id": ${TIMER_ID}}
JSON

# 3. Verify the auto-generated line item picked up the rate. Syncro sometimes
#    creates the line at $0.00 even though the product has a price_retail set
#    (same root cause as the bare add_line_item bug). If price_retail is 0,
#    patch it via update_line_item.
curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | jq '.ticket.line_items[] | {id, product_id, quantity, price_retail}'

# 4. (only if needed) Patch a $0 line:
curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "ticket_line_item_id": NNN,
  "price_retail": 150.00
}
JSON

# Delete timer (rarely needed):
curl -s -X POST "${BASE}/tickets/${ID}/delete_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"timer_entry_id": N}
JSON
# Returns: {"success": true}

charge_timer_entry produces the line item; you do NOT call add_line_item afterward — that would double-bill.

Fallback — bare add_line_item (NON-TIME items only):

Use this ONLY when there is genuinely no labor time component to bill — selling a hardware product, a flat-fee service, a recurring subscription line. For ANY work with a time component, including warranty/free work (where time should record at billable: false), use the timer path above. Cancelled tickets are the only exemption from creating a time entry.

Operation Method Endpoint
Add line item POST /tickets/<id>/add_line_item
Remove line item POST /tickets/<id>/remove_line_item
Update line item PUT /tickets/<id>/update_line_item
# Non-time line item (hardware, flat-fee). Always include price_retail —
# the API does not auto-apply product rates.
curl -s -X POST "${BASE}/tickets/${ID}/add_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "product_id": 1190473,
  "quantity": 1,
  "price_retail": 150.00,
  "name": "Hardware - Replacement Drive",
  "description": "Item description",
  "taxable": true
}
JSON

# Remove
curl -s -X POST "${BASE}/tickets/${ID}/remove_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"ticket_line_item_id": 12345}
JSON
# Returns: {"success": true, "message": ""}

add_line_item required fields (also apply to the auto-generated line from charge_timer_entry — verify after charging and patch via update_line_item if needed):

  • name — required (422 if missing)
  • description — required (422 if missing)
  • product_id — product ID (labor product table below for time-based work, or any other product for hardware / flat-fee items)
  • quantity — units of the product. For labor products, this is decimal hours (0.5 = 30 min, 1.0 = 1 hour). For hardware, the unit count.
  • price_retailmust always be set explicitly; price, retail_price, rate, price_cents all silently ignored and leave line at $0.00. Syncro does NOT auto-calculate rates via API even though it does in the web UI. Omitting it leaves the line at $0.00 and the invoice generates at $0 (verified 2026-04-23 on #32203). Always pass the rate from the table below.
  • taxablealways set explicitly; labor products default to no-tax in GUI but the API applies tax if this is omitted. Use false for labor, true for taxable hardware.

Do NOT remove ticket line items after invoicing. Leave them on the ticket — the "Add/View Charges" button and billing verification by techs depends on seeing line items there.

Labor product IDs and rates (rates pulled from Syncro API 2026-04-24):

product_id Name price_retail ($/hr) Notes
1190473 Labor - Remote Business 150.00 Standard remote work
26118 Labor - Onsite Business 175.00 Base onsite rate
573881 Labor - In Shop Business 150.00 Hardware brought into ACG's shop
26184 Labor - Emergency or After Hours Business 262.50 1.5× onsite; time-and-a-half baked into the rate. Non-prepaid customers only. Do NOT stack with 26118 for the same hours.
1049360 Labor- Warranty work 0.00 Use this for ANY warranty / no-charge work. Do NOT use a billable labor product + billable: false or a patched price. See feedback_syncro_warranty_product.md.
9269129 Labor - Prepaid Project Labor 0.00 Debits from customer prepay_hours bank. Per feedback_syncro_labor_type.md: do NOT use as a default — only when explicitly directed.
9269124 Labor - Internal Labor 0.00 Non-billable internal ACG time (not customer-facing).
26117 Fee - Travel Time 40.00 Per travel event (not hourly)
68055 Labor - Website Labor 150.00 Website-related work

price_retail is the per-unit rate. Line item total = price_retail × quantity. Rates are determined by the product selected — never patch price_retail on a line item to convert one product into another (e.g. don't take Remote Labor at $150 and patch to $0 to mimic warranty). If a line's dollar amount is wrong, the wrong product_id was picked — undo, pick the correct product, redo. The only legitimate update_line_item price_retail use is the Syncro auto-gen-zero recovery (when an auto-generated line came in at $0 instead of the product's intended rate).

Emergency / after-hours billing branches by whether customer has prepaid labor:

Check: GET /customers/<id>.customer.prepay_hours (string; "0.0" means no prepaid, any non-zero means prepaid block exists).

prepay_hours Regular hours Emergency / after-hours
0 / null (no prepaid) 26118, qty = actual_hours 26184, qty = actual_hours (rate already 1.5×)
> 0 (has prepaid block) 26118, qty = actual_hours 26118, qty = actual_hours × 1.5

Rationale (Winter, 2026-04-23): Prepaid blocks debit by QUANTITY, not dollars. To charge time-and-a-half against a prepaid block we bump the quantity to 1.5× on the Onsite product rather than switching to the Emergency product — switching would double-count because the Emergency product has the 1.5× already built into its dollar rate.

Example — 2 hour emergency onsite job:

  • Non-prepaid customer: one line of 2.0 hrs × 26184 @ $262.50 → $525.00 billed
  • Prepaid customer: one line of 3.0 hrs × 26118 @ $175.00 → debits 3 hrs from prepaid block

Winter caught this on #32203 (Desert Auto Tech) 2026-04-23 after a stack of 1hr 26118 + 1hr 26184 for a single hour of emergency work — the $ doubled because the 1.5× was applied twice.

Timer Entries (time tracking reference)

Operation Method Endpoint
Add timer POST /tickets/<id>/timer_entry
Charge timer → line item POST /tickets/<id>/charge_timer_entry
Update timer PUT /tickets/<id>/update_timer_entry
Delete timer POST /tickets/<id>/delete_timer_entry
List timers (on a ticket) GET /tickets/<id>.ticket.ticket_timers

Both POST /timer_entry and POST /charge_timer_entry return FLAT objects — parse .id directly. See "Billable Line Items → Default" above for the full response-shape note and duplicate-prevention pattern.

Invoices

Operation Method Endpoint Body
List invoices GET /invoices?per_page=25
Get invoice GET /invoices/<id>
Create from ticket POST /invoices {"ticket_id": N, "customer_id": N, "category": "Standard"}
Delete invoice DELETE /invoices/<id>

"Make Invoice" flow: POST /invoices pulls all line items currently on the ticket into the invoice. Line items are produced by charge_timer_entry (the default path for time-based work) or by bare add_line_item (the fallback path for non-time items). A bare timer entry that has not been charged is NOT pulled in — the timer must be converted to a line item via charge_timer_entry first.

Note: The POST /invoices response body does not include line_items — do GET /invoices/{id} to verify line items transferred correctly.

Display formatting

When showing ticket lists, format as:

#32164  New        Jerry Burger           Own cloud thing again
#32163  New        LeeAnn Parkinson       Remote - Jim cant access his email
#32162  Invoiced   Len's Auto Brokerage   Server upgrade

When showing ticket detail, include:

  • Ticket number, subject, status, priority
  • Customer name + contact
  • Created date, due date, last updated
  • Assigned tech
  • Comments (most recent first, truncated to last 5)
  • Line items / billing status

Billing workflow

ALWAYS ask the user for minutes and labor type before logging any time. Never assume a default. ALWAYS show a preview of the comment + timer entry to the user before posting. Wait for confirmation. ALWAYS read customer.prepay_hours before choosing the labor product for emergency work. ALWAYS bill via timer_entry → charge_timer_entry. Bare add_line_item for time-based work bypasses Syncro's time-tracking system and is forbidden — see Hard Rules.

When /syncro bill <number> is called:

  1. GET /tickets/{id} for ticket detail, then GET /customers/{customer_id} to read prepay_hours
  2. Check Ollama availability (see "Ollama drafting" above) — do this once, reuse $OLLAMA
  3. Ask: "How many minutes should I bill, and what labor type? (remote / onsite / emergency / project / internal / warranty)"
  4. Decide product + quantity using the emergency-branching table above:
    • Non-prepaid + emergency → product 26184, qty = actual hours
    • Prepaid + emergency → product 26118, qty = actual hours × 1.5
    • Warranty / no-charge → product 1049360 (Labor- Warranty work), billable: true, qty = actual hours. Do NOT pick a regular labor product with billable: false — Syncro silently overrides the flag and generates a billable line. (Verified 2026-05-06 on #32225 — see feedback_syncro_warranty_product.md.)
    • Otherwise → per --labor mapping below, qty = actual hours
  5. Look up price_retail from the local rate table (do NOT fetch live — rates are baked in)
  6. Compute start_at and end_at for the timer (use ISO8601; the end_at start_at interval should equal quantity hours so Syncro's reporting math matches what you bill)
  7. Send billing draft prompt to Ollama (or draft directly if $OLLAMA is empty) — see prompt template above
  8. Run Claude review checklist on the draft output
  9. Present preview to user: product, quantity, rate, computed total, comment body, timer notes / line item description. Wait for confirmation.
  10. Post resolution comment: POST /tickets/{id}/comment
  11. Create timer entry: POST /tickets/{id}/timer_entry with start_at, end_at, billable (true for paid work, false for warranty/no-charge), product_id, notes. Capture the returned timer ID.
  12. Charge the timer: POST /tickets/{id}/charge_timer_entry with {"timer_entry_id": N} — this records the time AND auto-generates a linked line item with the timer's product_id and computed quantity (hours).
  13. Verify the auto-generated line item picked up the rate: GET /tickets/{id} and inspect the new entry in .ticket.line_items[]. If price_retail came in at 0.00 (Syncro sometimes drops it on auto-generated lines), patch it: PUT /tickets/{id}/update_line_item with {"ticket_line_item_id": N, "price_retail": <rate>}.
  14. Create invoice: POST /invoices with {"ticket_id": N, "customer_id": N, "category": "Standard"}
  15. Verify invoice: GET /invoices/{id} → confirm .invoice.total matches qty × price_retail. (For prepaid customers, the line totals against the prepay block — .invoice.total will reflect any non-prepaid items only.)
  16. Update ticket status to Invoiced

If .invoice.total comes back $0.00 (auto-generated line item went in with null price and you missed step 13): PUT /tickets/{id}/update_line_item with price_retail on each item, then DELETE /invoices/{bad_id} and re-POST /invoices. Recovery verified on #32203 (2026-04-23).

Correct pattern:

# Step 1: Post resolution comment
curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "subject": "Resolution",
  "body": "...",
  "hidden": false,
  "do_not_email": false
}
JSON

# Step 2: Create timer entry — records hours in Syncro's time-tracking system.
# Convert minutes to decimal hours (60 min = 1.0, 30 min = 0.5, 45 min = 0.75).
# Set start_at/end_at so end - start equals the billed duration.
# For warranty / no-charge work, set "billable": false (time still records).
TIMER_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{
  "start_at": "2026-05-01T13:00:00-07:00",
  "end_at":   "2026-05-01T14:00:00-07:00",
  "notes": "Resolved customer issue — see ticket comment for detail.",
  "billable": true,
  "product_id": 1190473
}
JSON
)
TIMER_ID=$(echo "$TIMER_RESP" | jq -r '.id')  # response is FLAT — see "response shapes" note above

# Step 3: Charge the timer — creates the linked line item automatically.
curl -s -X POST "${BASE}/tickets/${ID}/charge_timer_entry?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"timer_entry_id": ${TIMER_ID}}
JSON

# Step 4: Verify auto-generated line item — price_retail should equal the
# product rate. If it's 0.00, patch with update_line_item before invoicing.
LINE=$(curl -s "${BASE}/tickets/${ID}?api_key=${API_KEY}" | \
  jq '.ticket.line_items | map(select(.product_id == 1190473)) | last')
echo "$LINE" | jq '{id, product_id, quantity, price_retail}'

# Step 5: (only if price_retail came in at 0) patch it
LINE_ID=$(echo "$LINE" | jq -r '.id')
curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_line_item_id": ${LINE_ID}, "price_retail": 150.00}
JSON

# Step 6: Create invoice
INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<JSON
{"ticket_id": ${ID}, "customer_id": ${CUST}, "category": "Standard"}
JSON
)
INVOICE_ID=$(echo "$INV_RESP" | jq -r '.invoice.id')

# Step 7: Verify line items transferred and total is correct
curl -s "${BASE}/invoices/${INVOICE_ID}?api_key=${API_KEY}" | \
  jq '{total: .invoice.total, line_items: .invoice.line_items}'

# Step 8: Mark ticket Invoiced
curl -s -X PUT "${BASE}/tickets/${ID}?api_key=${API_KEY}" \
  -H "Content-Type: application/json" \
  --data-binary @- <<'JSON'
{"status": "Invoiced"}
JSON

The two heredocs that interpolate ${TIMER_ID} / ${LINE_ID} / ${ID} / ${CUST} / ${INVOICE_ID} use unquoted <<JSON (allows expansion). The static-payload heredocs use <<'JSON' (single-quoted, no expansion) so any $ inside the body — passwords, regex, jq queries — comes through as a literal. Pick the right form per heredoc.

--labor maps to product IDs: remote → 1190473, onsite → 26118, emergency → 26184, project → 9269129, internal → 9269124, travel → 26117, website → 68055

Override: emergency becomes 26118 with quantity × 1.5 when the customer has prepay_hours > 0. See the Emergency billing branching table above. The override applies to the timer's product_id field and the timer's interval (set end_at start_at to actual_hours × 1.5 so the auto-generated line gets the right quantity).

Error handling

  • 401: API key invalid or expired
  • 404: ticket/customer/invoice not found
  • 422: validation error (show the error message from response body)
  • 429: rate limited (wait 60s and retry)

Integration with session logs

When closing a ticket (/syncro close), offer to create a session log entry in clients/<customer>/session-logs/ documenting what was resolved. Pull the ticket subject, comments, and resolution into a structured log.