<div align="center">

# @tiktool/live

### Connect to any TikTok LIVE stream in 4 lines of code.

[![npm version](https://img.shields.io/npm/v/@tiktool/live?color=%23ff0050&label=npm&logo=npm)](https://www.npmjs.com/package/@tiktool/live)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Node.js](https://img.shields.io/badge/Node.js-%3E%3D18-green?logo=node.js)](https://nodejs.org)
[![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue?logo=typescript)](https://www.typescriptlang.org)

Real-time chat, gifts, viewers, **PK battles with MVP breakdown**, **x2/x3 boosters**, gloves, mist, match-guide & 20+ event types from any TikTok livestream.

[Quick Start](#-quick-start) · [Events](#-events) · [PK Battles](#-pk-battles) · [API](#-api-reference) · [Rate Limits](#-rate-limits) · [Get API Key](https://tik.tools)

</div>

---

## ⚡ Quick Start

```bash
npm install @tiktool/live
```

Get your free API key at [tik.tools](https://tik.tools)

```typescript
import { TikTokLive } from '@tiktool/live';

const live = new TikTokLive({
    uniqueId: 'tv_asahi_news',
    apiKey: 'YOUR_API_KEY',
});

live.on('chat', e => console.log(`${e.user.uniqueId}: ${e.comment}`));
live.on('gift', e => console.log(`${e.user.uniqueId} sent ${e.giftName} (${e.diamondCount} diamonds)`));
live.on('member', e => console.log(`${e.user.uniqueId} joined`));
live.on('roomUserSeq', e => console.log(`Viewers: ${e.viewerCount}`));

await live.connect();
```

---

## How It Works

```
    Your App                    tik.tools                    TikTok
  +-----------+              +--------------+           +--------------+
  |          -+-- sign_url -->  Signs URL   |           |              |
  |  Your   <-+-- X-Bogus --|  with params |           |   TikTok     |
  |  Code    |              |              |           |   WebSocket  |
  |          -+------------ Connect directly ---------->|   Server     |
  |          <-+------------ Live events (protobuf) <---|              |
  +-----------+              +--------------+           +--------------+
                            ^ Only interaction             ^ Direct from
                              with our server                YOUR IP
```

- Your app connects directly to TikTok from your IP address
- The sign server only generates cryptographic signatures (requires API key)
- TikTok never sees the sign server
- Built-in protobuf parser, **zero external dependencies** (only `ws` for WebSocket)

---

## Connection Modes

There are **two ways** to receive TikTok LIVE events from `tik.tools`. Pick based on scale and IP-exposure tolerance.

### Mode 1 — Direct (default, this SDK)

```
  Your App  ──signs URL via──▶  tik.tools  ──returns signed URL──▶  Your App
  Your App  ───────────────── WS direct to TikTok ───────────────▶  TikTok
  Your App  ◀────────────────── live events ──────────────────────  TikTok
```

- The signed WebSocket is opened from your runtime. TikTok sees the network identity of the host that runs the SDK.
- Lowest per-stream cost — only the initial `ws_credentials` call is billed against your API key.
- Suited to **low- to mid-volume** workloads from a single host.
- High-volume setups should prefer Mode 2 for predictable, centrally-managed egress.

```typescript
const live = new TikTokLive({
    uniqueId: 'creator_username',
    apiKey: 'YOUR_TIKTOOLS_KEY',
    // Optional: route outbound WS + HTTP through a corporate / egress gateway
    // proxy: 'http://USER:PASS@gateway.your-company.com:port',
});
```

If you set the optional `proxy` field, install the standard agent:

```bash
npm install https-proxy-agent
```

### Mode 2 — Relayed (via TikTools managed edge)

```
  Your App  ◀──── wss://api.tik.tools/?... ────  tik.tools
                                                      │
                                                      │ managed edge
                                                      ▼
                                                   TikTok
```

- The TikTools service handles the upstream TikTok session and forwards decoded events over a single WebSocket to your client.
- Single, stable egress — your application connects only to `api.tik.tools`.
- Recommended for **production scale**, multi-tenant deployments, or any environment that already centralizes outbound traffic.

**Easiest — same SDK, one option:**

```typescript
import { TikTokLive } from '@tiktool/live';

const live = new TikTokLive({
    uniqueId: 'creator_username',
    apiKey: 'YOUR_KEY',
    mode: 'relayed',  // ← that's it
});

live.on('chat', e => console.log(`${e.user.uniqueId}: ${e.comment}`));
live.on('gift', e => console.log(`${e.user.uniqueId} sent ${e.giftName}`));
live.on('battleArmies', e => console.log(e));

await live.connect();
```

Event names + payload shapes are **identical to Direct mode**. Switch back and forth by toggling `mode`.

**Advanced — raw WebSocket (no SDK, your own client):**

```typescript
import WebSocket from 'ws';

const ws = new WebSocket(
    `wss://api.tik.tools/?uniqueId=creator_username&apiKey=YOUR_KEY`
);

ws.on('message', raw => {
    const msg = JSON.parse(raw.toString());
    if (msg.event === 'chat')         console.log(msg.data.user.uniqueId, msg.data.comment);
    if (msg.event === 'gift')         console.log(msg.data.user.uniqueId, msg.data.giftName);
    if (msg.event === 'battleArmies') console.log(msg.data);
});
```

Use this when integrating from a non-Node runtime (Python, Go, Bun, browser) or when you want absolute control over the connection.

### Side-by-side

| Aspect | Direct (Mode 1) | Relayed (Mode 2) |
|---|---|---|
| WebSocket opens from | Your runtime | TikTools managed edge |
| Setup | `new TikTokLive(...)` | `new TikTokLive({ ..., mode: 'relayed' })` |
| Egress | Your host's network | Single endpoint: `api.tik.tools` |
| Best for | Low- to mid-volume | Production scale, multi-tenant |

---

## Events

### Listening

```typescript
live.on('chat', (event) => {
    event.user.uniqueId  // string
    event.comment        // string
});

live.on('event', (event) => {
    console.log(event.type, event);
});
```

### Reference

| Event | Type | Description | Fields |
|-------|------|-------------|--------|
| `chat` | `ChatEvent` | Chat message (with inline emotes) | `user`, `comment`, `emotes` |
| `member` | `MemberEvent` | User joined | `user`, `action` |
| `like` | `LikeEvent` | User liked | `user`, `likeCount`, `totalLikes` |
| `gift` | `GiftEvent` | Gift sent | `user`, `giftId`, `giftName`, `diamondCount`, `repeatCount`, `combo`, `groupId` |
| `social` | `SocialEvent` | Follow / Share | `user`, `action` |
| `roomUserSeq` | `RoomUserSeqEvent` | Viewer count | `viewerCount`, `totalViewers` |
| `battle` | `BattleEvent` | PK Battle start / status | `battleId`, `status`, `battleDuration`, `teams` |
| `battleArmies` | `BattleArmiesEvent` | **PK score update + per-host MVP breakdown** | `battleId`, `teams`, `hosts[]`, `secsRemaining`, `serverTsMs`, `matchId`, `sessionId` |
| `battleItemCard` | `BattleItemCardEvent` | **x2/x3 boosters, gloves, mist, thunder, extra-time, match-guide** | `effect`, `multiplier`, `senderUserId`, `iconUrl`, `iconKey`, `accentColor`, … |
| `subscribe` | `SubscribeEvent` | New subscriber | `user`, `subMonth` |
| `emoteChat` | `EmoteChatEvent` | Emote in chat | `user`, `emoteId`, `emoteUrl` |
| `envelope` | `EnvelopeEvent` | Treasure chest | `diamondCount` |
| `question` | `QuestionEvent` | Q&A question | `user`, `questionText` |
| `control` | `ControlEvent` | Stream control | `action` (3 = ended) |
| `room` | `RoomEvent` | Room status | `status` |
| `liveIntro` | `LiveIntroEvent` | Stream intro | `roomId`, `title` |
| `rankUpdate` | `RankUpdateEvent` | Hourly / weekly rank | `rankType`, `rankList` |
| `linkMic` | `LinkMicEvent` | Link Mic guest action | `action`, `users` |
| `unknown` | `UnknownEvent` | Unrecognized | `method` |

### Connection Events

| Event | Callback | Description |
|-------|----------|-------------|
| `connected` | `() => void` | Connected to stream |
| `disconnected` | `(code, reason) => void` | Disconnected |
| `roomInfo` | `(info: RoomInfo) => void` | Room info |
| `error` | `(error: Error) => void` | Error |

---

## 🥊 PK Battles

This SDK fully parses TikTok PK (Player-vs-Killer) battle protobufs, including
**multi-guest** matches with up to 4 hosts per side and per-gifter MVP scores.

### `battleArmies` — score updates during a PK

Emitted every few seconds while a PK is active. The new `hosts[]` array gives
you the **per-host breakdown** with each gifter's contribution sorted **MVP
first** (highest score → lowest):

```typescript
live.on('battleArmies', (e) => {
    console.log(`PK ${e.battleId} — ${e.secsRemaining}s remaining`);

    for (const host of e.hosts ?? []) {
        console.log(`  Host ${host.hostUserId} — total ${host.teamTotalScore}`);

        // contributors[0] = MVP (highest gifter)
        for (const [i, c] of host.contributors.slice(0, 3).entries()) {
            console.log(`    #${i + 1} ${c.nickname || c.userId}: ${c.score}`);
        }
    }
});
```

**Per-host fields:**

| Field | Type | Description |
|-------|------|-------------|
| `hostUserId` | `string` | TikTok userId of this side's host |
| `teamTotalScore` | `number` | Total diamonds for this side |
| `teamIdx` | `number` | Side index (0 = left, 1 = right, …) |
| `contributors[]` | `BattleContributor[]` | Per-gifter breakdown, **sorted MVP first** |

**Per-contributor fields:**

| Field | Type | Description |
|-------|------|-------------|
| `userId` | `string` | Gifter's TikTok userId |
| `score` | `number` | Diamond score contributed |
| `nickname` | `string` | Display nickname (may be empty) |

**Timer & match identity:**

| Field | Type | Description |
|-------|------|-------------|
| `battleId` | `string` | Battle session tag |
| `matchId` | `string` | Stable match ID across multi-round PK |
| `sessionId` | `string` | Per-round session ID |
| `secsRemaining` | `number` | Seconds left, computed from **TikTok server clock** (no VPS drift) |
| `serverTsMs` | `number` | TikTok server-side ms epoch at frame emit |
| `durationSec` | `number` | Total battle duration |
| `endTimeMs` | `number` | Battle end ms epoch (alias for `battleStartMs + duration*1000`) |

### `battleItemCard` — boosters / power-ups during a PK

Fired when a gifter activates a special card: x2/x3 multipliers, gloves
(crit), mist, thunder, extra-time, or match-guide. Includes the **card art**
URL straight from TikTok's CDN — drop it into an OBS overlay as-is.

```typescript
live.on('battleItemCard', (e) => {
    console.log(`${e.senderNickname} activated ${e.effect}`);

    if (e.multiplier > 0) {
        console.log(`  x${e.multiplier} multiplier for ${e.durationSec}s`);
    }

    // Use straight in an <img> tag — TikTok CDN
    console.log(`  icon: ${e.iconUrl}`);
    console.log(`  accent: ${e.accentColor}`);
});
```

**Fields:**

| Field | Type | Description |
|-------|------|-------------|
| `battleId` | `string` | The PK this card belongs to |
| `cardType` | `number` | 2=gloves, 3=mist, 4=match_guide, 11=x3, … |
| `effect` | `string` | `'gloves'` \| `'mist'` \| `'booster_x2'` \| `'booster_x3'` \| `'match_guide'` \| `'thunder'` \| `'extra_time'` \| raw key |
| `effectKey` | `string` | Raw TikTok resource key |
| `multiplier` | `number` | 2 or 3 for boosters, else 0 |
| `senderUserId` | `string` | Gifter who activated it |
| `senderNickname` | `string` | Display name of the sender |
| `senderUniqueId` | `string` | Username (`@handle`) of the sender |
| `senderAvatarUrl` | `string` | Sender's avatar (CDN URL) |
| `activatedAtSec` | `number` | Unix seconds when activated |
| `durationSec` | `number` | Active duration |
| `endsAtSec` | `number` | Unix seconds when it ends |
| `commentTemplate` | `string` | e.g. `"{0:user} sent 1 magic mist"` |
| `iconUrl` | `string` | Full TikTok CDN URL for the card art (webp/jpeg) |
| `iconKey` | `string` | Short id, e.g. `card_mist_v3`, `card_crit_v3`, `top3_buffer` |
| `accentColor` | `string` | Hex color, e.g. `#BCD9E0` (mist blue), `#E0D4BC` (gloves tan) |

### `battle` — PK lifecycle events

Fired on start / status change / end. Use for PK ON/OFF banners.

```typescript
live.on('battle', (e) => {
    // status: 1=ACTIVE, 2=STARTING, 3=ENDED, 4=PREPARING
    if (e.status === 1) console.log('PK started');
    if (e.status === 3) console.log('PK ended');
});
```

---

## API Reference

### `new TikTokLive(options)`

| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `uniqueId` | `string` | — | TikTok username (without @) |
| `apiKey` | `string` | — | **Required.** API key from [tik.tools](https://tik.tools) |
| `signServerUrl` | `string` | `https://api.tik.tools` | Sign server URL |
| `autoReconnect` | `boolean` | `true` | Auto-reconnect on disconnect |
| `maxReconnectAttempts` | `number` | `5` | Max reconnect attempts |
| `heartbeatInterval` | `number` | `10000` | Heartbeat interval (ms) |
| `roomId` | `string` | — | Pre-resolved room ID (skips page fetch when paired with `sessionId`) |
| `sessionId` | `string` | — | Pre-resolved `ttwid` cookie (skips page fetch when paired with `roomId`) |
| `proxy` | `string` | — | HTTP(S) proxy URL for Direct mode (e.g. `http://USER:PASS@host:port`). Requires `https-proxy-agent`. |
| `mode` | `'direct' \| 'relayed'` | `'direct'` | Connection mode. See [Connection Modes](#connection-modes). |
| `debug` | `boolean` | `false` | Debug logging |

### Methods

| Method | Returns | Description |
|--------|---------|-------------|
| `connect()` | `Promise<void>` | Connect to livestream |
| `disconnect()` | `void` | Disconnect |
| `getStreamUrl()` | `Promise<StreamInfo>` | Get FLV/HLS playback URLs for the live video |
| `getUserProfile(uniqueId?)` | `Promise<UserProfile>` | Resolve any TikTok username → numeric `id`, `secUid`, nickname, bio, avatars, follower stats |
| `connected` | `boolean` | Connection status |
| `eventCount` | `number` | Total events received |
| `roomId` | `string` | Current room ID |

### Static methods (no connection required)

```typescript
// Get numeric TikTok user ID + full profile for any @username (Pro tier+)
const profile = await TikTokLive.getUserProfile({
    uniqueId: 'dalga.ahmedov',
    apiKey: 'YOUR_KEY',
});
profile.id;                  // "7355610677036581896"  ← numeric streamer ID
profile.nickname;            // "🤍DENIZ🖤🌊"
profile.secUid;              // signed TikTok user identifier
profile.stats.followerCount; // 7945
profile.avatarLarger;        // CDN URL

// Get FLV/HLS playback URLs for any creator
const stream = await TikTokLive.getStreamUrl({ uniqueId, apiKey });
```

### REST endpoints (companion to the WS SDK)

For lookups outside the live event stream, hit the sign server directly with your API key:

| Endpoint | Method | Tier | Use case |
|----------|--------|------|----------|
| `/webcast/room_id` | POST | sandbox+ | `unique_id` → `room_id` |
| `/webcast/room_info` | POST | sandbox+ | `unique_id` → `room_id` + `alive` + `title` |
| `/webcast/check_alive` | GET/POST | sandbox+ | Is `room_id` currently live? |
| `/webcast/bulk_live_check` | POST | basic+ | Batch check up to 500 users in one call |
| `/webcast/live_status` | GET | sandbox+ | `unique_id` → live snapshot incl. viewer count |
| `/webcast/user_profile` | GET | **pro+** | **`unique_id` → numeric `id`, `secUid`, nickname, bio, avatars, follower stats** |
| `/webcast/resolve_user_ids` | POST | sandbox+ | Batch numeric `userId` → `unique_id` (reverse of `user_profile`) |
| `/webcast/rankings` | GET | sandbox+ | Top gifters / hourly rank for a room |
| `/webcast/room_video` | POST | basic+ | Get HLS / FLV stream URLs |
| `/webcast/ws_credentials` | POST | sandbox+ | Get signed WS URL + ttwid (used by Direct mode under the hood) |

**Need the streamer's numeric TikTok ID for a username?**

```bash
curl -H "X-Api-Key: YOUR_KEY" \
  "https://api.tik.tools/webcast/user_profile?unique_id=anyuser"
```

Returns full profile JSON. Pro tier and above. Cached server-side for 24h — repeated lookups are free and instant.

---

## Rate Limits

All API requests require an API key. Get yours at [tik.tools](https://tik.tools).

| Tier | Price | Requests/day | WS Connections | Notes |
|------|-------|--------------|----------------|-------|
| **Community** | Free forever | 2,500 | 15 (2h per WS) | Masked leaderboards. No datacenter proxies — calls run from your own IP. |
| **Pro** | from $59/mo | 75,000 | 50 (8h) | Unmasked leaderboards · CAPTCHA Solver · Feed Discovery · 5 AI caption streams · priority routing |
| **Ultra** | from $219/mo | 300,000 | 250 (8h) | Unmasked leagues · 20 AI caption streams · 99.5% uptime SLA · priority chat support |
| **Global Agency** | $549/mo | 300,000 | 500 + Firehose | Everything in Ultra + **Live Gifter Firehose WS** (region/league/global + min-diamond filters) + VIP Telegram alerts + VIP Web Vault |

The SDK calls the sign server **once per connection**, then stays connected
via WebSocket. The free Community tier caps each WebSocket at 2 hours and
is sufficient for most use cases.

### Live Gifter Firehose — Global Agency

Real-time gift event stream from Dragonfly fan-out. Filter by region, league,
or globally; cap by minimum diamond threshold. Mid-stream filter updates
supported via `update_filter` frame.

```js
const ws = new WebSocket(
  `wss://api.tik.tools/firehose/gifters?apiKey=${KEY}&mode=region&region=US%2B&min_diamonds=1000`
)
ws.on('message', (raw) => {
  const evt = JSON.parse(raw)
  // evt: { type:'gifter_alert', ts, gifter, creator, gift, region }
})
```

---

## Examples

### Chat Bot

```typescript
import { TikTokLive } from '@tiktool/live';

const live = new TikTokLive({
    uniqueId: 'streamer_name',
    apiKey: 'YOUR_API_KEY',
});

live.on('chat', (e) => {
    if (e.comment.toLowerCase() === '!hello') {
        console.log(`Hello, ${e.user.nickname}!`);
    }
});

live.on('gift', (e) => {
    if (e.repeatEnd) {
        console.log(`${e.user.uniqueId} sent ${e.repeatCount}x ${e.giftName} (${e.diamondCount * e.repeatCount} diamonds)`);
    }
});

await live.connect();
```

### Live PK Battle Dashboard

```typescript
import { TikTokLive } from '@tiktool/live';

const live = new TikTokLive({ uniqueId: 'streamer_name', apiKey: 'YOUR_API_KEY' });

live.on('battle', e => {
    if (e.status === 1) console.log('🥊 PK STARTED');
    if (e.status === 3) console.log('🏁 PK ENDED');
});

live.on('battleArmies', e => {
    console.log(`\n⏱  ${e.secsRemaining}s remaining`);
    for (const host of e.hosts ?? []) {
        const top = host.contributors[0];
        console.log(`  ${host.hostUserId}: ${host.teamTotalScore}  ` +
                    `(MVP: ${top?.nickname || top?.userId || '—'} ${top?.score ?? 0})`);
    }
});

live.on('battleItemCard', e => {
    const tag = e.multiplier > 0 ? `x${e.multiplier} BOOSTER` : e.effect.toUpperCase();
    console.log(`💥 ${e.senderNickname} → ${tag} (${e.durationSec}s)`);
});

await live.connect();
```

### OBS Overlay

```typescript
import { TikTokLive } from '@tiktool/live';
import { WebSocketServer } from 'ws';

const wss = new WebSocketServer({ port: 8080 });
const live = new TikTokLive({
    uniqueId: 'streamer_name',
    apiKey: 'YOUR_API_KEY',
});

live.on('event', (event) => {
    for (const client of wss.clients) {
        client.send(JSON.stringify(event));
    }
});

await live.connect();
console.log('Forwarding events to ws://localhost:8080');
```

### HLS / FLV Playback URL

```typescript
const info = await live.getStreamUrl();
console.log('HLS:', info.hlsPullUrl);
console.log('FLV:', info.flvPullUrl);
// Or pick a specific quality
console.log('FULL_HD1 HLS:', info.streamUrls.FULL_HD1?.hls);
```

---

## TypeScript

Full TypeScript support with type inference:

```typescript
import {
    TikTokLive,
    ChatEvent,
    GiftEvent,
    BattleArmiesEvent,
    BattleItemCardEvent,
    BattleHost,
    BattleContributor,
} from '@tiktool/live';

const live = new TikTokLive({
    uniqueId: 'username',
    apiKey: 'YOUR_API_KEY',
});

live.on('chat', (event: ChatEvent) => {
    const username: string = event.user.uniqueId;
    const message: string = event.comment;
});

live.on('gift', (event: GiftEvent) => {
    const diamonds: number = event.diamondCount;
    const isCombo: boolean = event.combo;
});

live.on('battleArmies', (event: BattleArmiesEvent) => {
    for (const host of event.hosts ?? []) {
        const mvp: BattleContributor | undefined = host.contributors[0];
        if (mvp) console.log(mvp.nickname, mvp.score);
    }
});

live.on('battleItemCard', (event: BattleItemCardEvent) => {
    if (event.multiplier > 0) {
        console.log(`x${event.multiplier} buff active for ${event.durationSec}s`);
    }
});
```

---

## Changelog

### 2.8.0 (2026-05-19)

- **NEW** `battleItemCard` event — x2/x3 boosters, gloves, mist, thunder,
  extra-time, match-guide. Includes `iconUrl`, `iconKey`, `accentColor` for
  drop-in overlay use.
- **NEW** `BattleArmiesEvent.hosts[]` — multi-guest PK breakdown with per-host
  `contributors[]` sorted **MVP first**.
- **NEW** `matchId`, `sessionId`, `serverTsMs`, `sessionTag`, `secsRemaining`
  fields on `BattleArmiesEvent`.
- Verified live against multi-guest battles 2026-05-19.

---

## License

MIT © [tiktool](https://tik.tools)
