Hipcall Developers Knowledgebase / API

Live Transcript Stream Guide

Hipcall writes out what is said on your calls while the call is still going. This guide covers how to receive that text in your own system: you open a WebSocket connection, and the transcript of every call on your account streams to you as it is spoken.

Updated October 14, 2026
Read 25 min
On this page

Hipcall writes out what is said on your calls while the call is still going. This guide covers how to receive that text in your own system: you open a WebSocket connection, and the transcript of every call on your account streams to you as it is spoken.

Who this is for: a developer working in their own software. You do not need this guide to use transcription inside Hipcall; the text already appears next to the call recording. This stream is for carrying the text into your own system.

How it works

A speech recognition session is attached when the call connects. The recognised text is published in pieces while the call is still in progress. You receive those pieces over a WebSocket and reassemble them on your side.

Call (Hipcall phone platform)
└─ speech recognition → transcript events
└─ wss://stream.hipcall.com.tr/v1/stream
└─ your server (WebSocket client)

You open the connection; Hipcall does not connect to your server. That is where this differs from webhooks: there is no public endpoint to expose and no signature to verify.

That is all you need to know going in:

  1. Every event arrives inside an envelope: v, type, seq, call_uuid, ts, data.
  2. There is one rule for reassembling the text: upsert by segment_id, and is_final: true means that piece is frozen.
  3. The transcript.completed event that arrives when the call ends is authoritative; it supersedes everything sent before it.

Prerequisites

What you need Where
Transcription enabled on your account Settings → AI → Transcription
API access in your plan Without it the "Live transcript stream" toggle appears locked
A valid API token Settings → Developer → API Tokens
A jeton balance Transcription is charged per minute in jetons; no new text is produced once the balance runs out
A server that can run a WebSocket client Connect from a server, not a browser, since the credential travels in a header

Step 1: Turn on transcription and the live stream

https://use.hipcall.com.tr/portal/settings/ai/transcription/

  1. Switch Transcribe calls on.
  2. Under Which calls, select at least one of Inbound calls, Outbound calls, Internal calls. The form cannot be saved with none selected.
  3. Under When the text is ready, choose While the call is happening. The live stream only works with this option. With After the call ends the text is only produced next to the call recording, so there is nothing to stream.
  4. Switch Live transcript stream on.
  5. Press Save.

[Screenshot: Transcription settings page with "While the call is happening" selected and "Live transcript stream" on]

Note: switching back to After the call ends also switches the live stream off. The page warns you about this before you save.

Beta: this feature is in beta. If the page shows the notice "These settings are not applied to your calls yet", then which calls get transcribed is still decided by your account's existing setup; the toggles here still control the stream itself. The notice disappears once the phone platform starts reading these settings.

Step 2: Create an API token

https://use.hipcall.com.tr/portal/settings/developer/api-tokens/

Create a new API Token → give it a name and an expiry date (default one year, at most three years) → Save. The token is shown only once; store it somewhere safe.

It is the same token you use against the REST API. There is no separate streaming credential: that token already grants read access to stored transcripts, so streaming them live is a change of latency rather than of authority.

Revoking the token closes an open connection. The server re-checks the token and the account's entitlement every 5 minutes; a revoked or expired token drops the connection with close code 1008.

Step 3: Connect

wss://stream.hipcall.com.tr/v1/stream

The credential goes in a header:

Authorization: Bearer <api_token>

Query strings (?token=...) are not supported. This is a server-to-server integration so nothing forces a query parameter, and ?token= would put a long-lived credential into every access log between you and us.

Once connected, send a subscribe message:

→ {"action": "subscribe", "v": 1}
← {"action": "subscribed", "v": 1, "call_uuid": null}

Pass a call_uuid to follow one call; omit it to follow every call on the account:

→ {"action": "subscribe", "v": 1, "call_uuid": "b6e2-..."}
← {"action": "subscribed", "v": 1, "call_uuid": "b6e2-..."}

One connection, one subscription. A second subscribe returns an already_subscribed error. To follow two specific calls, open two connections, or subscribe to all of them and filter on your side.

You can keep the connection alive at the application level:

→ {"action": "ping"}
← {"action": "pong"}

The WebSocket protocol's own ping/pong works too; a 20-second ping interval in your client is enough. A call can be entirely silent for minutes, so keep an idle connection alive yourself.

Protocol

Client to server

