Live-only uploads, reposts, and LIVE start/end. REST and WebSocket. Adding, restarting, recovering, or reconnecting never replays history.
REST uses your API key. WebSocket uses a separate WebSocket key. Do not mix them.
REST — either header:
Authorization: Bearer <api_key> X-API-Key: <api_key>
WebSocket — either header (query-string keys are not enabled):
Authorization: Bearer <ws_key> X-WS-Key: <ws_key>
Mutations also require a stable Idempotency-Key (8–128 characters). Replaying the same key returns the original committed result.
Don't have keys? Join discord.gg/1322 and open a ticket.
Base URL: https://tiktok.1322.io
Public liveness. No auth.
Subscription details and current tracked count.
| Field | Type | Description |
|---|---|---|
client.client_id | string | Your tenant id |
client.name | string | Subscription name |
client.expires_at | RFC3339 or omitted | Expiry when set |
client.max_accounts | int | Plan tracking limit |
client.tracked_count | int | Accounts currently tracked |
client.websocket_enabled | bool | WebSocket delivery capability |
client.discord_enabled | bool | Discord delivery capability |
ready | bool | Tenant control-plane ready |
Accounts you currently track.
| Field | Type | Description |
|---|---|---|
tracks | object[] | Each row has track_id, username, sec_uid, events, state, ws_delivery, discord_delivery |
count | int | Number of tracks |
events is some subset of upload, repost, live. First add is warming until the current page is primed; no historical event is emitted.
Start tracking a TikTok account. 202 while the account is warming, 200 once already primed. Always live-only.
Send exactly one account alias: username, account, url, or tiktok.
| Field | Description | |
|---|---|---|
username | optional | Handle, with or without @ |
events | optional | ["upload","repost","live"] when omitted |
discord_channel_id | optional | Discord snowflake for this account |
emit_initial | optional | Must be omitted or false. true is rejected |
| Status | Meaning |
|---|---|
| 200 / 202 | Tracked. Body is {"track":{...},"quota":{...}} |
| 400 | Invalid body, alias, or emit_initial:true |
| 401 | Missing or invalid API key |
| 403 | Expired or API disabled |
| 409 | Already tracked or list full |
| 422 | Username could not be resolved |
curl -X POST https://tiktok.1322.io/v1/track \
-H "Authorization: Bearer api_xxxxxxxxxxxx" \
-H "Idempotency-Key: track-example-001" \
-H "Content-Type: application/json" \
-d '{"username":"example","events":["upload","repost","live"]}'
Stop tracking. Send exactly one selector: track_id, username, account, or sec_uid.
| Status | Meaning |
|---|---|
| 200 | {"track_id":"...","removed":true,"remaining":N} |
| 400 | Missing, extra, or invalid selector |
Change events, ws_delivery, discord_delivery, or discord_channel_id for an existing track. Requires Idempotency-Key.
Account quota plus WebSocket connection caps for your tenant.
Whether WebSocket is enabled, your opaque path, and current connection counts. The key is never returned. public_url is stripped of query strings when present.
Connect to your opaque tenant path. The path is not your client_id. Read it from Discord or GET /v1/ws/status.
wss://tiktok.1322.io/ws/<opaque_path>
# Auth (either header — not the REST API key):
# Authorization: Bearer <ws_key>
# X-WS-Key: <ws_key>
# Node.js (ws). Browsers cannot set these headers on WebSocket().
import WebSocket from "ws"
const ws = new WebSocket("wss://tiktok.1322.io/ws/<opaque_path>", {
headers: { Authorization: "Bearer <ws_key>" }
})
ws.on("message", (buf) => {
const event = JSON.parse(buf.toString())
// tiktok.upload.created | tiktok.repost.created
// tiktok.live.started | tiktok.live.ended | tiktok.media.ready
console.log(event)
})
Delivery is live-only. A reconnect does not replay missed frames. Use object.kind for the post type (video, photo, or carousel). The permalink path is /video/ or /photo/. Video uploads and video reposts may include object.media_url. When the origin has a playable prefix, a sibling tiktok.media.ready frame (1322.tiktok.media.v1) repeats that event_id, permalink url, and playable media_url. It is not a second post. Photos and carousels do not get media_url or tiktok.media.ready. Caption text is description. Structured mentions and hashtags come from TikTok textExtra when present, including non-ASCII tags that a simple #tag regex would miss. Uploads and reposts may include object.duration (seconds) and object.music. LIVE start may include object.title and object.cover_url from the last live_user room lookup. Posts do not have a separate title.
Browser pages on https://tiktok.1322.io or https://1322.io may set an Origin header; other browser origins are rejected. Clients that send no Origin (Node, Go, most CLI tools) are accepted.
Bad handshake is HTTP 401 / 403 / 404 / 429 before upgrade — wrong key, wrong path, expired tenant, WebSocket disabled, origin denied, or connection limit.
export type ContentEventType =
| "tiktok.upload.created"
| "tiktok.repost.created"
| "tiktok.live.started"
| "tiktok.live.ended"
export type MediaEventType = "tiktok.media.ready"
export interface Account {
sec_uid: string
username: string
display_name: string
verified?: true
}
export interface OriginalAuthor {
sec_uid: string
username: string
display_name: string
avatar_url?: string
verified?: true
}
export type ContentKind = "video" | "photo" | "carousel"
export interface MusicInfo {
title?: string
author_name?: string
original?: true
duration?: number
}
export interface EventObject {
video_id?: string
url?: string
description?: string
cover_url?: string
media_url?: string
image_urls?: string[]
avatar_url?: string
kind?: ContentKind
mentions?: string[]
hashtags?: string[]
duration?: number
music?: MusicInfo
title?: string
original_author?: OriginalAuthor
room_id?: string
previous_state?: string
current_state?: string
session_generation?: number
}
export interface EventEnvelope {
schema: "1322.tiktok.event.v1"
event_id: string
sequence: number
type: ContentEventType
platform: "tiktok"
account: Account
object: EventObject
occurred_at: string | null
observed_at: string
source: { lane: string; poll_sequence: number }
delivery: { mode: "live_only" }
}
export interface MediaReadyEnvelope {
schema: "1322.tiktok.media.v1"
event_id: string
type: "tiktok.media.ready"
platform: "tiktok"
observed_at: string
object: { video_id: string; url: string; media_url: string }
}
Uploads include occurred_at. Reposts and LIVE frames send occurred_at: null. Ignore unknown fields. Tenant frames never include raw TikTok play_url.
These are the fields we actually parse from TikTok polls. We do not invent Binance-style extras (play/digg/comment/share stats, follower counts, bitrate, duet/stitch types, or music CDN URLs). verified is omitted unless TikTok sent true. Discord titles for posts are still generated from @username plus kind.
| Kind | How you tell | Present |
|---|---|---|
| Video upload | kind: "video", URL path /video/, no image_urls | account.display_name (nickname), optional account.verified, cover_url, object.avatar_url, caption, duration in seconds when TikTok sends it, music (title, author_name, original, optional duration), HMAC media_url when we have a playable origin. Sibling tiktok.media.ready repeats the same event_id. |
| Photo | kind: "photo", URL path /photo/, one image_urls entry | Same account/cover/avatar/caption/music. No media_url. duration is present when TikTok sends a slideshow/video length. |
| Carousel / slideshow | kind: "carousel", URL path /photo/, 2–10 image_urls | Same as photo. TikTok returns at most 10 images; we keep that cap. Discord sends extra gallery messages after the first four images. |
| Text-style / photo-mode card | Still an upload. If TikTok attached a card image it is a photo; caption-only with a video cover stays video | Caption from desc, or contents[0].desc when the top-level caption is empty. Mentions/hashtags from textExtra. |
| Repost | tiktok.repost.created | Same media shape as the original item, plus original_author (sec_uid, username, display_name, avatar_url, optional verified). account is the tracked reposter — their verified comes from identity stash, not from item.author. object.avatar_url is the original creator’s face — that is what the repost poll returns as item.author. |
| LIVE start | tiktok.live.started | room_id, previous_state, current_state, session_generation. account.display_name, optional account.verified, and object.avatar_url come from the last seen tracked profile. object.title and object.cover_url come from the last live_user resolver poll while that room was live. The batch alive-check has no title/cover, so a start that fires before the resolver may omit them. No kind / mentions / hashtags / duration / music. |
| LIVE end | tiktok.live.ended | Same room/session fields and stashed profile. No title or cover_url. |
account.display_name is empty until we have seen a nickname for that sec_uid. After a process restart the first upload or LIVE-room lookup fills it again. Discord uses the same object: generated title from kind, caption with @ and # turned into TikTok links, duration/sound fields, mention/hashtag fields, and a gallery of every image_urls entry (four images per message). LIVE start Discord uses the room title as the description and the cover as the embed image when those fields are present.
Upload:
{
"schema": "1322.tiktok.event.v1",
"event_id": "01J5QUPLOAD000000000000000",
"sequence": 12345,
"type": "tiktok.upload.created",
"platform": "tiktok",
"account": {
"sec_uid": "MS4wLjABAAAAfixture",
"username": "example",
"display_name": "Example",
"verified": true
},
"object": {
"video_id": "7540000000000000001",
"url": "https://www.tiktok.com/@example/video/7540000000000000001",
"description": "fixture upload",
"cover_url": "https://p16-sign.tiktokcdn-us.com/fixture.jpeg",
"avatar_url": "https://p16-sign.tiktokcdn-us.com/avatar.jpeg",
"kind": "video",
"duration": 18,
"music": {
"title": "original sound",
"author_name": "Example",
"original": true,
"duration": 18
}
},
"occurred_at": "2026-08-19T10:00:00Z",
"observed_at": "2026-08-19T10:00:00.742Z",
"source": { "lane": "creator_item_list", "poll_sequence": 9876 },
"delivery": { "mode": "live_only" }
}
Photo / carousel (same event type; path is /photo/, no media_url):
{
"schema": "1322.tiktok.event.v1",
"event_id": "01J5QPHOTO0000000000000000",
"sequence": 12345,
"type": "tiktok.upload.created",
"platform": "tiktok",
"account": {
"sec_uid": "MS4wLjABAAAAfixture",
"username": "example",
"display_name": "Example"
},
"object": {
"video_id": "7540000000000000002",
"url": "https://www.tiktok.com/@example/photo/7540000000000000002",
"description": "fixture photo @friend #tag",
"cover_url": "https://p16-sign.tiktokcdn-us.com/photo.jpeg",
"image_urls": [
"https://p16-sign.tiktokcdn-us.com/1.jpeg",
"https://p16-sign.tiktokcdn-us.com/2.jpeg"
],
"avatar_url": "https://p16-sign.tiktokcdn-us.com/avatar.jpeg",
"kind": "carousel",
"mentions": ["friend"],
"hashtags": ["tag"]
},
"occurred_at": "2026-08-19T10:00:00Z",
"observed_at": "2026-08-19T10:00:00.742Z",
"source": { "lane": "creator_item_list", "poll_sequence": 9877 },
"delivery": { "mode": "live_only" }
}
Repost:
{
"schema": "1322.tiktok.event.v1",
"event_id": "01J5QREPOST00000000000000",
"sequence": 12346,
"type": "tiktok.repost.created",
"platform": "tiktok",
"account": {
"sec_uid": "MS4wLjABAAAAfixture",
"username": "example",
"display_name": "Example"
},
"object": {
"video_id": "7539999999999999999",
"url": "https://www.tiktok.com/@origin/video/7539999999999999999",
"description": "fixture repost",
"cover_url": "https://p16-sign.tiktokcdn-us.com/repost.jpeg",
"avatar_url": "https://p16-sign.tiktokcdn-us.com/origin-avatar.jpeg",
"kind": "video",
"original_author": {
"sec_uid": "MS4wLjABAAAAorigin",
"username": "origin",
"display_name": "Origin",
"avatar_url": "https://p16-sign.tiktokcdn-us.com/origin-avatar.jpeg",
"verified": true
}
},
"occurred_at": null,
"observed_at": "2026-08-19T10:00:01.101Z",
"source": { "lane": "repost_item_list", "poll_sequence": 8123 },
"delivery": { "mode": "live_only" }
}
LIVE started:
{
"schema": "1322.tiktok.event.v1",
"event_id": "01J5QLIVESTART00000000000",
"sequence": 12347,
"type": "tiktok.live.started",
"platform": "tiktok",
"account": {
"sec_uid": "MS4wLjABAAAAfixture",
"username": "example",
"display_name": "Example",
"verified": true
},
"object": {
"room_id": "7460000000000000001",
"previous_state": "offline",
"current_state": "live",
"session_generation": 9,
"avatar_url": "https://p16-sign.tiktokcdn-us.com/avatar.jpeg",
"title": "late night",
"cover_url": "https://p16-sign.tiktokcdn-us.com/live-cover.jpeg"
},
"occurred_at": null,
"observed_at": "2026-08-19T10:00:02.250Z",
"source": { "lane": "live_batch", "poll_sequence": 500 },
"delivery": { "mode": "live_only" }
}
LIVE ended:
{
"schema": "1322.tiktok.event.v1",
"event_id": "01J5QLIVEEND0000000000000",
"sequence": 12348,
"type": "tiktok.live.ended",
"platform": "tiktok",
"account": {
"sec_uid": "MS4wLjABAAAAfixture",
"username": "example",
"display_name": "Example"
},
"object": {
"room_id": "7460000000000000001",
"previous_state": "live",
"current_state": "offline",
"session_generation": 9
},
"occurred_at": null,
"observed_at": "2026-08-19T10:30:02.250Z",
"source": { "lane": "live_batch", "poll_sequence": 1940 },
"delivery": { "mode": "live_only" }
}
Playable media sibling (same event_id as the upload or repost, not a second post):
{
"schema": "1322.tiktok.media.v1",
"event_id": "01J5QUPLOAD000000000000000",
"type": "tiktok.media.ready",
"platform": "tiktok",
"observed_at": "2026-08-24T00:00:00Z",
"object": {
"video_id": "7540000000000000001",
"url": "https://www.tiktok.com/@example/video/7540000000000000001",
"media_url": "https://tiktok.1322.io/v1/media/7540000000000000001.mp4?exp=1780000000&sig=abc"
}
}
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | invalid_request | Bad JSON, query, alias, or emit_initial:true |
| 401 | invalid_authentication | Missing or invalid key |
| 403 | subscription_expired / api_disabled / forbidden | Expired, disabled, or not permitted. Open a ticket at discord.gg/1322 |
| 404 | not_found | Unknown track or route |
| 409 | already_tracked / list_full / conflict | Duplicate track, quota, or idempotency clash |
| 422 | resolve_failed | TikTok account could not be resolved |
| 429 | rate_limited | Back off and retry |
| 500 | internal_error | Internal error |
Error body shape: {"error":{"code":"...","message":"...","request_id":"..."}}
Machine-readable contract: /openapi.json. Route table: /v1/docs.