---
title: "Cookbook: Opening a Ticket from a Monitoring System"
section: "Getting Started"
order: 3
excerpt: "Step-by-step guide for automatically creating a support ticket from a monitoring alert: identify the customer from their 3CX PBX host, look up the right technical contact, and open the ticket — all via the BlueRockTEL API."
---

This cookbook covers the end-to-end flow for a monitoring agent (such as LibreNMS) that detects an issue on a customer's 3CX PBX instance and needs to automatically open a support ticket in BlueRockTEL. The same pattern applies to any alerting system that monitors customer infrastructure.

**Prerequisites:** You must have a valid API bearer token. See [Authentication](/api-documentation/authentication) for details.

```bash
export BASE_URL="https://your-instance.bluerocktel.net/api"
export TOKEN="your-bearer-token"
```

---

## Step 1: Discover Ticket Metadata

Before creating tickets, your monitoring system needs three IDs: a priority, a category, and a sub-category. Fetch them once on startup and store them in your configuration — they rarely change.

**Endpoints:**

| Endpoint | Purpose |
|---|---|
| `GET /v1/ticketing/priorities` | List available priorities |
| `GET /v1/ticketing/categories` | List ticket categories |
| `GET /v1/ticketing/sub-categories` | List sub-categories with their parent category |

```bash
# Fetch priorities
curl -s -X GET "$BASE_URL/v1/ticketing/priorities" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"

# Fetch categories
curl -s -X GET "$BASE_URL/v1/ticketing/categories" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"

# Fetch sub-categories
curl -s -X GET "$BASE_URL/v1/ticketing/sub-categories" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
```

```python
import requests

BASE_URL = "https://your-instance.bluerocktel.net/api"
TOKEN = "your-bearer-token"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"}

priorities    = requests.get(f"{BASE_URL}/v1/ticketing/priorities", headers=HEADERS).json()
categories    = requests.get(f"{BASE_URL}/v1/ticketing/categories", headers=HEADERS).json()
sub_categories = requests.get(f"{BASE_URL}/v1/ticketing/sub-categories", headers=HEADERS).json()

# Pick the IDs that match your monitoring workflow and hard-code them:
PRIORITY_ID         = 2   # e.g. "High"
CATEGORY_ID         = 5   # e.g. "Infrastructure"
SUB_CATEGORY_ID     = 12  # e.g. "PBX / 3CX"
```

```php
use Illuminate\Support\Facades\Http;

$priorities     = Http::withToken($token)->get("{$baseUrl}/v1/ticketing/priorities")->json();
$categories     = Http::withToken($token)->get("{$baseUrl}/v1/ticketing/categories")->json();
$subCategories  = Http::withToken($token)->get("{$baseUrl}/v1/ticketing/sub-categories")->json();

// Store the matching IDs in your config:
// $priorityId = 2; $categoryId = 5; $subCategoryId = 12;
```

```javascript
const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" };

const [priorities, categories, subCategories] = await Promise.all([
  fetch(`${BASE_URL}/v1/ticketing/priorities`, { headers }).then(r => r.json()),
  fetch(`${BASE_URL}/v1/ticketing/categories`, { headers }).then(r => r.json()),
  fetch(`${BASE_URL}/v1/ticketing/sub-categories`, { headers }).then(r => r.json()),
]);

// Store the matching IDs in your config:
// const PRIORITY_ID = 2; const CATEGORY_ID = 5; const SUB_CATEGORY_ID = 12;
```

---

## Step 2: Identify the Customer

Choose the lookup strategy that matches what your monitoring system knows about the affected host.

| Available identifier | Use |
|---|---|
| Customer name or partial text | Option A — Search by customer name |
| Customer account code (e.g. `CL2385`) | Option B — Look up by customer account number |
| PBX hostname (e.g. `acme.3cx.fr`) | Option C — Resolve from PBX hostname |

---

### Option A — Search by customer name

**Endpoint:** `GET /v1/customers/search`

The search matches against the customer's full-text index (name, account, contacts, phone numbers, IP addresses, and licence keys). Returns up to 15 results.

**Query parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `term` | string | Yes | Search term (spaces become wildcards) |

```bash
curl -s -X GET "$BASE_URL/v1/customers/search?term=acme" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
```

