Partner Streaming
Referensi endpoint, contoh request/response, dan cara memasang player di website kamu.
playback_url berumur pendek dan sekali pakai.playback_url, lalu dapat alamat playlist.Semua panggilan API dilakukan dari server kamu, bukan dari browser. Kredensial dan cara menandatangani request dikirim privat ke partner aktif lewat dashboard.
Jalankan permintaan asli ke API kami memakai kunci partner kamu. Header identitas, waktu, nonce, dan tanda tangan dihitung otomatis di sisi kami, lalu request dikirim seolah-olah datang dari IP server kamu yang sudah terdaftar. Hasilnya nyata — kuota view tetap terpakai kalau kamu membuat sesi.
| Method & path | Fungsi |
|---|---|
| GET /api/public/partner/v1/shows | Daftar show beserta status akses kamu |
| POST /api/public/partner/v1/session | Membuat sesi tontonan untuk satu penonton |
| GET /api/public/partner/v1/usage | Sisa kuota view, show aktif, dan estimasi tagihan |
| POST /api/public/partner/v1/session/revoke | Mencabut sesi penonton tertentu |
| GET /api/partner/p/{token} | Dibuka browser penonton, menukar token jadi playlist |
| GET /api/partner/resume | Melanjutkan sesi setelah penonton refresh |
Semua endpoint /api/public/partner/v1/* hanya menerima request yang ditandatangani. Tidak ada mode “tanpa header”, tidak ada API key polos di query string, dan tidak ada endpoint publik untuk melihat data partner. Setiap panggilan wajib membawa header identitas, waktu, nonce sekali pakai, dan tanda tangan atas isi request:
X-Partner-Key: <key id kamu>
X-Timestamp: <unix time detik, selisih maks 30 detik>
X-Nonce: <string acak unik per request, 8–120 karakter>
X-Signature: <tanda tangan request>Cara membentuk X-Signature. Ada dua langkah: turunkan kunci harian dari secret kamu, lalu tanda tangani string kanonik request.
# 1) Kunci harian (subkey), di-hex
day = tanggal UTC hari ini, format YYYY-MM-DD (contoh: 2026-08-08)
subkey = HMAC_SHA256(key = SECRET, msg = day + "|" + KEY_ID + "|" + SERVER_IP) -> hex
# 2) String kanonik
body_hash = SHA256(raw body persis yang dikirim; string kosong "" untuk GET) -> hex
base = TIMESTAMP + ":" + NONCE + ":" + METHOD + ":" + PATH + ":" + body_hash
# 3) Tanda tangan
signature = HMAC_SHA256(key = bytes_dari_hex(subkey), msg = base) -> hex (huruf kecil)SERVER_IP = IP server kamu yang terdaftar di dashboard (yang benar-benar dipakai memanggil).METHOD huruf besar: GET / POST.PATH hanya pathname, tanpa query & tanpa domain, contoh /api/public/partner/v1/session.TIMESTAMP unix detik, sama persis dengan header X-Timestamp.NONCE acak, sama persis dengan header X-Nonce, tidak boleh dipakai ulang.// Node.js (contoh lengkap)
import crypto from "node:crypto";
const KEY_ID = process.env.PARTNER_KEY_ID;
const SECRET = process.env.PARTNER_SECRET;
const SERVER_IP = process.env.PARTNER_SERVER_IP; // IP terdaftar
function signedHeaders(method, path, body = "") {
const day = new Date().toISOString().slice(0, 10);
const subkeyHex = crypto.createHmac("sha256", SECRET)
.update(`${day}|${KEY_ID}|${SERVER_IP}`).digest("hex");
const ts = Math.floor(Date.now() / 1000);
const nonce = crypto.randomBytes(16).toString("hex");
const bodyHash = crypto.createHash("sha256").update(body).digest("hex");
const base = `${ts}:${nonce}:${method.toUpperCase()}:${path}:${bodyHash}`;
const signature = crypto.createHmac("sha256", Buffer.from(subkeyHex, "hex"))
.update(base).digest("hex");
return {
"X-Partner-Key": KEY_ID,
"X-Timestamp": String(ts),
"X-Nonce": nonce,
"X-Signature": signature,
"content-type": "application/json",
};
}
// contoh GET
const path = "/api/public/partner/v1/shows";
await fetch("https://jkt48connect.com" + path, { headers: signedHeaders("GET", path) });
// contoh POST (body ditandatangani, lalu dikirim string yang sama)
const p2 = "/api/public/partner/v1/session";
const body = JSON.stringify({ show_slug: "xxx", viewer_id: "user-1" });
await fetch("https://jkt48connect.com" + p2, {
method: "POST", headers: signedHeaders("POST", p2, body), body,
});# Python
import hmac, hashlib, time, secrets, datetime, json, requests
def signed_headers(method, path, body=""):
day = datetime.datetime.utcnow().strftime("%Y-%m-%d")
subkey = hmac.new(SECRET.encode(), f"{day}|{KEY_ID}|{SERVER_IP}".encode(), hashlib.sha256).hexdigest()
ts = str(int(time.time()))
nonce = secrets.token_hex(16)
body_hash = hashlib.sha256(body.encode()).hexdigest()
base = f"{ts}:{nonce}:{method.upper()}:{path}:{body_hash}"
sig = hmac.new(bytes.fromhex(subkey), base.encode(), hashlib.sha256).hexdigest()
return {"X-Partner-Key": KEY_ID, "X-Timestamp": ts, "X-Nonce": nonce,
"X-Signature": sig, "content-type": "application/json"}Kalau SIGNATURE_REJECTED: cek urutan cek ini — IP yang dipakai server = IP terdaftar, jam server sinkron (maks selisih 30 detik), PATH tanpa query, dan body yang ditandatangani identik dengan body yang dikirim. Nonce yang diulang ditolak dengan REPLAY_DETECTED. Request media (playlist/segmen) memakai header terpisah, lihat bagian 3.
Kalau ada field yang kurang, jawaban kami menyebut nama field-nya, contoh:
{ "ok": false, "code": "BAD_REQUEST",
"message": "Permintaan tidak lengkap atau formatnya salah.",
"field": "show_slug",
"hint": "Wajib kirim show_slug. Ambil nilainya dari GET /api/public/partner/v1/shows." }Banyak yang bingung di sini, jadi diperjelas: tidak ada endpoint khusus per-show maupun per-view. Kedua paket memakai endpoint yang sama, dan show_slug tetap wajib di semua paket karena kami harus tahu stream mana yang dibuka.
SHOW_NOT_PURCHASED.VIEW_QUOTA_EXCEEDED.Field plan.billing pada endpoint shows berisi per_show atau per_view supaya integrasi kamu bisa menyesuaikan tampilan sendiri.
Panggil dari server kamu untuk tahu slug yang valid dan status aksesnya.
GET /api/public/partner/v1/showsContoh response:
{
"ok": true,
"plan": { "code": "view_200", "name": "Per View", "billing": "per_view" },
"entitlements": [
{ "show_slug": "jkt48_official", "title": "JKT48 Theater", "expires_at": null }
],
"shows": [
{
"show_slug": "jkt48_official",
"slug": "jkt48_official",
"title": "JKT48 Theater — Aturan Anti Cinta",
"entitled": true,
"reason": null
}
]
}Pakai nilai show_slug apa adanya (huruf kecil) saat membuat sesi. Jangan mengarang slug sendiri — slug yang tidak dikenal akan gagal.
Satu sesi = satu penonton. viewer_id opsional tapi sangat disarankan (ID user di sistem kamu) supaya refresh atau buka ulang tidak dihitung sebagai view baru. Tanpa viewer_id, setiap sesi dianggap penonton baru.
POST /api/public/partner/v1/session
Content-Type: application/json
{
"show_slug": "jkt48_official",
"viewer_id": "user-8812",
"ttl": 120
}Response:
{
"ok": true,
"session_id": "ps_01J8Z...",
"playback_url": "https://jkt48connect.com/api/partner/p/eyJhbGciOi...",
"expires_in": 120,
"single_use": true,
"resume_url": "https://jkt48connect.com/api/partner/resume",
"quota": {
"type": "per_view",
"used": 143,
"purchased": 200,
"remaining": 57
},
"max_resolution": 720
}playback_url hanya berlaku sekali tukar dan berumur pendek (ttl 30–300 detik, default 120). Kirim ke browser penonton begitu halaman tonton dibuka, jangan disimpan atau dibagikan. Untuk paket per-show, quota.type berisi per_show dan quota.remaining bisa null kalau tanpa batas view.
Browser penonton membuka playback_url, lalu menerima alamat playlist beserta header pemutaran:
{
"ok": true,
"session_id": "ps_01J8Z...",
"playlist_url": "https://jkt48connect.com/api/partner/s/2f9c8a1d4b...",
"play_header_name": "X-Play-Key",
"play_header_value": "pmk_9f2a...",
"type": "application/vnd.apple.mpegurl",
"max_resolution": 720,
"expires_at": "2026-08-06T13:10:00Z"
}Wajib: setiap permintaan playlist, kunci, dan segmen harus mengirim header X-Play-Key berisi play_header_value. Header ini berbeda dari kredensial server-to-server, hanya berlaku untuk sesi penonton tersebut, dan berganti setiap resume. Alamat playlist tanpa header ini tidak akan dilayani, jadi URL yang tercopy tidak bisa dipakai ulang oleh orang lain.
Contoh pemasangan dengan hls.js:
const res = await fetch(playbackUrl, { credentials: "include" });
const data = await res.json();
const hls = new Hls({
xhrSetup: (xhr) => {
xhr.withCredentials = true; // sesi penonton
xhr.setRequestHeader(data.play_header_name, data.play_header_value); // wajib
},
});
hls.loadSource(data.playlist_url);
hls.attachMedia(document.querySelector("video"));Kalau memakai loader berbasis fetch (hls.js v1 fetchSetup):
const hls = new Hls({
fetchSetup: (context, initParams) => new Request(context.url, {
...initParams,
credentials: "include",
headers: { ...initParams.headers, [data.play_header_name]: data.play_header_value },
}),
});Karena header wajib ini, pemutaran memakai hls.js (atau player lain yang bisa menambah header). Native HLS Safari tanpa hls.js tidak didukung. Playlist multi-resolusi didukung penuh — ganti resolusi tidak menambah kuota view.
Saat halaman dimuat ulang, panggil resume dulu sebelum minta sesi baru:
GET /api/partner/resume (credentials: include)
{
"ok": true,
"session_id": "ps_01J8Z...",
"resumed": true,
"counted": true,
"playlist_url": "https://jkt48connect.com/api/partner/s/7d21...",
"play_header_name": "X-Play-Key",
"play_header_value": "pmk_baru..."
}counted: true berarti view tidak dihitung ulang. Resume selalu mengeluarkan play_header_value baru — pakai nilai terbaru dan buang yang lama. Kalau resume gagal (ok: false), baru minta sesi baru dari server kamu.
Alamat playlist dan segmen tidak lagi punya masa kedaluwarsa sendiri. Alamat hidup selama sesi penonton masih hidup, tetapi terikat ketat ke sesi, perangkat, dan jaringan yang membuatnya — jadi kalau dicolong dan dibuka di tempat lain tetap tidak bisa dipakai.
Karena itu, resume hanya dipakai saat penonton refresh halaman atau saat sesi benar-benar ditolak — bukan untuk error jaringan biasa. Setelah resume, header lama masih diterima sebentar supaya request yang sedang berjalan tidak putus dan player tidak tersendat.
const hls = new Hls({
xhrSetup: (xhr) => {
xhr.withCredentials = true;
xhr.setRequestHeader("X-Play-Key", playKey); // selalu pakai nilai terbaru
},
});
let tries = 0;
async function renew() {
if (tries++ >= 3) return; // sesi benar-benar sudah tidak valid
const r = await fetch("/api/partner/resume", { credentials: "include" });
const j = await r.json();
if (!j.ok) return; // baru minta sesi baru dari server kamu
tries = 0;
playKey = j.play_header_value;
hls.destroy();
start(j.playlist_url, playKey); // pasang ulang player
}
hls.on(Hls.Events.ERROR, (_e, d) => {
const status = d.response?.code;
// hanya sesi yang ditolak yang perlu resume
if (status === 401 || status === 403) { renew(); return; }
if (d.fatal && d.type === Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad();
else if (d.fatal && d.type === Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError();
});Jangan menjalankan resume hanya karena levelParsingError, fragLoadError, atau HTTP 404 sesaat. Pada live stream, itu dapat terjadi saat pergantian segmen dan harus ditangani oleh retry bawaan player. Resume yang terlalu agresif mengganti header saat request lama masih berjalan dan justru menyebabkan player berhenti berulang.
Catatan: resume tidak menambah kuota view selama masih sesi yang sama (counted: true). Beri jeda minimal 5 detik antar percobaan resume dan berhenti setelah beberapa kali gagal supaya tidak looping saat show sudah selesai.
GET /api/public/partner/v1/usage
{
"ok": true,
"partner": { "name": "Nama Partner", "status": "active", "expires_at": "2026-09-06T00:00:00Z" },
"plan": {
"code": "view_200",
"name": "Per View",
"monthly_price": 0,
"price_per_show": 0,
"price_per_view": 2000
},
"quota": { "purchased": 200, "used": 143, "remaining": 57 },
"last_24h": {
"sessions": 51,
"by_show": { "jkt48_official": { "sessions": 51, "counted_views": 34 } }
}
}Untuk paket per-show, price_per_show terisi dan quota.remaining bisa null (tidak dibatasi jumlah view).
| Kode | Arti & solusi |
|---|---|
| BAD_REQUEST | Ada field wajib yang kurang atau salah format. Lihat field & hint di response — biasanya show_slug belum dikirim. |
| SHOW_NOT_PURCHASED | Show belum kamu beli (khusus paket per-show). Beli show di dashboard. |
| STALE_REQUEST | Jam server kamu meleset lebih dari 30 detik. Sinkronkan waktu (NTP). |
| REPLAY_DETECTED | Nonce sudah pernah dipakai. Buat nonce acak baru tiap request. |
| KEY_NOT_FOUND | Kredensial tidak dikenali atau sudah dinonaktifkan. |
| VIEW_QUOTA_EXCEEDED | Kuota view habis. Top-up kuota; penonton lama tetap lanjut. |
| SIGNATURE_REJECTED | Permintaan tidak sah atau kedaluwarsa. Ulangi dengan request baru. |
| ORIGIN_NOT_ALLOWED | Domain atau server pemanggil belum terdaftar di dashboard. |
| SESSION_BINDING_MISMATCH | Alamat dipakai di perangkat lain. Minta sesi baru. |
| PLAY_HEADER_REQUIRED | Header X-Play-Key tidak dikirim pada permintaan playlist/segmen. |
| PLAY_HEADER_INVALID | Nilai X-Play-Key salah atau sudah kedaluwarsa. Panggil resume untuk nilai baru. |
| VARIANT_NOT_ALLOWED | Resolusi di atas batas paket kamu. |
| KEY_LOCKED | Kunci dibekukan. Hubungi kami dari dashboard. |
| PARTNER_SUSPENDED | Akses partner dihentikan sementara. |
Format error selalu rata (tidak bertingkat), dan bisa membawa field/hint:
{ "ok": false, "code": "SHOW_NOT_PURCHASED", "message": "Show ini belum dibeli untuk akun partner kamu." }Kredensial, konfigurasi tanda tangan request, serta penambahan domain/server diatur lewat dashboard partner. Butuh bantuan integrasi? Hubungi kami dari dashboard.