---
name: Syncro API — invoice verification pattern
description: The /invoices?customer_id=X list endpoint returns no ticket linkage; query individual invoices (/invoices/{number}) for ticket_id. Compare by ticket ID, not number.
type: reference
---

# Syncro API: Correct Invoice Verification Pattern

**Created:** 2026-04-30
**Category:** API Integration, Billing Verification
**Keywords:** syncro, invoice, verification, ticket linkage, billing, false positive

## Problem

The Syncro API's invoice list endpoint (`/api/v1/invoices?customer_id=X`) **does not return line items or ticket linkage information**. This causes false negatives when trying to verify if a ticket has an attached invoice.

## Incorrect Approach (DO NOT USE)

```python
# WRONG - This will always fail to find invoice linkage
inv_result = requests.get(
    f'https://computerguru.syncromsp.com/api/v1/invoices?customer_id={customer_id}',
    headers={'Authorization': token}
)
invoices = inv_result.json().get('invoices', [])

# This will NOT work - line_items are not in the list response
for inv in invoices:
    for item in inv.get('line_items', []):  # line_items will be empty or missing
        if item.get('ticket_id') == ticket_id:
            # This code path never executes
```

**Why this fails:**
- List endpoint returns minimal invoice data
- No `line_items` array
- No `ticket_id` field
- Returns only: number, total, status, created_at, customer_id

## Correct Approach (USE THIS)

Invoice linkage is stored at the **invoice level, not in line items**. You must query each invoice individually to get the `ticket_id` field.

```python
import requests, json

SYNCRO_TOKEN = "your_token_here"
SYNCRO_BASE = "https://computerguru.syncromsp.com/api/v1"

def find_invoice_for_ticket(ticket_id, customer_id):
    """
    Correctly verify if a ticket has an attached invoice.

    Args:
        ticket_id: Syncro ticket ID (integer from ticket object, not ticket number)
        customer_id: Syncro customer ID

    Returns:
        Invoice number (string) if found, None otherwise
    """
    # Step 1: Get list of invoice numbers for customer
    list_resp = requests.get(
        f'{SYNCRO_BASE}/invoices?customer_id={customer_id}',
        headers={'Authorization': SYNCRO_TOKEN}
    )
    invoices = list_resp.json().get('invoices', [])

    # Step 2: Query each invoice individually to check ticket_id
    for inv in invoices:
        inv_number = inv.get('number')  # Note: this is a STRING

        # Get full invoice details
        detail_resp = requests.get(
            f'{SYNCRO_BASE}/invoices/{inv_number}',
            headers={'Authorization': SYNCRO_TOKEN}
        )

        detail_data = detail_resp.json()
        if 'invoice' in detail_data:
            full_invoice = detail_data['invoice']

            # The ticket_id is at the invoice level, not in line items
            if full_invoice.get('ticket_id') == ticket_id:
                return inv_number

    return None

# Usage example
ticket_data = requests.get(
    f'{SYNCRO_BASE}/tickets?number=32223',
    headers={'Authorization': SYNCRO_TOKEN}
).json()

ticket = ticket_data['tickets'][0]
ticket_id = ticket['id']  # Use ID, not number
customer_id = ticket['customer_id']

invoice_num = find_invoice_for_ticket(ticket_id, customer_id)
if invoice_num:
    print(f"Ticket has invoice #{invoice_num}")
else:
    print("No invoice found")
```

## Critical Details

1. **Invoice numbers are strings, not integers**
   - `inv.get('number')` returns `"67469"` not `67469`
   - Use string comparison: `if str(num) == '67469'`

2. **Use ticket ID, not ticket number**
   - Ticket number: `32223` (user-visible)
   - Ticket ID: `109554336` (internal, used for linkage)
   - Invoice `ticket_id` field contains the internal ID

3. **Invoice endpoint structure**
   - List: `/api/v1/invoices?customer_id=X` → minimal data
   - Detail: `/api/v1/invoices/{invoice_number}` → full data including `ticket_id`

4. **Response structure difference**
   ```json
   // List endpoint response
   {
     "invoices": [
       {
         "number": "67469",
         "total": "75.0",
         "status": null,
         "created_at": "2026-04-28T16:36:22.421-07:00"
         // NO ticket_id here
         // NO line_items here
       }
     ]
   }

   // Detail endpoint response (/invoices/67469)
   {
     "invoice": {
       "number": "67469",
       "total": "75.0",
       "status": null,
       "created_at": "2026-04-28T16:36:22.421-07:00",
       "ticket_id": 109554336,  // <-- THIS is what you need
       "line_items": [...]        // <-- These are also here
     }
   }
   ```

## Real-World Impact

**Case Study (2026-04-30):**
- Analyzed 31 tickets with zero time entries
- Used incorrect list-endpoint approach
- Falsely concluded ALL 31 had no invoices
- Created false "CRITICAL billing gap" alarm
- Reality: 29 had proper invoices, 2 were correctly Non-Billable
- 93.5% success rate misidentified as 0% success

**User caught the error immediately:** "That kittle ticket DOES have an invoice attached, perhaps your search isn't working properly?"

**Impact:** Nearly triggered unnecessary remediation work, lost credibility, wasted time

## Rate Limiting

When verifying multiple tickets:
- List endpoint: 1 call per customer
- Detail endpoint: 1 call per invoice
- Add `time.sleep(0.1)` between detail calls
- For 30 tickets with ~20 invoices each = ~600 API calls
- Budget 60+ seconds for full verification

## When to Use This

Use this pattern when:
- Verifying if a ticket has been invoiced
- Auditing billing completeness
- Reconciling tickets marked "Invoiced" status
- Investigating billing workflow issues

**DO NOT assume** invoice linkage exists without checking the detail endpoint.

## Related Files

- Session log with error analysis: `session-logs/2026-04-30-session.md`
- Syncro API credentials: `vault:msp-tools/syncro.sops.yaml`
- Syncro base URL: `https://computerguru.syncromsp.com/api/v1`

## See Also

- `.claude/CLAUDE.md` — Data integrity rule: "Never use placeholder/fake data"
- This pattern applies to any API where list ≠ detail responses
