# SIP Capture Screen

> How the SIP capture console screen scopes, lists, and renders signalling ladders for recent calls, and what the CSV export contains.

The **SIP capture** screen shows the signalling ladder for recent calls:
which host sent which SIP message, in what order, and where the exchange
failed. It is the screen you open when a call never produced a usable
`CallEvent` and you need the raw wire exchange.

The screen renders in two places — the agent suite (`SIP capture` under
admin) and the customer portal — from the same shared body component, so
both show the same data for the same workspace.

## Where captures come from

Captures are produced by Heplify and stored in Homer (HEP). The console
does not query Homer's own UI or deep-link to it; it reads Homer's
storage through the `sip.calls` and `sip.transaction` procedures and
renders the ladder inline.

Two consequences follow:

- If Heplify was not running when the call was placed, there is nothing
  to show for that call, even if the call itself succeeded.
- Everything on this screen is signalling. The screen shows SIP messages,
  source/destination hosts, ports, and the raw message bodies. It does not
  show RTP statistics, codec negotiation results as media metrics, or a
  media-mode indicator. For those, use the CDR / metrics surfaces described
  in [Telemetry](/platform/telemetry).

## How a capture is matched to your workspace

Homer stores captures for every call that traverses the capture point.
The console never shows you the raw Homer set — it first resolves which
Call-IDs belong to your workspace, then fetches only those.

Ownership is resolved as the union of two paths:

1. **CDR-based.** ClickHouse `cdrs.tenant_id` is the authoritative owner
   for outbound calls and answered inbound calls.
2. **DID-based fallback.** For inbound calls whose CDR row has not been
   written yet, or was written with an empty `tenant_id`, the console falls
   back to the called number. Homer's `data_header.to_user` carries the
   dialled DID, and each DID belongs to exactly one workspace via
   `phone_number.org_id`. The match is a digit-suffix comparison against
   your workspace's DIDs.

The fallback exists because short or failed inbound calls can send `BYE`
before the CDR pipeline writes a row. Without path 2 those calls would be
invisible here — which is precisely the case you most often want to debug.

Expanding a row applies the same ownership check again before returning
message bodies. `sip.transaction` first counts matching `cdrs` rows for
your tenant and the requested Call-ID; if there are none, it repeats the
DID-suffix check. If neither path claims the Call-ID, the procedure returns
an empty message list rather than the bodies. A guessed Call-ID from
another tenant on a shared Homer therefore returns nothing.

Call-IDs are normalised before lookup: any `@host` suffix on the input is
stripped, so the wire-form value shown in the calls list still resolves
back to the CDR row.

## The time window and refresh behaviour

The date-range picker at the top of the screen sets the capture window.
The default window is the last hour (`now-1h` → `now`).

Both bounds are quantised down to 30-second buckets before they are sent
to the server. This is deliberate: the query key stays stable across
parent re-renders and tooltip hovers, so incidental renders do not trigger
a new fetch. The list also refetches on a 30-second interval, which is in
phase with the bucket size.

Practical effects:

- The window edges "snap" — a range you pick mid-bucket is rounded to the
  bucket boundary, so a call placed in the last few seconds may not appear
  until the next bucket.
- The header shows a `refreshed <time>` stamp that updates whenever the
  list query returns new data.
- **Refresh** forces an immediate refetch. Expanded ladder bodies are
  cached separately and are not refetched on window focus.

The list is capped by a `limit` that the screen sets to 50 calls. The
procedure accepts a limit between 1 and 200.

## Finding a call

Three controls narrow the listing, all applied client-side to the rows
already fetched for the window:

| Control | Matches |
| ------- | ------- |
| Search box | Substring of the Call-ID, or of the from / to phone numbers |
| Direction | `inbound` / `outbound`, from the capture's direction field |
| Status | **Success (2xx)** — status `200` or `OK`; **Failed (4xx/5xx/6xx)** — status beginning `4`, `5`, or `6` |

Because filtering happens after the fetch, narrowing the filters does not
reach further back in time. If a call is missing, widen the **time range**
rather than adjusting the filters.

The counter above the list reads `N of M captured` when a filter is active,
and `M calls captured` when it is not.

## Reading a call row

Each collapsed row is one captured call:

| Element | Meaning |
| ------- | ------- |
| Direction icon | Inbound (arrow in) or outbound (arrow out) |
| Call-ID | The SIP `Call-ID`, in wire form |
| From → To | `from_user` → `to_user` from the capture's data header |
| Duration | Call duration in seconds, or `—` when absent |
| Start | Start time of the capture, local time |
| Status badge | The call's SIP status. Green for `200` / `OK`; red for 4xx / 5xx / 6xx; neutral otherwise |

Any of these may render as `—`. The values come from the captured SIP
headers, and a call that failed early may not have populated them.

Clicking a row expands it and issues the `sip.transaction` query for that
Call-ID over the same quantised window.

## Reading the ladder view

The expanded panel has two parts.

**Call summary.** Four fields: `Call-ID`, `Method` (defaults to `INVITE`
when the capture does not carry one), `Src Host`, and `Dst Host`.

**SIP ladder.** A scrollable list of the captured messages for that
Call-ID, with a count in the header (`SIP Ladder (N messages)`). Each row
is collapsible and carries three columns:

| Column | Source | Notes |
| ------ | ------ | ----- |
| Timestamp | `create_date` | Rendered as local time, 24-hour |
| Method or status | `data_header.method`, falling back to `data_header.status` | Shows `?` when neither is present |
| Path | `src_ip:src_port → dst_ip:dst_port` | The observed sender and receiver of that message |

Expanding a message row reveals the **raw SIP message** exactly as
captured, including all headers and any body. This is where you read the
response code, the `Reason` header, and the SDP.

Messages are listed in the order Homer returns them for the transaction,
so you read the ladder top-to-bottom as the exchange occurred. For how to
interpret the response codes and Q.850 causes you find here, see the
debugging reference.

## When a Call-ID returns no messages

A row can appear in the list and still expand to
`No captured messages`. The list and the ladder are resolved by two
different queries, so this is expected in a few situations:

- **Heplify was not running** at the point in time the leg was signalled,
  so no HEP frames were stored for it.
- **The leg ran outside the time window.** The ladder query uses the same
  quantised `fromMs` / `toMs` as the list. A call whose `INVITE` landed
  just before the window start has its messages outside the bounds even
  though the call itself is listed.
- **Ownership could not be confirmed for the ladder.** If neither the CDR
  `tenant_id` match nor the DID-suffix fallback claims the Call-ID at the
  moment you expand it, `sip.transaction` returns an empty list.

A valid Call-ID returning nothing is therefore not a sign of a broken
Call-ID. Widen the time range first, then check whether the call is owned
via a DID in your workspace.

## The empty state

When the window contains no captures at all, the screen shows
`No SIP captures in this window`. Captures are matched to your workspace's
calls, so the two remedies are to widen the time range or to place a call
and let it appear on the next refresh.

## Exporting the list

**Export CSV** downloads the currently **filtered** list — exactly the
rows visible on screen, not the whole window. The button is disabled while
a fetch is in flight and when the filtered list is empty.

The export runs entirely in the browser from rows already delivered over
tRPC; there is no extra server round-trip. The filename is
`sip-capture-<ISO timestamp>.csv`.

Columns, in order:

```
call_id, start_time, duration_sec, from, to, method, status,
src_host, dst_host, direction
```

Every field is quoted, and embedded quotes are doubled. These are the same
fields the table displays, so the download mirrors what the operator sees.

> **NOTE:**
> The CSV contains the call-level summary only. It does **not** contain the
> SIP message bodies from the ladder, and the screen does not offer a pcap
> or HEPv3 export. To inspect raw messages, expand the ladder rows in the
> console.

## Where this differs from call traces

Both screens are diagnostic, but they answer different questions.

| | SIP capture | Call traces |
| --- | --- | --- |
| Data source | Heplify → Homer (HEP) | Gateway-emitted trace / event data |
| Unit | One SIP `Call-ID` | One call as TeleQuick observed it |
| Shows | Raw SIP messages, src/dst host and port, response codes | Gateway-side call lifecycle |
| Available when | The capture point saw the packets | The gateway produced events |

Use SIP capture when a call fails **before** any `CallEvent` reaches the
SDK, or when you need to prove what the carrier actually sent on the wire.
Use call traces when the call reached the gateway and you want the
platform's view of it.

## Related

- [Telemetry](/platform/telemetry) — the HEPv3 capture stream, CDR schema, and metrics
- [Telephony Metrics](/glossary/metrics) — definitions for the numbers that captures do not carry