```python
response = requests.get(
    f"{BASE_URL}/v1/customers/search",
    params={"term": "acme"},
    headers=HEADERS,
)
results = response.json()

if len(results) == 1:
    customer_id = results[0]["id"]
elif len(results) == 0:
    raise Exception("No customer found")
else:
    # Multiple matches — narrow down by exact name or account
    customer_id = next(c["id"] for c in results if c["name"] == "Acme Telecom SAS")
```

```php
$response = Http::withToken($token)
    ->get("{$baseUrl}/v1/customers/search", ['term' => 'acme']);

$results = $response->json();
$customerId = $results[0]['id']; // refine if multiple results
```

```javascript
const response = await fetch(
  `${BASE_URL}/v1/customers/search?term=acme`,
  { headers }
);
const results = await response.json();
const customerId = results[0]?.id;
```

**Response 200:**
```json
[
  {
    "id": 42,
    "name": "Acme Telecom SAS",
    "customer_account": "CL000042",
    "quick_search_label": "Acme Telecom SAS CL000042"
  }
]
```

---

### Option B — Look up by customer account number

**Endpoint:** `GET /v1/customers/lookup`

Exact lookup by customer account code. Recommended when your monitoring inventory already stores the `customer_account` alongside each host.

**Query parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `customer_account` | string | Yes | Customer account code (e.g. `CL2385`) |

```bash
curl -s -X GET "$BASE_URL/v1/customers/lookup?customer_account=CL2385" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
```

```python
response = requests.get(
    f"{BASE_URL}/v1/customers/lookup",
    params={"customer_account": "CL2385"},
    headers=HEADERS,
)
data = response.json()

if not data.get("found"):
    raise Exception("Customer not found")

customer_id = data["data"]["id"]
```

```php
$response = Http::withToken($token)
    ->get("{$baseUrl}/v1/customers/lookup", ['customer_account' => 'CL2385']);

$data = $response->json();
$customerId = $data['data']['id'];
```

```javascript
const response = await fetch(
  `${BASE_URL}/v1/customers/lookup?customer_account=CL2385`,
  { headers }
);
const data = await response.json();
const customerId = data.found ? data.data.id : null;
```

**Response 200:**
```json
{
  "found": true,
  "data": {
    "id": 42,
    "name": "Acme Telecom SAS",
    "customer_account": "CL2385",
    "vip": false,
    "on_call_duty": false,
    "setup_follow_up": false,
    "setup_follow_up_user": null
  }
}
```

---

### Option C — Resolve from PBX hostname

**Endpoint:** `GET /v1/pbx3cx-hosts/lookup`

Look up a 3CX host by its hostname. Returns the host record with all associated customers — the most direct path when your monitoring alert carries the PBX FQDN.

**Query parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `host_name` | string | Yes | Hostname or FQDN of the 3CX instance (e.g. `acme.3cx.fr`) |

```bash
curl -s -X GET "$BASE_URL/v1/pbx3cx-hosts/lookup?host_name=acme.3cx.fr" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
```

```python
response = requests.get(
    f"{BASE_URL}/v1/pbx3cx-hosts/lookup",
    params={"host_name": "acme.3cx.fr"},
    headers=HEADERS,
)

if response.status_code == 404:
    raise Exception("3CX host not found in BlueRockTEL")

host = response.json()
customers = host.get("customers", [])

if not customers:
    raise Exception("Host found but no customer is linked to it yet")

customer_id = customers[0]["id"]
print(f"Customer resolved: id={customer_id}, name={customers[0]['name']}")
```

```php
$response = Http::withToken($token)
    ->get("{$baseUrl}/v1/pbx3cx-hosts/lookup", ['host_name' => 'acme.3cx.fr']);

if ($response->status() === 404) {
    throw new \Exception('3CX host not found');
}

$host = $response->json();
$customerId = $host['customers'][0]['id'] ?? null;
```

```javascript
const response = await fetch(
  `${BASE_URL}/v1/pbx3cx-hosts/lookup?host_name=acme.3cx.fr`,
  { headers }
);

if (response.status === 404) throw new Error("3CX host not found");

const host = await response.json();
const customerId = host.customers?.[0]?.id;
```

**Response 200:**
```json
{
  "id": 7,
  "host_name": "acme.3cx.fr",
  "code": "XK93PL",
  "active": true,
  "ip_address": "203.0.113.10",
  "licence_type": "Enterprise",
  "customers": [
    {
      "id": 42,
      "name": "Acme Telecom SAS",
      "customer_account": "CL2385"
    }
  ]
}
```

