# Notifications, live streams and support tickets

The three ways Zaroz tells you what is going on, and the one way you talk back. The samples use the helper from [Using the Zaroz API](/guides/api/overview). For the events of a Workload's units (and their webhooks) see [Events](/guides/units-api/events) and [Webhooks](/guides/units-api/webhooks).

## Notifications

Platform messages for your account: an invoice, a service that failed, an invitation. They are newest first and paged. `unread_count` is the total across every page, which is what a badge shows.

**curl**

```bash
curl -sS "$ZAROZ_API/me/notifications?page=1&page_size=20" -H "$AUTH"
curl -sS -X POST "$ZAROZ_API/me/notifications/$NOTIFICATION/read" -H "$AUTH"       # idempotent
curl -sS -X POST "$ZAROZ_API/me/notifications/read-all" -H "$AUTH"
```

**TypeScript**

```ts
import { call } from "./zaroz";

const feed = await call("GET", "/me/notifications?page_size=20");
console.log(`${feed.body.unread_count} unread`);
for (const n of feed.body.data) console.log(n.kind, n.title, n.href, n.read);
await call("POST", "/me/notifications/read-all");
```

**Python**

```python
from zaroz import call

_, feed = call("GET", "/me/notifications?page_size=20")
print(feed["unread_count"], "unread")
for n in feed["data"]:
    print(n["kind"], n["title"], n["href"], n["read"])
call("POST", "/me/notifications/read-all")
```

Marking a notification read is idempotent. A notification that is not yours answers `404`, the same as one that does not exist.

## Live streams (server-sent events)

Three routes hold a `text/event-stream` open so you do not have to poll.

| Stream | Route | Frames |
|---|---|---|
| Your new notifications | `GET /me/notifications/stream` | `notification_created` |
| One service's live state | `GET /service-instances/{id}/status/stream` | `status`, `network_metrics`, `resource_metrics`, `vm_progress` |
| One ticket's conversation | `GET /support/tickets/{id}/events` | `message_sent`, `message_edited`, ... |

The server closes each connection after **fifteen minutes** and the first frame sets the reconnect interval, so reconnect when it ends (a browser `EventSource` does it by itself). The service stream's `status` frame carries the whole picture (aggregate run state, the lifecycle actions that make sense right now, the operation in flight, every visible resource with live usage) on connect, whenever a resource changes and on a five-second tick. It answers `404` for a service whose subscription is suspended, terminated, cancelled or failed.

**curl**

```bash
curl -sS -N "$ZAROZ_API/service-instances/$SERVICE/status/stream" -H "$AUTH" -H "Accept: text/event-stream"
```

**TypeScript**

```ts
// Node 20+: a plain fetch can read the stream; EventSource cannot send an Authorization header.
const res = await fetch(`${process.env.ZAROZ_API}/service-instances/${SERVICE}/status/stream`, {
  headers: { Authorization: `Bearer ${process.env.ZAROZ_TOKEN}`, Accept: "text/event-stream" },
});
const decoder = new TextDecoder();
let buffer = "";
for await (const chunk of res.body as any) {
  buffer += decoder.decode(chunk, { stream: true });
  let i;
  while ((i = buffer.indexOf("\n\n")) >= 0) {
    const frame = buffer.slice(0, i);
    buffer = buffer.slice(i + 2);
    const event = /^event: (.*)$/m.exec(frame)?.[1];
    const data = /^data: (.*)$/m.exec(frame)?.[1];
    if (event === "status" && data) console.log(JSON.parse(data));
  }
}
```

**Python**

```python
import json, os, requests

url = f"{os.environ['ZAROZ_API']}/service-instances/{SERVICE}/status/stream"
headers = {"Authorization": "Bearer " + os.environ["ZAROZ_TOKEN"], "Accept": "text/event-stream"}
with requests.get(url, headers=headers, stream=True, timeout=(10, 60)) as r:
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if line.startswith("event: "):
            event = line[7:]
        elif line.startswith("data: ") and event == "status":
            print(json.loads(line[6:]))
```