Message Fields
{"action": "subscribe"} v (integer, optional; the newest version the service speaks if omitted), call_uuid (optional)
{"action": "ping"} —

Server to client

Message Meaning
{"action": "subscribed", "v": 1, "call_uuid": null} The subscription is live
{"action": "pong"} Ping reply
{"action": "error", "code": "...", "message": "..."} An error (table below)
envelope A transcript event; it carries type rather than action

How to tell them apart: action means a control message, type means a transcript event.

Envelope

Every transcript event, whatever its type, arrives in this shell:

{
"v": 1,
"type": "transcript.segment",
"seq": 42,
"call_uuid": "b6e2-...",
"account_id": 1234,
"ts": "2026-09-19T10:31:04.812Z",
"data": { }
}
Field Description
v Version of this wire shape. Currently 1.
type Event type (below).
seq Per-call counter starting at 1. It counts events that were not sent as well; see Loss and duplicates.
call_uuid The call's id. Several calls can stream down one connection; key your state on this.
account_id The account's id.
ts UTC timestamp of the event (ISO 8601).
data Type-specific body.

You state your v when subscribing. The service downgrades newer events to the version you understand, or refuses the subscription if it cannot. You never silently receive a shape you were not written for.

Event types

transcript.started

The first event for a call, always. If you see a transcript.segment first, you joined the call late.

{
"language": "tr",
"direction": "inbound",
"from": "+90...",
"to": "+90..."
}

direction is one of inbound, outbound, internal.

transcript.segment

A piece of text, as it is spoken.

{
"segment_id": 7,
"speaker": "1",
"start_ms": 1500,
"end_ms": 3120,
"text": "Saturday. August 8",
"is_final": false
}
Field Description
segment_id Increasing piece number within the call. Use it as the merge key.
speaker A label such as "1" or "2" when the provider diarized, otherwise null. It is not an identity and is only meaningful within one call, so do not carry it across calls.
start_ms / end_ms Milliseconds from the start of the call.
text The piece's text as it currently stands.
is_final true means this piece is frozen and will not change again.

transcript.failed

{"reason": "provider_error"}

Terminal: no further text arrives for this call. Everything received before it is still valid, and transcript.completed still follows.

reason is a closed set of ours. The speech recognition provider's own error code is not passed through. It is vendor-assigned and its meaning changes with the vendor.

transcript.completed

One per call, sent when the call record (CDR) is written. Authoritative: it supersedes every transcript.segment sent for that call.

{
"cdr_uuid": "...",
"segments": [
{"segment_id": 1, "speaker": "1", "start_ms": 0, "end_ms": 3120,
"text": "...", "is_final": true}
]
}

cdr_uuid is the join back to your own call record.

If you do not need the live preview, listen only to this event and ignore everything else. It is the simplest integration you can build.

Reassembling the transcript

The whole rule:

Upsert by segment_id. is_final: true means frozen. The text is the segments concatenated in segment_id order.

segments = {} # segment_id -> segment
def apply(segment):
segments[segment["segment_id"]] = segment
def text():
return "".join(s["text"] for _, s in sorted(segments.items()))

It is this simple because the raw interim/final semantics of the speech recognition engine are resolved on our side. In the raw stream, interims are cumulative rewrites of the unstable tail, finals carry only the fragment just frozen, and that fragment then disappears from the interim stream. Appending gives you "SSaturSaturdSaturday.", replacing throws away everything already committed. None of that reaches you.

Two behaviours that look like bugs and are not:

  • A segment's text can shrink. If the interim tail turns out to belong to the next speaker, the turn freezes with only its committed text and the tail reappears under the next segment_id. Upserting handles this without you having to notice.
  • speaker can be empty. Diarization is not always possible.

Loss and duplicates: seq

seq counts every event the call generated, including ones that were not sent. When you see a gap, the server shed a live preview to keep up.

  • The only thing dropped is live (not yet frozen) segment updates. A preview that arrives late is worse than one that never arrives.
  • transcript.started, frozen (is_final: true) segments, transcript.failed and transcript.completed are never dropped. Under load the stream degrades to delivering each turn once it closes rather than character by character, and no spoken word is lost.
  • If seq goes backwards, you have received the same event twice. The transport can redeliver. It arrived out of turn rather than out of order: ignore it, do not re-sort.

Ordering is per call and guaranteed only by seq. Do not rely on arrival order.