---

### Option D — List a customer's PBX hosts (optional confirmation)

**Endpoint:** `GET /v1/pbx3cx-hosts/lookup-by-customer`

List all 3CX hosts registered for a customer, identified by their account code. Useful to confirm which PBX instances are on record before filing a ticket, or to map a customer account to its hosts.

**Query parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `customer_account` | string | Yes | Customer account code (e.g. `CL2385`) |

```bash
curl -s -X GET "$BASE_URL/v1/pbx3cx-hosts/lookup-by-customer?customer_account=CL2385" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
```

```python
response = requests.get(
    f"{BASE_URL}/v1/pbx3cx-hosts/lookup-by-customer",
    params={"customer_account": "CL2385"},
    headers=HEADERS,
)
hosts = response.json().get("data", [])
print(f"{len(hosts)} host(s) registered for this customer")
```

```php
$response = Http::withToken($token)
    ->get("{$baseUrl}/v1/pbx3cx-hosts/lookup-by-customer", [
        'customer_account' => 'CL2385',
    ]);

$hosts = $response->json()['data'] ?? [];
```

```javascript
const response = await fetch(
  `${BASE_URL}/v1/pbx3cx-hosts/lookup-by-customer?customer_account=CL2385`,
  { headers }
);
const { data: hosts } = await response.json();
```

**Response 200:**
```json
{
  "data": [
    {
      "id": 7,
      "host_name": "acme.3cx.fr",
      "code": "XK93PL",
      "active": true
    }
  ]
}
```

---

## Step 3: Create the Ticket

**Endpoint:** `POST /v1/tickets`

The customer is passed as query parameters (`ticketable_type` and `ticketable_id`). The ticket body accepts HTML; only the tags `br`, `img`, `ul`, `ol`, `li`, `strong`, `i`, `p`, and `a` are preserved — all others are stripped.

**Required query parameters:**

| Parameter | Type | Description |
|---|---|---|
| `ticketable_type` | string | Always `App\Customer` |
| `ticketable_id` | integer | Customer ID from Step 2 |

**Body (JSON):**

| Field | Type | Required | Description |
|---|---|---|---|
| `subject` | string | Yes | Ticket subject (HTML stripped) |
| `body` | string | Yes | Ticket body (HTML) |
| `priority_id` | integer | Yes | Priority ID from Step 1 |
| `ticket_category_id` | integer | Yes | Category ID from Step 1 |
| `ticket_sub_category_id` | integer | Yes | Sub-category ID from Step 1 |
| `estimated_resolution_date` | datetime | No | Format `Y-m-d H:i:s`. Defaults to now + 4 hours |
| `tags` | array | No | Array of tag strings |
| `team_id` | integer | No | Route to a specific support team |
| `close` | boolean | No | Set `true` to close the ticket immediately after creation |

```bash
curl -s -X POST "$BASE_URL/v1/tickets?ticketable_type=App%5CCustomer&ticketable_id=42" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "subject": "3CX offline: acme.3cx.fr",
    "body": "<p>Monitoring alert: host <strong>acme.3cx.fr</strong> unreachable since 2026-07-02T08:15:00Z.</p>",
    "priority_id": 2,
    "ticket_category_id": 5,
    "ticket_sub_category_id": 12,
    "tags": ["monitoring", "3cx", "auto"]
  }'
```

```python
import datetime

alert_time = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")

response = requests.post(
    f"{BASE_URL}/v1/tickets",
    params={
        "ticketable_type": "App\\Customer",
        "ticketable_id": customer_id,
    },
    json={
        "subject": f"3CX offline: acme.3cx.fr",
        "body": f"<p>Monitoring alert: host <strong>acme.3cx.fr</strong> unreachable since {alert_time}.</p>",
        "priority_id": PRIORITY_ID,
        "ticket_category_id": CATEGORY_ID,
        "ticket_sub_category_id": SUB_CATEGORY_ID,
        "tags": ["monitoring", "3cx", "auto"],
    },
    headers=HEADERS,
)
response.raise_for_status()
ticket = response.json()
print(f"Ticket created: #{ticket['number']} (id={ticket['id']})")
```

