Partner Streaming

Dokumentasi Partner Streaming

Referensi endpoint, contoh request/response, dan cara memasang player di website kamu.

Ke /stream

Ringkas: cara kerjanya

  1. 1. Server kamu minta sesi tontonan untuk satu penonton ke endpoint sesi.
  2. 2. Kami balas playback_url berumur pendek dan sekali pakai.
  3. 3. Browser penonton membuka playback_url, lalu dapat alamat playlist.
  4. 4. Player memutar playlist tersebut. Saat refresh, panggil endpoint resume.

Semua panggilan API dilakukan dari server kamu, bukan dari browser. Kredensial dan cara menandatangani request dikirim privat ke partner aktif lewat dashboard.

Coba langsung (playground)

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.

Daftar endpoint

Method & pathFungsi
GET /api/public/partner/v1/showsDaftar show beserta status akses kamu
POST /api/public/partner/v1/sessionMembuat sesi tontonan untuk satu penonton
GET /api/public/partner/v1/usageSisa kuota view, show aktif, dan estimasi tagihan
POST /api/public/partner/v1/session/revokeMencabut sesi penonton tertentu
GET /api/partner/p/{token}Dibuka browser penonton, menukar token jadi playlist
GET /api/partner/resumeMelanjutkan sesi setelah penonton refresh

Header wajib di setiap request

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.
  • Body harus ditandatangani byte-per-byte sama dengan yang dikirim (jangan re-serialize JSON setelah tanda tangan).
  • Subkey berganti setiap hari UTC — hitung ulang, jangan di-cache lintas hari.
// 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." }

Per-show vs per-view — alurnya sama

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.

  • Per-show: slug harus sudah dibeli. Kalau belum, jawabannya SHOW_NOT_PURCHASED.
  • Per-view: semua slug yang sedang tayang otomatis boleh; yang dihitung adalah jumlah penonton unik. Kalau kuota habis: VIEW_QUOTA_EXCEEDED.

Field plan.billing pada endpoint shows berisi per_show atau per_view supaya integrasi kamu bisa menyesuaikan tampilan sendiri.

1. Ambil daftar show

Panggil dari server kamu untuk tahu slug yang valid dan status aksesnya.

GET /api/public/partner/v1/shows

Contoh 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.

2. Buat sesi penonton

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.

3. Buka playback & pasang player

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.

4. Refresh & lanjut menonton

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.

Masa berlaku alamat media (wajib dibaca)

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.

  • playback_url — 30–300 detik, sekali tukar.
  • playlist_url & segmen — mengikuti umur sesi penonton.
  • X-Play-Key — wajib di setiap request media, berganti setiap resume.

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.

5. Cek pemakaian & tagihan

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 error

KodeArti & solusi
BAD_REQUESTAda field wajib yang kurang atau salah format. Lihat field & hint di response — biasanya show_slug belum dikirim.
SHOW_NOT_PURCHASEDShow belum kamu beli (khusus paket per-show). Beli show di dashboard.
STALE_REQUESTJam server kamu meleset lebih dari 30 detik. Sinkronkan waktu (NTP).
REPLAY_DETECTEDNonce sudah pernah dipakai. Buat nonce acak baru tiap request.
KEY_NOT_FOUNDKredensial tidak dikenali atau sudah dinonaktifkan.
VIEW_QUOTA_EXCEEDEDKuota view habis. Top-up kuota; penonton lama tetap lanjut.
SIGNATURE_REJECTEDPermintaan tidak sah atau kedaluwarsa. Ulangi dengan request baru.
ORIGIN_NOT_ALLOWEDDomain atau server pemanggil belum terdaftar di dashboard.
SESSION_BINDING_MISMATCHAlamat dipakai di perangkat lain. Minta sesi baru.
PLAY_HEADER_REQUIREDHeader X-Play-Key tidak dikirim pada permintaan playlist/segmen.
PLAY_HEADER_INVALIDNilai X-Play-Key salah atau sudah kedaluwarsa. Panggil resume untuk nilai baru.
VARIANT_NOT_ALLOWEDResolusi di atas batas paket kamu.
KEY_LOCKEDKunci dibekukan. Hubungi kami dari dashboard.
PARTNER_SUSPENDEDAkses 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." }

Aturan singkat

  • • Kredensial hanya dipakai di server kamu sendiri, tidak boleh dibagikan atau dijual kembali.
  • • Satu akun partner untuk satu platform.
  • • Dilarang merekam, mengarsipkan, atau menyiarkan ulang stream kami.
  • • Dilarang meneruskan atau memublikasikan alamat playback.
  • • Wajib memakai branding sendiri dan tidak mengklaim sebagai layanan resmi JKT48/IDN.
  • • Pelanggaran berakibat penguncian akses, pencabutan permanen, dan tagihan tetap berjalan.

Bantuan

Kredensial, konfigurasi tanda tangan request, serta penambahan domain/server diatur lewat dashboard partner. Butuh bantuan integrasi? Hubungi kami dari dashboard.