def check_gap(call_uuid, seq):
previous = last_seq.get(call_uuid)
last_seq[call_uuid] = seq
if previous is None:
return
if seq <= previous:
pass # redelivery — ignore
elif seq > previous + 1:
pass # a preview was shed — the text still arrives

One more trap: a redelivered event can land after the call has finished. Keep a bounded set of completed call ids and ignore late events for them, otherwise a closed call is resurrected with a one-line transcript that looks live.

Example code

Python — the minimal version

pip install websockets # 10.x and 14+ both work
export HIPCALL_TOKEN=<api token>
import asyncio, json, os, ssl, websockets
URL = "wss://stream.hipcall.com.tr/v1/stream"
async def main():
headers = {"Authorization": f"Bearer {os.environ['HIPCALL_TOKEN']}"}
async with websockets.connect(
URL,
additional_headers=headers, # websockets < 14: extra_headers
ssl=ssl.create_default_context(),
ping_interval=20,
ping_timeout=20,
) as socket:
await socket.send(json.dumps({"action": "subscribe", "v": 1}))
calls = {} # call_uuid -> {segment_id: segment}
async for frame in socket:
message = json.loads(frame)
if message.get("action"): # control message
print(message)
continue
call_uuid, data = message["call_uuid"], message["data"]
if message["type"] == "transcript.segment":
segments = calls.setdefault(call_uuid, {})
segments[data["segment_id"]] = data
print(call_uuid[:8],
"".join(s["text"] for _, s in sorted(segments.items())))
elif message["type"] == "transcript.completed":
text = "".join(s["text"] for s in data["segments"])
print(f"DONE {call_uuid} (cdr {data['cdr_uuid']}): {text}")
calls.pop(call_uuid, None)
asyncio.run(main())

This is enough to see the stream, but not enough for production: it does not come back when the connection drops, does not notice what it missed, and processes a redelivered event twice. The full client below covers all three.

Python — the full client

Copy it as it is, save it as hipcall_client.py, and run it:

pip install websockets # 10.x and 14+ both work
export HIPCALL_TOKEN=<api token>
# every call on the account
python3 hipcall_client.py wss://stream.hipcall.com.tr/v1/stream
# one call only
python3 hipcall_client.py wss://stream.hipcall.com.tr/v1/stream <call_uuid>

Three parts are worth copying into your own client: Transcript.apply, the seq gap check, and the reconnect loop. The rest is printing.