Use a plain HTTP client as above for scripts. A browser `EventSource` cannot set the `Authorization` header, so web apps stream through their own back end.

## Support tickets

Tickets belong to an organization; every member sees the organization's tickets and may reply to a colleague's. Staff identities are never exposed: replies show as "Support Team".

**curl**

```bash
# open a ticket in an organization
curl -sS -X POST "$ZAROZ_API/organizations/$ORG/tickets" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"subject": "Server will not start", "body": "It restarts in a loop since the last deploy. Service id: …"}'
# or in your personal organization
curl -sS -X POST "$ZAROZ_API/me/tickets" -H "$AUTH" -H "Content-Type: application/json" -d '{"subject": "…", "body": "…"}'

curl -sS "$ZAROZ_API/support/tickets?page_size=20" -H "$AUTH"
curl -sS "$ZAROZ_API/support/tickets/$TICKET" -H "$AUTH"                       # with its messages
curl -sS -X POST "$ZAROZ_API/support/tickets/$TICKET/messages" -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"body": "Here are the logs you asked for."}'
```

**TypeScript**

```ts
const opened = await call("POST", `/organizations/${ORG}/tickets`, {
  subject: "Server will not start",
  body: "It restarts in a loop since the last deploy.",
});
const TICKET = opened.body.ticket.id;
await call("POST", `/support/tickets/${TICKET}/messages`, { body: "Here are the logs you asked for." });
const detail = await call("GET", `/support/tickets/${TICKET}`);
for (const m of detail.body.messages) console.log(m.author_name, m.body);
```

**Python**

```python
_, opened = call("POST", f"/organizations/{ORG}/tickets", {
    "subject": "Server will not start",
    "body": "It restarts in a loop since the last deploy.",
})
TICKET = opened["ticket"]["id"]
call("POST", f"/support/tickets/{TICKET}/messages", {"body": "Here are the logs you asked for."})
_, detail = call("GET", f"/support/tickets/{TICKET}")
for m in detail["messages"]:
    print(m["author_name"], m["body"])
```

Opening a ticket in `/me/tickets` always uses your personal organization; there is no organization to get wrong. The list shows a last-message preview, the unread count and the review status. When a ticket is closed you can send a satisfaction review once: `POST /support/tickets/{id}/review` with `problem_solved` (boolean), `resolution_speed` and `response_quality` (small integers) and an optional `comment`; only the ticket's owner can.

## Giving support access to a service

Support may ask, inside a ticket, for access to **one** of your services. You decide:

**curl**

```bash
curl -sS "$ZAROZ_API/support/tickets/$TICKET/access-requests" -H "$AUTH"
curl -sS -X POST "$ZAROZ_API/support/access-requests/$REQUEST/approve" -H "$AUTH"
curl -sS -X POST "$ZAROZ_API/support/access-requests/$REQUEST/deny" -H "$AUTH"
curl -sS "$ZAROZ_API/support/access-grants" -H "$AUTH"                         # who is inside right now
curl -sS -X DELETE "$ZAROZ_API/support/access-requests/$REQUEST" -H "$AUTH"    # withdraw an approval
```

**TypeScript**

```ts
const grants = await call("GET", "/support/access-grants");
for (const g of grants.body) console.log(g.service_instance_id, g.expires_at, g.state);
await call("DELETE", `/support/access-requests/${grants.body[0].id}`); // withdraw at any time
```

**Python**

```python
_, grants = call("GET", "/support/access-grants")
for g in grants:
    print(g["service_instance_id"], g["expires_at"], g["state"])
call("DELETE", f"/support/access-requests/{grants[0]['id']}")   # withdraw at any time
```

An approved grant is for the one service named on the request and nothing else you own. It starts now and **stops on its own**: after `max_duration_hours`, the moment the ticket is closed, or when you withdraw it. `GET /support/access-grants` answers only live grants, so it is the check for "is anybody inside my infrastructure right now".