```php
$response = Http::withToken($token)
    ->post("{$baseUrl}/v1/tickets", [
        'ticketable_type'        => 'App\\Customer',
        'ticketable_id'          => $customerId,
        'subject'                => '3CX offline: acme.3cx.fr',
        'body'                   => '<p>Monitoring alert: host <strong>acme.3cx.fr</strong> unreachable.</p>',
        'priority_id'            => $priorityId,
        'ticket_category_id'     => $categoryId,
        'ticket_sub_category_id' => $subCategoryId,
        'tags'                   => ['monitoring', '3cx', 'auto'],
    ]);

$ticket = $response->json();
$ticketId = $ticket['id'];
```

```javascript
const ticketResponse = await fetch(
  `${BASE_URL}/v1/tickets?ticketable_type=App%5CCustomer&ticketable_id=${customerId}`,
  {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({
      subject: "3CX offline: acme.3cx.fr",
      body: "<p>Monitoring alert: host <strong>acme.3cx.fr</strong> unreachable.</p>",
      priority_id: PRIORITY_ID,
      ticket_category_id: CATEGORY_ID,
      ticket_sub_category_id: SUB_CATEGORY_ID,
      tags: ["monitoring", "3cx", "auto"],
    }),
  }
);
const ticket = await ticketResponse.json();
const ticketId = ticket.id;
console.log(`Ticket created: #${ticket.number}`);
```

**Response 201:**
```json
{
  "id": 1024,
  "number": "A7KZ2F",
  "subject": "3CX offline: acme.3cx.fr",
  "open": true,
  "origin": "admin",
  "priority_id": 2,
  "ticket_category_id": 5,
  "ticket_sub_category_id": 12,
  "tags": "[\"monitoring\",\"3cx\",\"auto\"]",
  "estimated_resolution_date": "2026-07-02 12:15:00",
  "opened_at": "2026-07-02 08:15:00",
  "created_at": "2026-07-02T08:15:00.000000Z"
}
```

---

## Step 4: Attach a Technical Contact (Optional)

Linking the customer's technical contact to the ticket ensures notifications reach the right person. This step requires two calls: first fetch the customer's contacts to find the technical one, then attach it to the ticket.

### Step 4a — List contacts for the customer

**Endpoint:** `GET /v1/contacts`

**Query parameters:**

| Parameter | Type | Required | Description |
|---|---|---|---|
| `contactable_type` | string | Yes | `App\Customer` (URL-encode the backslash as `%5C`) |
| `contactable_id` | integer | Yes | Customer ID from Step 2 |

Filter the results client-side by the `role` field to find the technical contact. The `role` field is a free-text string (e.g. `"Technical"`, `"IT Manager"`).

```bash
curl -s -X GET "$BASE_URL/v1/contacts?contactable_type=App%5CCustomer&contactable_id=42" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Accept: application/json"
```

```python
response = requests.get(
    f"{BASE_URL}/v1/contacts",
    params={
        "contactable_type": "App\\Customer",
        "contactable_id": customer_id,
    },
    headers=HEADERS,
)
contacts = response.json()

# Filter for the technical contact (role is a free-text field)
technical_contact = next(
    (c for c in contacts if "technical" in (c.get("role") or "").lower()),
    None,
)

if technical_contact:
    contact_id = technical_contact["id"]
    print(f"Technical contact: {technical_contact['first_name']} {technical_contact['last_name']}")
```

```php
$response = Http::withToken($token)
    ->get("{$baseUrl}/v1/contacts", [
        'contactable_type' => 'App\\Customer',
        'contactable_id'   => $customerId,
    ]);

$contacts = $response->json();

$technicalContact = collect($contacts)->first(
    fn ($c) => str_contains(strtolower($c['role'] ?? ''), 'technical')
);

$contactId = $technicalContact['id'] ?? null;
```

```javascript
const contactsResponse = await fetch(
  `${BASE_URL}/v1/contacts?contactable_type=App%5CCustomer&contactable_id=${customerId}`,
  { headers }
);
const contacts = await contactsResponse.json();