#!/usr/bin/env python3
"""
A worked subscriber for the Hipcall live transcript stream.
Run it against a live account and it prints the transcript of every call as it
is spoken.
pip install websockets
export HIPCALL_TOKEN=<your api token>
python3 hipcall_client.py wss://stream.hipcall.com.tr/v1/stream [call_uuid]
"""
import asyncio
import json
import os
import ssl
import sys
from collections import OrderedDict
from typing import Optional
import websockets
# 10.x lazy-loads its submodules, so this import is not redundant.
import websockets.exceptions
# The envelope version this client understands. The service downgrades newer
# events to it, or refuses the subscription if it cannot - either way you never
# silently receive a shape you were not written for.
VERSION = 1
# websockets renamed the header argument in 14.0 and reshaped its exceptions at
# the same time. Distributions still ship 10.x, so this sample works on both
# rather than making you upgrade before you can see anything.
_WS_MAJOR = int(websockets.__version__.split(".")[0])
HEADER_KWARG = "additional_headers" if _WS_MAJOR >= 14 else "extra_headers"
# 14+ raises InvalidStatus, earlier versions InvalidStatusCode. Neither is worth
# retrying: both mean the upgrade was refused with a 401 or 403. Chosen by
# version rather than by hasattr, because 14+ still exposes the old name and
# merely touching it emits a DeprecationWarning.
REFUSED = (
getattr(
websockets.exceptions,
"InvalidStatus" if _WS_MAJOR >= 14 else "InvalidStatusCode",
),
)
def refusal_detail(error) -> str:
"""
The body the server sent with its 401 or 403.
The server answers with the reason - invalid_token, revoked, expired,
forbidden - and without it a refusal is just "HTTP 401", which is the
difference between a five-second fix and an afternoon. Only websockets 14+
exposes the body; on older versions use curl to see it.
"""
response = getattr(error, "response", None)
body = getattr(response, "body", None) if response is not None else None
if body:
return f" - {body.decode('utf-8', 'replace').strip()}"
return " (upgrade to websockets>=14, or use curl, to see the reason)"
def close_code(error) -> tuple[Optional[int], str]:
"""The close frame the server sent, across both exception shapes."""
received = getattr(error, "rcvd", None)
if received is not None:
return received.code, received.reason
return getattr(error, "code", None), getattr(error, "reason", "")
class Transcript:
"""
One call's transcript, rebuilt from the events.
The entire rule is: **upsert by `segment_id`, and `is_final` means frozen.**
Two behaviours that look like bugs and are not:
* A segment's text can **shrink**. If the interim tail turns out to belong
to the next speaker, the turn freezes with only its committed text and
the tail reappears under the next `segment_id`. Upserting handles this
without you having to notice.
* `speaker` is `None` unless the provider diarized, and when present it is
a per-call label ("1", "2"), not an identity. Do not carry it across
calls.
"""
def __init__(self) -> None:
self.segments: dict[int, dict] = {}
def apply(self, segment: dict) -> None:
self.segments[segment["segment_id"]] = segment
def replace_all(self, segments: list[dict]) -> None:
"""`transcript.completed` is authoritative - it supersedes everything."""
self.segments = {segment["segment_id"]: segment for segment in segments}
def text(self) -> str:
return "".join(
segment["text"] for _id, segment in sorted(self.segments.items())
)
def settled(self) -> str:
"""Only the frozen part - what you would send to an LLM mid-call."""
return "".join(
segment["text"]
for _id, segment in sorted(self.segments.items())
if segment["is_final"]
)
class Session:
"""Tracks the calls on one connection, and whether we missed anything."""
# The transport can redeliver, and a redelivery can land *after* the call
# finished. Without this, a stale segment would resurrect a completed call
# and leave you holding a one-line transcript that looks live. Bounded so a
# long-running client does not grow it forever.
FINISHED_MEMORY = 1_000
def __init__(self) -> None:
self.calls: dict[str, Transcript] = {}
self.last_seq: dict[str, int] = {}
self.finished: OrderedDict[str, None] = OrderedDict()
def check_gap(self, call_uuid: str, seq: int) -> None:
"""
`seq` counts every event the call generated, including ones the server
chose to drop under backpressure. A gap is therefore not an error - it
is the server telling you it shed a live preview to keep up. Frozen
segments and `transcript.completed` are never dropped, so the text
still arrives; only the character-by-character preview does not.
Duplicates are possible too, so `seq` going backwards means "seen this
already", not "reordered".
"""
previous = self.last_seq.get(call_uuid)
self.last_seq[call_uuid] = seq
if previous is None:
return
if seq <= previous:
print(f" [duplicate or replay: seq {seq} after {previous}]")
elif seq > previous + 1:
print(f" [gap: missed {seq - previous - 1} event(s) before seq {seq}]")
def handle(self, event: dict) -> None:
kind = event["type"]
call_uuid = event["call_uuid"]
data = event["data"]
if call_uuid in self.finished:
print(f" [{call_uuid[:8]} late {kind} after completion, ignored]")
return
self.check_gap(call_uuid, event["seq"])
if kind == "transcript.started":
self.calls[call_uuid] = Transcript()
print(
f"\n== {call_uuid} started "
f"({data['direction']}, {data['language']}, "
f"{data['from']} -> {data['to']})"
)
elif kind == "transcript.segment":
# A subscriber that joined mid-call never saw `transcript.started`,
# so do not assume the call is known.
transcript = self.calls.setdefault(call_uuid, Transcript())
transcript.apply(data)
marker = "*" if data["is_final"] else " "
print(f"{marker} {call_uuid[:8]} {transcript.text()}")
elif kind == "transcript.failed":
# Terminal: no more text on this call. Whatever arrived before it
# is still good, and `transcript.completed` still follows.
print(f"!! {call_uuid[:8]} transcription failed: {data['reason']}")
elif kind == "transcript.completed":
transcript = self.calls.setdefault(call_uuid, Transcript())
transcript.replace_all(data["segments"])
print(f"\n== {call_uuid} complete (cdr {data['cdr_uuid']})")
print(f" {transcript.text()}\n")
self.calls.pop(call_uuid, None)
self.last_seq.pop(call_uuid, None)
self.finished[call_uuid] = None
while len(self.finished) > self.FINISHED_MEMORY:
self.finished.popitem(last=False)
else:
# Additive event types are not a breaking change. Ignore what you
# do not recognise rather than failing on it.
print(f"?? {call_uuid[:8]} unhandled event {kind}")
async def stream(url: str, token: str, call_uuid: Optional[str]) -> None:
session = Session()
# The credential goes in a header, never the query string: this is a
# server-to-server integration, and `?token=` would put a long-lived
# credential into every access log between you and us.
headers = {"Authorization": f"Bearer {token}"}
options = {
HEADER_KWARG: headers,
# Keep the connection alive through idle periods - a call can be
# entirely silent for minutes.
"ping_interval": 20,
"ping_timeout": 20,
}
if url.startswith("wss://"):
options["ssl"] = ssl.create_default_context()
async with websockets.connect(url, **options) as socket:
subscribe = {"action": "subscribe", "v": VERSION}
if call_uuid:
subscribe["call_uuid"] = call_uuid
await socket.send(json.dumps(subscribe))
async for frame in socket:
message = json.loads(frame)
# Control frames carry "action"; transcript events carry "type".
action = message.get("action")
if action == "subscribed":
scope = message["call_uuid"] or "all calls"
print(f"subscribed to {scope} (v{message['v']})")
elif action == "error":
print(f"error [{message['code']}]: {message['message']}")
elif action == "pong":
pass
else:
session.handle(message)
async def main() -> None:
if len(sys.argv) < 2:
print(__doc__)
sys.exit(1)
url = sys.argv[1]
call_uuid = sys.argv[2] if len(sys.argv) > 2 else None
token = os.environ.get("HIPCALL_TOKEN")
if not token:
print("HIPCALL_TOKEN is not set.")
sys.exit(1)
# Reconnect with backoff. The server closes a connection when the
# credential is revoked or expires, when the account loses the
# entitlement, or when this client falls too far behind to keep up - and
# only the last of those is worth retrying, so the close code is checked.
backoff = 1.0
while True:
try:
await stream(url, token, call_uuid)
print("connection closed by server")
except REFUSED as error:
# 401 or 403 at the upgrade: nothing about retrying will help.
print(f"refused: {error}{refusal_detail(error)}")
return
except websockets.exceptions.ConnectionClosed as error:
code, reason = close_code(error)
if code == 1008:
# Policy violation - revoked, expired, or no longer entitled.
print(f"closed by policy: {reason}")
return
print(f"connection lost: {error}")
except OSError as error:
print(f"connection failed: {error}")
# After a reconnect you have missed whatever was said while away.
# Nothing is replayed on the socket; if `transcript.completed` is still
# to come for that call, the full text arrives there.
print(f"reconnecting in {backoff:.0f}s")
await asyncio.sleep(backoff)
backoff = min(backoff * 2, 30.0)
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass

