# /syncro — Syncro PSA ticket management Create, update, close, comment on, and bill tickets in Syncro PSA. ## Usage ``` /syncro Show open tickets summary /syncro ticket View ticket details + comments /syncro create Create new ticket /syncro update Update ticket status /syncro close Close/resolve a ticket /syncro comment Add a comment to a ticket /syncro bill Add billable time and create invoice /syncro search Search tickets by subject/customer /syncro customers Search customers /syncro move-appointment Find and reschedule an existing appointment /syncro estimate Create ticket + linked estimate with line items and private purchase notes ``` ## 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) **Billing uses `add_line_item` directly — do NOT use `timer_entry → charge_timer_entry`.** The timer workflow is not used. For all billable work (labor, warranty, internal), POST directly to `/tickets//add_line_item` with the correct `product_id`, `name`, `quantity` (decimal hours), `price_retail`, `description`, and `taxable: false`. The `name` field is required — Syncro returns `{"errors":"Name can't be blank"}` if omitted (verified 2026-05-21 on Cascades #32313). **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). **If any API call returns an unexpected response: STOP and report — do NOT try alternative endpoints, payload formats, or retries.** The Syncro API does not change between calls. An unexpected result means either the call failed cleanly (check the error field) or it succeeded with a known-quirky response shape (see Verified Response Shapes below). Experimenting with alternatives creates duplicates that cannot be cleaned up via API. **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/` 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. **Prepaid customers — ALL billing (not just emergency):** `GET /customers/` → `prepay_hours` before creating ANY invoice for a prepaid customer. When you bill a prepaid customer using a billable labor product (remote / onsite / in-shop / web), Syncro automatically deducts from their prepay block and the invoice total shows $0.00. The line item name is annotated "- Applied X Prepay Hours". This is correct behavior — do NOT treat a $0.00 invoice as an error. Verify the deduction by re-fetching `customer.prepay_hours` after invoicing and confirming it dropped by `quantity`. **`9269129` (Labor - Prepaid Project Labor) is EXEMPT — it does NOT deduct from prepay blocks:** Despite the name, this product is categorized as Exempt Labor at $0.00 and contains no prepay-deduction logic. Billing a prepaid customer with this product results in a $0.00 invoice AND no block decrement — silent accounting drift. Discovered 2026-05-04 (see `feedback_syncro_labor_type.md`). NEVER use `9269129` for normal or prepaid work. Only use it if explicitly directed. The correct approach for prepaid customers is a billable labor product matching the delivery channel (remote / onsite / in-shop / web). **Line-item `name` AND `price_retail` MUST both be fetched from the product and set explicitly.** Run `GET /products/` and capture `.product.name` → use as `name`, `.product.price_retail` → use as `price_retail`. Neither populates automatically via API. Omitting `name` returns `{"errors":"Name can't be blank"}`. Omitting `price_retail` leaves the line at $0.00 (verified 2026-04-23 on #32203 and 2026-05-21 on Cascades #32313). **Always pass `"taxable": false` explicitly on labor line items.** Labor products are configured with `taxable: false` in Syncro, but `add_line_item` via API does not inherit the product's taxable setting — it posts the line item as `taxable: true` regardless. Always include `"taxable": false` in the payload to match the product's configured value. **Appointment dates — always verify day-of-week before the preview.** Day-of-week math is easy to get wrong. Before including any appointment date in a preview, run a live check and display the full day name alongside the date (e.g. "Saturday 2026-05-23", never just "2026-05-23"). The user confirms the day name at the preview step — if the name is wrong, the date is wrong. Incident: #32312 booked Sunday May 24 instead of Saturday May 23 (2026-05-21). Reported by Winter. ```bash py -c "import datetime; d = datetime.date(YYYY, M, D); print(d.strftime('%A %Y-%m-%d'))" ``` **After every write operation, post a summary + link to #bot-alerts.** Every ticket created, updated, closed, or commented, every billing run, and every customer created posts a one-line alert to the team's live feed in Discord. This runs AFTER the write succeeds (never before — no alert for an action that didn't happen) and applies regardless of who runs the skill or where (workstation or the Discord bot). Read-only commands (list / view / search) post nothing. Full format, link mapping, and helper call are in "Post to #bot-alerts" below. **Estimate task success criteria — do NOT consider the request fulfilled until ALL of the following are true:** 1. Every line item requested by the user has been added to the estimate and price-fixed via PUT (verify with GET /estimates/{id} — check `.estimate.line_items[]`) 2. Every line item has a corresponding private note on the linked ticket (hidden: true, do_not_email: true) containing: item name, source/retailer, cost, retail price, and any markup 3. The estimate total (subtotal + tax) matches the sum of all line items after recalc 4. The linked ticket subject starts with `Estimate - ` (verify with GET /tickets/{id} — check `.ticket.subject`). If it does not, PUT the ticket with the corrected subject before reporting done. 5. A bot alert has been posted to #bot-alerts If any check fails, complete the missing step before reporting done. This rule fires on initial estimate creation AND on every subsequent "add X to the estimate" request. Incident: 2026-05-22, UPS added to estimate #7189 without a ticket note — caught by Winter. ## Implementation When invoked, use the Syncro REST API via `curl`. All requests include `?api_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-.sops.yaml`. ### Get API key ```bash 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: ```bash 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:** ```bash # 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' 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; always fetch price_retail live from Syncro - 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 #: CUSTOMER: TECH: WORK DONE: LABOR: min ( hrs) @ $/hr = $ Rules: - comment_body must use
for line breaks. Do NOT use
    or
  • — 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": " line breaks>", "line_item_description": "", "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 #: CUSTOMER: NOTE: VISIBILITY: <"Internal only" | "Customer-visible"> Rules: - Use
    for line breaks. Do NOT use
      or
    • . - Professional and factual. No filler. Return ONLY valid JSON: { "subject": "Update", "body": " line breaks>", "preview": "" } ``` #### Claude review checklist (always run before presenting to user) Whether the draft came from Ollama or Claude wrote it directly: 1. `price_retail` was fetched live from `GET /products/` → `.product.price_retail` and matches what will be shown to the user 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 `
      `, not `
        /
      • ` 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: ```bash cat > $VAULT_ROOT/msp-tools/syncro-.sops.yaml <) subdomain: computerguru api-base-url: https://computerguru.syncromsp.com/api/v1 api-docs: https://api-docs.syncromsp.com/ status: active owner: syncro_user_id: tags: [msp-tools, per-user] credentials: credential: notes: Per-user Syncro API token for . 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-.sops.yaml") ``` 5. Commit + push vault repo. ### Endpoints reference #### Tickets | Operation | Method | Endpoint | Body | |---|---|---|---| | List tickets | GET | `/tickets?status=&per_page=25` | — | | Get ticket | GET | `/tickets/` | — | | Create ticket | POST | `/tickets` | see full create workflow below | | Update ticket | PUT | `/tickets/` | `{"status": "In Progress", "priority": "..."}` | | Delete ticket | DELETE | `/tickets/` | — | **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/` | Returns `{"appointment": {...}}` | | Create | POST | `/appointments` | Used in ticket creation flow (Call 3) | | Move / edit | PUT | `/appointments/` | Verified 2026-04-24 — updates `start_at`/`end_at` | | Delete | DELETE | `/appointments/` | Not yet verified | **Finding an appointment by customer:** `GET /appointments?start_at=` 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=` — find appointment ID 2. Confirm new date/time with user 3. `PUT /appointments/` 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. Defaults to API key owner if not specified (mike → 1735, howard → 1750). MUST always be included in the POST payload — never omit. | | 8 | **Contact** | Omit unless the ticket is opened by or specifically regarding a named contact. When omitted, Syncro assigns the customer's primary contact automatically. Only look up and set `contact_id` when the user names a specific person. | | 9 | **Appointment Type** | Omit unless the user specifies one of: Remote, Onsite, In Shop, Phone Call, Reminder. If omitting, include the delivery type in the ticket subject line so it's visible on the calendar. | | 10 | **Location** | Free text; usually blank unless onsite at non-primary address | | 11 | **Start Time** | ISO8601 datetime; omit if no scheduled appointment | | 12 | **End Time** | Default: start + 90 minutes | | 13 | **Asset** | Search `GET /customer_assets?customer_id=N&query=` if a specific device is involved | **Contact lookup (only when a specific contact is named):** ```bash curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | \ jq '[.customer.contacts[] | {id, name, email}]' ``` Match by name, confirm with user, then include `contact_id` in the ticket POST. Never include `contact_id: null` — omit the field entirely when using the default. #### Step 2 — Show preview and confirm Display the full ticket before posting. Include all populated fields. Wait for explicit confirmation. ``` TICKET PREVIEW -------------- Customer: Subject: Issue Type: Priority: Description: Due Date: Assigned To: Contact: (or named contact if specified) Do Not Email: APPOINTMENT (omit section if no appointment) ----------- Type: Start: at ← day name verified with py datetime End: at (90 min default) Location: ASSET: Confirm? (yes/no) ``` #### Step 3 — Execute (after confirmation) **Call 1 — Create ticket:** ```bash 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" } JSON ) TICKET_ID=$(echo "$RESP" | jq -r '.ticket.id') CUST_ID=$(echo "$RESP" | jq -r '.ticket.customer_id') ``` Omit `contact_id` unless a specific contact was named — Syncro assigns the primary automatically. Omit `asset_ids` unless an asset was identified. Omit `do_not_email` unless suppression was requested. Never include fields with null values. The `'JSON'` quoting on the heredoc suppresses `$` expansion inside the payload. **Call 2 — Post initial description as "Initial Issue" comment:** ```bash 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": "", "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):** ```bash curl -s -X POST "${BASE}/appointments?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ --data-binary @- <<'JSON' { "ticket_id": N, "customer_id": N, "start_at": "ISO8601", "end_at": "ISO8601" } JSON ``` Omit `appointment_type_id` unless the user specifies Remote (59289), Onsite (4322), In Shop (4321), Phone Call (4323), or Reminder (193053). When omitting the type, ensure the ticket subject includes the delivery method so it's identifiable on the calendar. Omit `location` unless a non-standard location is specified. 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/.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. --- ### Verified Response Shapes Every endpoint's response shape, verified against the live API. Parse exactly as shown — no guessing. | Operation | Endpoint | Response shape | Key to parse | |---|---|---|---| | Create ticket | POST `/tickets` | `{"ticket": {...}}` | `.ticket.id`, `.ticket.number` | | Update ticket | PUT `/tickets/{id}` | `{"ticket": {...}}` | `.ticket.status` | | Add comment | POST `/tickets/{id}/comment` | `{"comment": {...}}` | `.comment.id` | | Add line item | POST `/tickets/{id}/add_line_item` | **FLAT** `{"id": N, ...}` | `.id` | | Update line item | PUT `/tickets/{id}/update_line_item` | `{"ticket_line_item": {...}}` | `.ticket_line_item.id` | | Remove line item | POST `/tickets/{id}/remove_line_item` | `{"success": true, "message": ""}` | — | | Create invoice | POST `/invoices` | `{"invoice": {...}}` | `.invoice.id`, `.invoice.total` | | Get invoice | GET `/invoices/{id}` | `{"invoice": {"line_items": [...]}}` | `.invoice.line_items[].price` (not `price_retail`) | | Delete invoice | DELETE `/invoices/{id}` | `{"message": "ID: We deleted # N."}` | — | | Create appointment | POST `/appointments` | `{"appointment": {...}}` | `.appointment.id` | | Update appointment | PUT `/appointments/{id}` | `{"appointment": {...}}` | `.appointment.start_at` | | Delete appointment | DELETE `/appointments/{id}` | `{"message": "deleted"}` | — | | Create estimate | POST `/estimates` | `{"estimate": {...}}` | `.estimate.id`, `.estimate.number` | | Add estimate line item | POST `/estimates/{id}/line_items` | `{"estimate": {...}, "line_item": {"id": N, ...}}` | `.line_item.id` | | Get estimate | GET `/estimates/{id}` | `{"estimate": {"line_items": [...]}}` | `.estimate.line_items[].price` | | Delete estimate | DELETE `/estimates/{id}` | `{"message": "N: We deleted # NNNN. "}` | — | **Invoice GET line_items field names differ from ticket line_items:** `item` = product name, `name` = description, `price` = unit rate. Do not use `price_retail` when reading invoice line items. **Estimate line items use the same field naming as invoice line_items:** `item` = product name, `name` = description, `price` = unit rate (not `price_retail`). The add-line-item endpoint is `POST /estimates/{id}/line_items` — NOT `add_line_item` (that path 404s). Response includes both the updated estimate and the created line_item as sibling keys. **`start_at` in ticket POST is unreliable** — always create appointments via separate POST `/appointments`. Do not rely on `start_at`/`end_at` on the ticket object itself. --- #### Comments ```bash # POST comment — response: {"comment": {...}} COMMENT_RESP=$(curl -s -X POST "${BASE}/tickets/${ID}/comment?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ --data-binary @- <<'JSON' { "subject": "Resolution", "body": "Work summary here. Use
        for line breaks. No
          /
        • .", "hidden": false, "do_not_email": false } JSON ) COMMENT_ID=$(echo "$COMMENT_RESP" | jq -r '.comment.id') ``` - `hidden: true` = internal only (customer can't see) - `do_not_email: true` = suppress email to customer - Body is HTML; use `
          ` for line breaks. `
            `/`
          • ` do not render in Syncro. - Do NOT wrap the payload in `{"comment": {...}}` — returns 422. - **If `COMMENT_ID` is null:** GET `/tickets/{id}` and check `.ticket.comments[]` by subject before doing anything else. Comments cannot be deleted via API — duplicates require manual GUI removal. #### Customers ```bash # Search curl -s "${BASE}/customers?query=&per_page=25&api_key=${API_KEY}" | jq '[.customers[] | {id, business_name, email}]' # Get one curl -s "${BASE}/customers/${CUST_ID}?api_key=${API_KEY}" | jq '{id: .customer.id, prepay_hours: .customer.prepay_hours}' ``` #### Line Items All billing uses `add_line_item` directly. Do not use `timer_entry → charge_timer_entry`. ```bash # Add line item — response is FLAT: {"id": N, "ticket_id": N, "product_id": N, "price_retail": N, ...} LINE_RESP=$(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, "name": "Labor - Remote Business", "description": "One-line billing description.", "quantity": 1.0, "price_retail": 150.00, "taxable": false } JSON ) LINE_ID=$(echo "$LINE_RESP" | jq -r '.id') # Update line item — response: {"ticket_line_item": {...}} curl -s -X PUT "${BASE}/tickets/${ID}/update_line_item?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ --data-binary @- < 0` | delivery-channel product, qty = actual_hours | delivery-channel product, qty = actual_hours × **1.5** | Prepaid blocks debit by quantity not dollars — for emergency prepaid, use the normal delivery-channel product at 1.5× qty, not `26184` (which has 1.5× already in the dollar rate). #### Invoices ```bash # Create invoice from ticket — response: {"invoice": {"id": N, "total": "N.N", "ticket_id": N, ...}} INV_RESP=$(curl -s -X POST "${BASE}/invoices?api_key=${API_KEY}" \ -H "Content-Type: application/json" \ --data-binary @- <