const technicalContact = contacts.find(
  c => (c.role ?? "").toLowerCase().includes("technical")
);
const contactId = technicalContact?.id;
```

**Response 200:**
```json
[
  {
    "id": 88,
    "first_name": "Marie",
    "last_name": "Dupont",
    "email_address": "m.dupont@acme-telecom.fr",
    "role": "Technical Manager",
    "mobile_phone": "+33612345678"
  }
]
```

---

### Step 4b — Attach the contact to the ticket

**Endpoint:** `PUT /v1/tickets/{id}/associates`

**URL parameters:**

| Parameter | Type | Description |
|---|---|---|
| `id` | integer | Ticket ID from Step 3 |

**Body (JSON):**

| Field | Type | Required | Description |
|---|---|---|---|
| `associated` | array | Yes | Array of contact IDs to attach |
| `unassociated` | array | Yes | Array of contact IDs to detach (pass `[]` if none) |

```bash
curl -s -X PUT "$BASE_URL/v1/tickets/1024/associates" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"associated": [88], "unassociated": []}'
```

```python
if contact_id:
    requests.put(
        f"{BASE_URL}/v1/tickets/{ticket['id']}/associates",
        json={"associated": [contact_id], "unassociated": []},
        headers=HEADERS,
    ).raise_for_status()
    print(f"Contact {contact_id} attached to ticket")
```

```php
if ($contactId) {
    Http::withToken($token)
        ->put("{$baseUrl}/v1/tickets/{$ticketId}/associates", [
            'associated'   => [$contactId],
            'unassociated' => [],
        ]);
}
```

```javascript
if (contactId) {
  await fetch(`${BASE_URL}/v1/tickets/${ticketId}/associates`, {
    method: "PUT",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify({ associated: [contactId], unassociated: [] }),
  });
}
```

**Response 201:**
```json
{ "response": "1 associated and 0 unassociated" }
```

---

## Step 5: Add a Reply with Alert Details (Optional)

Attach structured alert data (metrics, log excerpt, timestamps) as the first reply. This makes the ticket immediately actionable for the support team.

**Endpoint:** `POST /v1/replies`

**Required query parameters:**

| Parameter | Type | Description |
|---|---|---|
| `ticket_id` | integer | Ticket ID from Step 3 |

**Body (JSON):**

| Field | Type | Required | Description |
|---|---|---|---|
| `body` | string | Yes | Reply body (HTML) |
| `close` | boolean | Yes | Set `false` to keep the ticket open |
| `send_to_assignee` | boolean | Yes | Notify the assigned technician |
| `send_to_contacts` | boolean | Yes | Send the reply to the ticket's linked contacts |
| `customer_visible` | boolean | No | Set `false` to create an internal note visible only to your team (default: `true`) |

```bash
curl -s -X POST "$BASE_URL/v1/replies?ticket_id=1024" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{
    "body": "<p><strong>Alert details</strong></p><ul><li>Host: acme.3cx.fr</li><li>Time: 2026-07-02 08:14:57 UTC</li><li>Check: ICMP ping — timeout after 30s</li></ul>",
    "close": false,
    "send_to_assignee": true,
    "send_to_contacts": false,
    "customer_visible": false
  }'
```

```python
requests.post(
    f"{BASE_URL}/v1/replies",
    params={"ticket_id": ticket["id"]},
    json={
        "body": (
            "<p><strong>Alert details</strong></p>"
            "<ul>"
            f"<li>Host: acme.3cx.fr</li>"
            f"<li>Time: {alert_time} UTC</li>"
            "<li>Check: ICMP ping — timeout after 30s</li>"
            "</ul>"
        ),
        "close": False,
        "send_to_assignee": True,
        "send_to_contacts": False,
        "customer_visible": False,
    },
    headers=HEADERS,
).raise_for_status()
```

```php
Http::withToken($token)
    ->post("{$baseUrl}/v1/replies", [
        'ticket_id'         => $ticketId,
        'body'              => '<p><strong>Alert details</strong></p><ul><li>Host: acme.3cx.fr</li></ul>',
        'close'             => false,
        'send_to_assignee'  => true,
        'send_to_contacts'  => false,
        'customer_visible'  => false,
    ]);
```

```javascript
await fetch(`${BASE_URL}/v1/replies?ticket_id=${ticketId}`, {
  method: "POST",
  headers: { ...headers, "Content-Type": "application/json" },
  body: JSON.stringify({
    body: "<p><strong>Alert details</strong></p><ul><li>Host: acme.3cx.fr</li></ul>",
    close: false,
    send_to_assignee: true,
    send_to_contacts: false,
    customer_visible: false,
  }),
});
```

**Response 201:**
```json
{
  "id": 4096,
  "ticket_id": 1024,
  "body": "<p><strong>Alert details</strong></p>...",
  "origin": "admin",
  "created_at": "2026-07-02T08:15:01.000000Z"
}
```

---

## Complete Workflow (Python)

The following script combines all steps into a single function. It resolves the customer from the PBX hostname, finds the technical contact, opens the ticket, attaches the contact, and adds an alert-detail reply.

```python
import datetime
import requests