Node.js

import WebSocket from "ws"; // npm install ws
const socket = new WebSocket("wss://stream.hipcall.com.tr/v1/stream", {
headers: { Authorization: `Bearer ${process.env.HIPCALL_TOKEN}` },
});
const calls = new Map(); // call_uuid -> Map(segment_id -> segment)
socket.on("open", () => socket.send(JSON.stringify({ action: "subscribe", v: 1 })));
socket.on("message", (frame) => {
const message = JSON.parse(frame);
if (message.action) { // subscribed / pong / error
console.log(message);
return;
}
const { type, call_uuid, data } = message;
if (type === "transcript.segment") {
if (!calls.has(call_uuid)) calls.set(call_uuid, new Map());
calls.get(call_uuid).set(data.segment_id, data);
const text = [...calls.get(call_uuid).entries()]
.sort(([a], [b]) => a - b)
.map(([, segment]) => segment.text)
.join("");
console.log(call_uuid.slice(0, 8), text);
}
if (type === "transcript.completed") {
console.log("DONE", call_uuid, data.segments.map((s) => s.text).join(""));
calls.delete(call_uuid);
}
});
socket.on("close", (code, reason) => {
console.log("closed", code, reason.toString());
// anything but 1008: reconnect with backoff
});

Managing the connection

The one piece missing from a minimal client is the reconnect loop. The rules:

Situation What to do
401 on the upgrade request Do not retry. The token is wrong, deleted or expired. The reason is in the response body.
403 on the upgrade request Do not retry. The account is not entitled to the live stream; check the settings in Step 1.
Close code 1008 Do not retry. The token was revoked, expired, or the entitlement was removed.
Close code 1011 with too_slow Reconnect. You are not reading messages fast enough.
Any other drop (network, deploy, timeout) Reconnect with backoff (1s → 2s → 4s … capped at 30s).