BASE_URL = "https://your-instance.bluerocktel.net/api"
TOKEN    = "your-bearer-token"
HEADERS  = {"Authorization": f"Bearer {TOKEN}", "Accept": "application/json"}

# Hard-coded from one-time Step 1 discovery:
PRIORITY_ID      = 2   # High
CATEGORY_ID      = 5   # Infrastructure
SUB_CATEGORY_ID  = 12  # PBX / 3CX


def open_monitoring_ticket(host_name: str, alert_message: str) -> dict:
    alert_time = datetime.datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")

    # Step 2C — resolve customer from PBX hostname
    r = requests.get(
        f"{BASE_URL}/v1/pbx3cx-hosts/lookup",
        params={"host_name": host_name},
        headers=HEADERS,
    )
    if r.status_code == 404:
        raise Exception(f"Host '{host_name}' is not registered in BlueRockTEL")

    host = r.json()
    customers = host.get("customers", [])
    if not customers:
        raise Exception(f"Host '{host_name}' found but has no linked customer")

    customer_id = customers[0]["id"]
    customer_name = customers[0]["name"]
    print(f"Customer: {customer_name} (id={customer_id})")

    # Step 4a — find the technical contact
    contacts = requests.get(
        f"{BASE_URL}/v1/contacts",
        params={"contactable_type": "App\\Customer", "contactable_id": customer_id},
        headers=HEADERS,
    ).json()

    technical_contact = next(
        (c for c in contacts if "technical" in (c.get("role") or "").lower()),
        None,
    )

    # Step 3 — create the ticket
    ticket = requests.post(
        f"{BASE_URL}/v1/tickets",
        params={"ticketable_type": "App\\Customer", "ticketable_id": customer_id},
        json={
            "subject": f"Monitoring alert: {host_name}",
            "body": f"<p>{alert_message}</p><p>Detected at: {alert_time} UTC</p>",
            "priority_id": PRIORITY_ID,
            "ticket_category_id": CATEGORY_ID,
            "ticket_sub_category_id": SUB_CATEGORY_ID,
            "tags": ["monitoring", "3cx", "auto"],
        },
        headers=HEADERS,
    )
    ticket.raise_for_status()
    ticket = ticket.json()
    ticket_id = ticket["id"]
    print(f"Ticket created: #{ticket['number']} (id={ticket_id})")

    # Step 4b — attach technical contact
    if technical_contact:
        requests.put(
            f"{BASE_URL}/v1/tickets/{ticket_id}/associates",
            json={"associated": [technical_contact["id"]], "unassociated": []},
            headers=HEADERS,
        ).raise_for_status()
        print(f"Contact attached: {technical_contact['first_name']} {technical_contact['last_name']}")

    # Step 5 — add alert detail reply
    requests.post(
        f"{BASE_URL}/v1/replies",
        params={"ticket_id": ticket_id},
        json={
            "body": (
                f"<p><strong>Alert details</strong></p>"
                f"<ul>"
                f"<li>Host: {host_name}</li>"
                f"<li>Time: {alert_time} UTC</li>"
                f"<li>Message: {alert_message}</li>"
                f"</ul>"
            ),
            "close": False,
            "send_to_assignee": True,
            "send_to_contacts": False,
            "customer_visible": False,
        },
        headers=HEADERS,
    ).raise_for_status()

    return ticket


# Example usage:
# ticket = open_monitoring_ticket("acme.3cx.fr", "ICMP ping timeout after 30s")
```

---

## What's Next?

- **Avoid duplicate tickets:** Before opening a new ticket, check for an existing open one:
  `GET /v1/tickets/search?filter[ticketable_id]={customer_id}&filter[ticketable_type]=App\Customer&filter[open]=true`
  Skip ticket creation if one already exists.

- **Close on resolution:** When the monitoring system clears the alert:
  `PUT /v1/tickets/close/{ticket_id}`

- **Log time** if a technician intervened:
  `PUT /v1/tickets/amend/{ticket_id}` with `{ "time": 30 }` (minutes)

- **Retrieve the full ticket** at any time:
  `GET /v1/tickets/{ticket_id}`