Also:

  • A connection is dropped after 12 hours with no data received. Regular pings already prevent this, but a long-lived client should still have a reconnect loop.
  • Nothing said while you were away is replayed. The stream is a live broadcast, not a queue. If transcript.completed is still to come for a call, you get the full text there when you reconnect; otherwise the transcript remains next to the call recording in the Hipcall panel.
  • If you stop reading, the server closes the connection rather than growing a buffer. That way a slow consumer cannot quietly accumulate a corrupted transcript.

Error codes

Delivered as {"action": "error", "code": "...", "message": "..."}.

code Meaning
malformed_json The frame you sent was not valid JSON.
missing_action The frame had no action.
unknown_action The action was not recognised.
invalid_version v must be an integer.
unsupported_version The requested v is higher than this service speaks (or lower than 1).
already_subscribed This connection is already subscribed. One connection, one subscription.
unsupported_frame A non-text (binary) frame was sent.
feed_unavailable The subscription could not be set up right now; retry with backoff.
too_slow You fell too far behind and the connection was closed.
forbidden The account is not entitled to the live stream.
revoked The credential is no longer valid.
expired The credential has expired.

If you are refused at the HTTP upgrade step, the reason comes back as plain text in the response body. You can see it with curl:

curl -i -H "Authorization: Bearer $HIPCALL_TOKEN" \
-H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: $(openssl rand -base64 16)" \
https://stream.hipcall.com.tr/v1/stream

What is deliberately absent from the payload

Not sent Why
The speech recognition provider's name The provider can change. An integration that branches on it would make swapping providers a breaking change.
The raw provider response It is the bulk of the message and it is nobody's API.
confidence Calibrated per provider and not comparable across them. It would mislead more than it informs.
Channel / leg identifiers They match the call id only by coincidence in today's setup; an integration built on that breaks silently.

Adding a field is not a breaking change; removing one is. So write your client to ignore type values and fields it does not recognise, rather than failing on them.

Troubleshooting

I connect but no events arrive

Check, in order:

  1. Did you receive the subscribed reply? If not, your subscribe message never went out.
  2. Is While the call is happening selected in the settings? With After the call ends there is no stream.
  3. Under Which calls, is the direction you are testing selected?
  4. Do you have a jeton balance? No new transcription is produced once it runs out.
  5. Did the call actually connect? The transcription session starts when the parties are bridged, not when the call is answered. The greeting, the IVR and hold music are not written out.

I get a 403

The account is not entitled to the live stream. All three are required: the main transcription toggle on, the mode set to While the call is happening, and Live transcript stream on. If the toggle shows a padlock, your plan does not include API access.

I get a 401 and I am sure the token is right

Read the reason in the response body. The token may have been deleted, expired, or belong to a different account. Create a new token in the panel and try again.

The text accumulates as "SSaturSaturday"

You are appending segments. Overwrite by segment_id; see Reassembling the transcript.

I process the same sentence twice

Track seq, ignore events where it goes backwards, and filter out late events for calls you have already completed.

A turn's text got shorter

Expected behaviour. The tail belonged to the next speaker and will arrive under the next segment_id.

FAQ

Does turning the stream on change anything in the Hipcall screens?

No. The transcript already appears next to the call recording. This toggle only exists to carry the text into your own system.

Do I need to buy a separate package?

No. Transcription is charged per minute in jetons. There is no extra charge for the stream itself.

Can I connect from a browser?

No. The credential travels in an Authorization header and the browser WebSocket API cannot send custom headers. You also would not want an API token in a browser. Connect from your server and distribute from there if you need to.

How many connections can I open at once?

You can open several connections for the same account; they all receive the same calls. But each connection holds a single subscription.

Can I feed the text to an AI model during the call?

Yes, but only use segments with is_final: true. Unfrozen segments can still change.

Can I fetch a past call's transcript from this stream?

No. The stream is live only; it does not replay history. Past transcripts sit next to the call recording in the Hipcall panel.

Can I tell the speakers apart?

If speaker is populated, yes, but it is a per-call label, not an identity. "1" may be the customer on one call and the agent on another.

Next steps

Was this article helpful?

Still need help?

Can't find what you're looking for? Reach out and we'll help.