transkrip.onlineMasuk

Dokumentasi API · v1

API transkrip bahasa Indonesia (speech to text)

Kirim rekaman audio atau video, terima transkrip bahasa Indonesia dengan pembicara dipisah dan stempel waktu per segmen. Satu key, saldo yang sama dengan dasbor web, tarif Rp50 per menit. Pakai API asinkron + webhook untuk rekaman panjang, atau endpoint kompatibel OpenAI kalau aplikasi Anda sudah memakai SDK-nya.

Key dibuat di dasbor (menu API) setelah masuk dengan email, dan hanya tampil sekali.

Base URL
https://api.transkrip.online/v1
Autentikasi
Authorization: Bearer trk_live_…
Harga
Rp50/menit · Rp3.000/jam audio
Batas berkas
90 MB per kiriman · 2 GB lewat unggahan bertahap · durasi ≤ 2 jam

Mulai cepat

Semua permintaan memakai HTTPS ke https://api.transkrip.online/v1, galat berbentuk { error: { message, type, code, param } } seperti API OpenAI, waktu dalam detik unix, dan uang dalam Rupiah.

1. Buat key di dasbor

Masuk, buka menu API, lalu buat key. Key berawalan trk_live_ dan hanya tampil sekali, jadi simpan langsung sebagai variabel lingkungan. Di sana Anda juga bisa memberi batas pemakaian bulanan per key dalam Rupiah, mengatur URL webhook bawaan, dan membuat rahasia webhook.

shell
export TRANSKRIP_API_KEY="trk_live_..."

2. Kirim rekaman

Jawabannya langsung 202 dengan objek transkrip berstatus queued. Biaya pekerjaan ini ada di price_idr, dan saldo ditahan sebesar itu.

curl
curl https://api.transkrip.online/v1/transcriptions \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  -F [email protected] \
  -F language=id \
  -F num_speakers=3 \
  -F title="Rapat mingguan"
202 Accepted (dipangkas)
{
  "id": "trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1",
  "object": "transcription",
  "status": "queued",
  "created_at": 1792677149,
  "completed_at": null,
  "title": "Rapat mingguan",
  "filename": "rapat.m4a",
  "language": "id",
  "duration": 1834.2,
  "billed_minutes": 31,
  "price_idr": 1550,
  "num_speakers": 3,
  "source": "api"
}

3. Ambil hasil

Tanya status sampai completed (atau pasang webhook), lalu baca segments atau unduh berkas jadi. Lama proses biasanya ±¼–½ durasi rekaman, tergantung antrean.

curl
curl https://api.transkrip.online/v1/transcriptions/trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1 \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY"
curl
curl -o rapat.docx \
  "https://api.transkrip.online/v1/transcriptions/trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1/export?format=docx" \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY"

Transkrip dari API tampil juga di dasbor (lencana API): bisa dibuka, disunting, dan diunduh di web. Label pembicara yang diganti di web ikut terbaca lewat API.

Python

Dengan requests (API asinkron)

Cocok untuk rekaman panjang dan pekerjaan batch: kirim, lalu tanya status tiap 15 detik. Pasang paketnya dengan pip install requests.

transkrip.py
import os
import time

import requests

BASE = "https://api.transkrip.online/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['TRANSKRIP_API_KEY']}"}

# 1. Kirim rekaman (202: pekerjaan masuk antrean)
with open("rapat.m4a", "rb") as f:
    r = requests.post(
        f"{BASE}/transcriptions",
        headers=HEADERS,
        files={"file": ("rapat.m4a", f)},
        data={"language": "id", "num_speakers": "3", "title": "Rapat mingguan"},
        timeout=600,
    )
if not r.ok:
    raise SystemExit(r.json()["error"]["message"])
job = r.json()

# 2. Tanya status sampai selesai
while job["status"] in ("queued", "processing"):
    time.sleep(15)
    job = requests.get(f"{BASE}/transcriptions/{job['id']}", headers=HEADERS, timeout=30).json()

if job["status"] == "failed":
    raise SystemExit(job["error"]["message"])  # saldo sudah kembali otomatis

# 3. Pakai hasilnya: label pembicara + segmen berstempel waktu
names = {s["id"]: s["label"] for s in job["speakers"]}
for seg in job["segments"]:
    print(f"[{seg['start']:7.1f}] {names.get(seg['speaker'], seg['speaker'])}: {seg['text']}")

Dengan SDK OpenAI

Cukup ganti base_url dan model. Endpoint ini sinkron: panggilan menunggu sampai transkrip selesai, jadi naikkan timeout untuk rekaman panjang. Detailnya di Kompatibel OpenAI.

openai_sdk.py
import os

from openai import OpenAI

client = OpenAI(
    api_key=os.environ["TRANSKRIP_API_KEY"],
    base_url="https://api.transkrip.online/v1",
    timeout=3 * 60 * 60,  # detik; bawaan SDK 10 menit, terlalu pendek untuk rekaman panjang
)

with open("rapat.m4a", "rb") as f:
    result = client.audio.transcriptions.create(
        model="transkrip-1",
        file=f,
        language="id",
        response_format="verbose_json",
        extra_body={"num_speakers": 2},  # opsional; kosong = otomatis
    )

print(result.text)
for seg in result.segments:
    print(seg.start, seg.end, seg.text)

Node.js

Dengan fetch (API asinkron)

Node 20 ke atas sudah punya fetch, FormData, dan openAsBlob; tidak perlu paket tambahan. Simpan sebagai .mjs agar await di tingkat atas berjalan.

transkrip.mjs
// transkrip.mjs — Node 20+, tanpa dependensi
import { openAsBlob } from "node:fs";

const BASE = "https://api.transkrip.online/v1";
const headers = { Authorization: `Bearer ${process.env.TRANSKRIP_API_KEY}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

// 1. Kirim rekaman (202: pekerjaan masuk antrean)
const form = new FormData();
form.append("file", await openAsBlob("rapat.m4a"), "rapat.m4a");
form.append("language", "id");
form.append("num_speakers", "3");

const res = await fetch(`${BASE}/transcriptions`, { method: "POST", headers, body: form });
let job = await res.json();
if (!res.ok) throw new Error(job.error.message);

// 2. Tanya status sampai selesai
while (job.status === "queued" || job.status === "processing") {
  await sleep(15_000);
  job = await (await fetch(`${BASE}/transcriptions/${job.id}`, { headers })).json();
}
if (job.status === "failed") throw new Error(job.error.message); // saldo sudah kembali

// 3. Pakai hasilnya: label pembicara + segmen berstempel waktu
const names = Object.fromEntries(job.speakers.map((s) => [s.id, s.label]));
for (const seg of job.segments) {
  console.log(`[${seg.start.toFixed(1)}] ${names[seg.speaker] ?? seg.speaker}: ${seg.text}`);
}

Dengan SDK OpenAI

npm install openai, lalu arahkan baseURL ke API ini. Jumlah pembicara dideteksi otomatis bila tidak diisi.

openai-sdk.mjs
import fs from "node:fs";
import OpenAI from "openai";

const client = new OpenAI({
  apiKey: process.env.TRANSKRIP_API_KEY,
  baseURL: "https://api.transkrip.online/v1",
  timeout: 3 * 60 * 60 * 1000, // milidetik; bawaan SDK 10 menit
});

const result = await client.audio.transcriptions.create({
  model: "transkrip-1",
  file: fs.createReadStream("rapat.m4a"),
  language: "id",
  response_format: "verbose_json",
});

console.log(result.text);

Unggahan besar

Berkas sampai 90 MB cukup dikirim sekali — cukup untuk kebanyakan rekaman MP3/M4A. Berkas yang lebih besar (WAV mentah, video) sampai 2 GB dikirim lewat unggahan bertahap, pola yang juga dipakai platform besar untuk berkas besar. Ada dua bentuk badan untuk POST /v1/transcriptions:

  • Multipart (-F file=@…), seperti di Mulai cepat. curl membaca berkas dari disk sambil mengirim.
  • Badan mentah dengan Content-Type: audio/*, video/*, atau application/octet-stream. Opsi dikirim lewat query string. Untuk application/octet-stream, query filename wajib diisi; untuk audio/* dan video/* boleh kosong.
curl · badan mentah
curl "https://api.transkrip.online/v1/transcriptions?language=id&num_speakers=3&title=Rapat%20tahunan" \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  -H "Content-Type: video/mp4" \
  -T rapat-tahunan.mp4 -X POST

-T berkas -X POST mengalirkan berkas dari disk; --data-binary @berkas juga diterima, tetapi curl memuat seluruh berkas ke memori lebih dulu.

Python · badan mentah
import os

import requests

BASE = "https://api.transkrip.online/v1"
HEADERS = {"Authorization": f"Bearer {os.environ['TRANSKRIP_API_KEY']}"}

with open("rapat-tahunan.mp4", "rb") as f:
    r = requests.post(
        f"{BASE}/transcriptions",
        params={"language": "id", "num_speakers": 3, "title": "Rapat tahunan"},
        headers={**HEADERS, "Content-Type": "video/mp4"},
        data=f,  # objek berkas = dialirkan dari disk, tidak dimuat ke memori
        timeout=(10, 3600),
    )
print(r.status_code, r.json())
Node · badan mentah
import { openAsBlob } from "node:fs";

const BASE = "https://api.transkrip.online/v1";
const params = new URLSearchParams({ language: "id", num_speakers: "3", title: "Rapat tahunan" });

const res = await fetch(`${BASE}/transcriptions?${params}`, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.TRANSKRIP_API_KEY}`,
    "Content-Type": "video/mp4",
  },
  body: await openAsBlob("rapat-tahunan.mp4"), // dibaca dari disk sambil dikirim
});
console.log(res.status, await res.json());

Bila koneksi putus di tengah kiriman, jawabannya 400 upload_interrupted: potongan berkas dihapus dan tidak ada tagihan. Kirim ulang dari awal, atau pakai unggahan bertahap di bawah supaya bisa dilanjutkan.

Unggahan bertahap (bisa dilanjutkan)

Wajib untuk berkas di atas 90 MB (sampai 2 GB), dan berguna untuk koneksi yang sering putus: kirim berkas per potongan. Skrip Python & Node di bawah siap dipakai. Kalau putus, cukup lanjutkan dari received_bytes, tidak perlu mengulang dari awal.

  1. POST /v1/uploads dengan {"filename", "bytes"} → 201, berisi id (upl_…) dan part_bytes (16 MB).
  2. PUT /v1/uploads/{id}/parts?offset=… dengan badan biner application/octet-stream, maksimal 16 MB per potongan. Potongan yang sudah diterima dijawab sukses tanpa ditulis ulang. Offset yang salah dijawab 409 offset_mismatch, dan pesannya menyebut offset yang benar.
  3. GET /v1/uploads/{id} untuk melihat status dan received_bytes saat melanjutkan.
  4. POST /v1/uploads/{id}/complete: durasi diukur dan harga dihitung. Jawabannya objek unggahan ditambah duration, billed_minutes, price_idr, balance_idr, dan enough_balance. Saldo belum dipotong.
  5. POST /v1/transcriptions dengan JSON {"upload_id": "upl_…"} dan opsi yang sama seperti multipart. Unggahan yang tidak dipakai kedaluwarsa setelah 24 jam.
curl · dari upload_id
curl https://api.transkrip.online/v1/transcriptions \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: upload-upl_8a1f..." \
  -d '{"upload_id": "upl_8a1f...", "language": "id", "num_speakers": 2}'

Skrip siap pakai di bawah menjalankan kelima langkah itu, mencoba ulang saat jaringan atau server sibuk, dan menyimpan id unggahan di berkas .upload-id. Jalankan perintah yang sama lagi untuk melanjutkan.

unggah.py
"""Unggah bertahap yang bisa dilanjutkan.

Pakai:  TRANSKRIP_API_KEY=trk_live_... python unggah.py rekaman.mp4
Bila koneksi putus, jalankan perintah yang sama lagi: unggahan diteruskan dari posisi terakhir.
"""
import os
import sys
import time
from pathlib import Path

import requests

BASE = "https://api.transkrip.online/v1"
AUTH = {"Authorization": f"Bearer {os.environ['TRANSKRIP_API_KEY']}"}


def call(method, path, headers=None, **kwargs):
    """Panggil API. Galat jaringan, 429, dan 5xx dicoba ulang dengan jeda bertambah."""
    for attempt in range(6):
        wait = 2**attempt
        try:
            r = requests.request(
                method, BASE + path, headers={**AUTH, **(headers or {})}, timeout=120, **kwargs
            )
            if r.status_code != 429 and r.status_code < 500:
                return r
            wait = int(r.headers.get("Retry-After") or wait)
        except requests.RequestException:
            pass
        time.sleep(wait)
    raise SystemExit(f"Gagal setelah 6 percobaan: {method} {path}")


def check(r):
    if not r.ok:
        err = r.json().get("error", {})
        raise SystemExit(f"{r.status_code} {err.get('code')}: {err.get('message')}")
    return r.json()


def main(file_path):
    path = Path(file_path)
    size = path.stat().st_size
    state = path.with_name(path.name + ".upload-id")

    # Lanjutkan unggahan lama bila masih terbuka; kalau tidak, mulai baru.
    upload = None
    if state.exists():
        r = call("GET", f"/uploads/{state.read_text().strip()}")
        if r.ok and r.json().get("status") == "uploading":
            upload = r.json()
    if upload is None:
        upload = check(call("POST", "/uploads", json={"filename": path.name, "bytes": size}))
        state.write_text(upload["id"])

    offset = upload["received_bytes"]
    with path.open("rb") as f:
        while offset < size:
            f.seek(offset)
            chunk = f.read(upload["part_bytes"])
            r = call(
                "PUT",
                f"/uploads/{upload['id']}/parts",
                params={"offset": offset},
                headers={"Content-Type": "application/octet-stream"},
                data=chunk,
            )
            if r.status_code == 409 and r.json()["error"]["code"] == "offset_mismatch":
                offset = check(call("GET", f"/uploads/{upload['id']}"))["received_bytes"]
                continue
            if not r.ok:
                check(r)
            offset += len(chunk)
            print(f"{offset * 100 // size}% terkirim")

    done = check(call("POST", f"/uploads/{upload['id']}/complete"))
    print(f"Durasi {done['duration']:.0f} detik, {done['billed_minutes']} menit, Rp{done['price_idr']}")
    if not done["enough_balance"]:
        raise SystemExit(
            f"Saldo Rp{done['balance_idr']} belum cukup. Isi saldo, lalu buat transkrip dengan "
            f"upload_id {upload['id']} (berlaku 24 jam)."
        )

    # Idempotency-Key dari id unggahan: aman diulang, tidak pernah tertagih dua kali.
    job = check(
        call(
            "POST",
            "/transcriptions",
            headers={"Idempotency-Key": f"upload-{upload['id']}"},
            json={"upload_id": upload["id"], "language": "id"},
        )
    )
    state.unlink(missing_ok=True)
    print("Transkrip dibuat:", job["id"], job["status"])


if __name__ == "__main__":
    main(sys.argv[1])
unggah.mjs
// unggah.mjs — unggah bertahap yang bisa dilanjutkan (Node 20+, tanpa dependensi).
// Pakai:  TRANSKRIP_API_KEY=trk_live_... node unggah.mjs rekaman.mp4
// Bila koneksi putus, jalankan perintah yang sama lagi: unggahan diteruskan dari posisi terakhir.
import { existsSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { open } from "node:fs/promises";
import { basename } from "node:path";

const BASE = "https://api.transkrip.online/v1";
const AUTH = { Authorization: `Bearer ${process.env.TRANSKRIP_API_KEY}` };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

/** Panggil API. Galat jaringan, 429, dan 5xx dicoba ulang dengan jeda bertambah. */
async function call(method, path, { headers = {}, body } = {}) {
  for (let attempt = 0; attempt < 6; attempt++) {
    let wait = 2 ** attempt * 1000;
    try {
      const res = await fetch(BASE + path, {
        method,
        headers: { ...AUTH, ...headers },
        body,
        signal: AbortSignal.timeout(120_000),
      });
      if (res.status !== 429 && res.status < 500) return res;
      wait = Number(res.headers.get("retry-after")) * 1000 || wait;
    } catch {
      // galat jaringan / batas waktu: coba lagi
    }
    await sleep(wait);
  }
  throw new Error(`Gagal setelah 6 percobaan: ${method} ${path}`);
}

async function check(res) {
  const data = await res.json();
  if (!res.ok) throw new Error(`${res.status} ${data.error?.code}: ${data.error?.message}`);
  return data;
}

const asJson = (obj) => ({
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify(obj),
});

const file = process.argv[2];
const size = statSync(file).size;
const stateFile = `${file}.upload-id`;

// Lanjutkan unggahan lama bila masih terbuka; kalau tidak, mulai baru.
let upload = null;
if (existsSync(stateFile)) {
  const res = await call("GET", `/uploads/${readFileSync(stateFile, "utf8").trim()}`);
  const data = res.ok ? await res.json() : null;
  if (data?.status === "uploading") upload = data;
}
if (!upload) {
  upload = await check(await call("POST", "/uploads", asJson({ filename: basename(file), bytes: size })));
  writeFileSync(stateFile, upload.id);
}

const fh = await open(file, "r");
let offset = upload.received_bytes;
while (offset < size) {
  const length = Math.min(upload.part_bytes, size - offset);
  const chunk = Buffer.alloc(length);
  await fh.read(chunk, 0, length, offset);
  const res = await call("PUT", `/uploads/${upload.id}/parts?offset=${offset}`, {
    headers: { "Content-Type": "application/octet-stream" },
    body: chunk,
  });
  if (res.status === 409) {
    const { error } = await res.json();
    if (error?.code !== "offset_mismatch") throw new Error(error?.message);
    offset = (await check(await call("GET", `/uploads/${upload.id}`))).received_bytes;
    continue;
  }
  if (!res.ok) await check(res);
  await res.arrayBuffer();
  offset += length;
  console.log(`${Math.floor((offset * 100) / size)}% terkirim`);
}
await fh.close();

const done = await check(await call("POST", `/uploads/${upload.id}/complete`));
console.log(`Durasi ${Math.round(done.duration)} detik, ${done.billed_minutes} menit, Rp${done.price_idr}`);
if (!done.enough_balance) {
  console.error(
    `Saldo Rp${done.balance_idr} belum cukup. Isi saldo, lalu buat transkrip dengan upload_id ${upload.id} (berlaku 24 jam).`,
  );
  process.exit(1);
}

// Idempotency-Key dari id unggahan: aman diulang, tidak pernah tertagih dua kali.
const job = await check(
  await call("POST", "/transcriptions", {
    headers: { "Content-Type": "application/json", "Idempotency-Key": `upload-${upload.id}` },
    body: JSON.stringify({ upload_id: upload.id, language: "id" }),
  }),
);
unlinkSync(stateFile);
console.log("Transkrip dibuat:", job.id, job.status);

Polling vs webhook

Polling paling sederhana: GET /v1/transcriptions/{id} tiap 10–30 detik sampai status completed atau failed. Selama antre, queue_position dan estimated_completion_at membantu menentukan jeda. Untuk banyak pekerjaan sekaligus, pakai GET /v1/transcriptions?status=processing daripada menanyai satu per satu, dan ingat batas 60 permintaan per menit per key.

Webhook lebih hemat: kami memanggil URL Anda saat transkrip selesai atau gagal. Isi webhook_url per pekerjaan, atau atur URL bawaan per key di dasbor (dipakai bila webhook_url kosong; URL dicatat pada pekerjaan saat dibuat). URL wajib https dan alamat publik; alamat privat, loopback, link-local, dan metadata ditolak. Port bebas. Webhook hanya untuk API asinkron POST /v1/transcriptions; endpoint kompatibel OpenAI tidak mengirim webhook.

Peristiwa

  • transcription.completed dan transcription.failed, satu per jenis per transkrip.
  • webhook.test dari tombol Kirim uji di dasbor, dengan data.id bernilai null dan data.status test.

Payload sengaja ringkas. Anggap sebagai petunjuk, lalu ambil hasilnya dengan GET.

Header
POST /webhook/transkrip HTTP/1.1
Content-Type: application/json
User-Agent: Transkrip-Webhooks/1.0
Transkrip-Event: transcription.completed
Transkrip-Signature: t=1792677901,v1=5f0c2a…
Badan
{
  "id": "evt_3c9d1e7f2a6b4c8d9e0f1a2b3c4d5e6f",
  "object": "event",
  "type": "transcription.completed",
  "created_at": 1792677901,
  "data": {
    "id": "trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1",
    "status": "completed"
  }
}

Pengiriman & coba ulang

  • Balas 2xx dalam 10 detik; badan jawaban diabaikan dan redirect tidak diikuti. Kerjakan hal berat (GET hasil, simpan ke database) di luar permintaan webhook.
  • 408, 429, 5xx, atau galat jaringan dicoba ulang setelah 30 detik, 2 menit, 10 menit, 30 menit, 1 jam, 3 jam, 6 jam, dan 12 jam (sekitar 24 jam). 4xx lain atau 3xx menghentikan pengiriman.
  • Peristiwa yang sama bisa tiba lebih dari sekali bila jawaban Anda lambat. Abaikan id peristiwa (evt_…) yang sudah pernah diproses.
  • Riwayat 20 kiriman terakhir ada di dasbor.

Verifikasi tanda tangan

Header Transkrip-Signature: t=<unix>,v1=<hex>. Nilai v1 = hex dari HMAC-SHA256 dengan kunci rahasia whsec_… utuh (termasuk awalannya, sebagai UTF-8) atas pesan t + "." + badan mentah.

  • Pakai badan mentah, sebelum di-parse JSON. Parse lalu stringify ulang mengubah bait dan tanda tangannya tidak akan cocok.
  • Bandingkan secara constant-time, dan tolak bila selisih waktu sekarang dengan t lebih dari 300 detik.
  • Selama 24 jam setelah rahasia diganti, header memuat dua v1= (rahasia baru dan lama). Cocok dengan salah satunya berarti sah.
  • Rahasia dibuat dan diganti di dasbor, dan hanya tampil sekali. Bila belum pernah dibuat, rahasia dibuat otomatis saat kiriman pertama; buat ulang di dasbor untuk melihatnya.
Python · Flask
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

TOLERANCE = 300  # detik
SECRET = os.environ["TRANSKRIP_WEBHOOK_SECRET"]  # "whsec_..." utuh, termasuk awalannya


def verify_signature(raw_body: bytes, header: str, secret: str) -> bool:
    """raw_body = badan permintaan apa adanya (bytes), SEBELUM di-parse JSON."""
    t = None
    signatures = []
    for item in header.split(","):
        key, _, value = item.strip().partition("=")
        if key == "t":
            t = value
        elif key == "v1":
            signatures.append(value)  # bisa lebih dari satu (rahasia baru & lama)
    if t is None or not t.isdigit() or abs(time.time() - int(t)) > TOLERANCE:
        return False
    message = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected.encode(), s.encode()) for s in signatures)


app = Flask(__name__)
seen = set()  # contoh; di produksi simpan id peristiwa di database/Redis


@app.post("/webhook/transkrip")
def transkrip_webhook():
    raw = request.get_data()
    if not verify_signature(raw, request.headers.get("Transkrip-Signature", ""), SECRET):
        abort(401)
    event = json.loads(raw)
    if event["id"] not in seen:  # peristiwa bisa tiba lebih dari sekali
        seen.add(event["id"])
        if event["type"] == "transcription.completed":
            print("Selesai:", event["data"]["id"])  # ambil hasil lewat GET di antrean Anda
    return "", 200
Node · Express
import crypto from "node:crypto";
import express from "express";

const TOLERANCE = 300; // detik
const SECRET = process.env.TRANSKRIP_WEBHOOK_SECRET; // "whsec_..." utuh, termasuk awalannya

/** rawBody = Buffer badan permintaan apa adanya, SEBELUM di-parse JSON. */
function verifySignature(rawBody, header, secret) {
  let t = null;
  const signatures = [];
  for (const item of (header ?? "").split(",")) {
    const i = item.indexOf("=");
    if (i < 0) continue;
    const key = item.slice(0, i).trim();
    const value = item.slice(i + 1).trim();
    if (key === "t") t = value;
    else if (key === "v1") signatures.push(value); // bisa lebih dari satu (rahasia baru & lama)
  }
  const ts = Number(t);
  if (!t || !Number.isSafeInteger(ts) || Math.abs(Date.now() / 1000 - ts) > TOLERANCE) {
    return false;
  }
  const expected = Buffer.from(
    crypto.createHmac("sha256", secret).update(`${t}.`).update(rawBody).digest("hex"),
  );
  return signatures.some((s) => {
    const got = Buffer.from(s);
    return got.length === expected.length && crypto.timingSafeEqual(got, expected);
  });
}

const app = express();
const seen = new Set(); // contoh; di produksi simpan id peristiwa di database/Redis

app.post("/webhook/transkrip", express.raw({ type: "application/json" }), (req, res) => {
  if (!verifySignature(req.body, req.get("Transkrip-Signature"), SECRET)) {
    return res.sendStatus(401);
  }
  const event = JSON.parse(req.body.toString("utf8"));
  if (!seen.has(event.id)) {
    // peristiwa bisa tiba lebih dari sekali
    seen.add(event.id);
    if (event.type === "transcription.completed") {
      console.log("Selesai:", event.data.id); // ambil hasil lewat GET di antrean Anda
    }
  }
  res.sendStatus(200);
});

app.listen(3000);

Objek transkrip

GET/v1/transcriptions/{id}

Query include=segments (bawaan) mengembalikan teks dan segmen; include=text hanya teks.

200 OK
{
  "id": "trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1",
  "object": "transcription",
  "status": "completed",
  "created_at": 1792677149,
  "completed_at": 1792677901,
  "title": "Wawancara informan 3",
  "filename": "informan-3.m4a",
  "language": "id",
  "duration": 2832.4,
  "billed_minutes": 48,
  "price_idr": 2400,
  "charged_idr": 2400,
  "refunded": false,
  "num_speakers": 2,
  "progress": 1,
  "queue_position": null,
  "estimated_completion_at": null,
  "expires_at": 1795269149,
  "source": "api",
  "metadata": {
    "informan": "I-03"
  },
  "speakers": [
    {
      "id": "A",
      "label": "Pewawancara"
    },
    {
      "id": "B",
      "label": "Narasumber"
    }
  ],
  "text": "Sejak kapan usahanya berjalan? …",
  "segments": [
    {
      "id": 0,
      "start": 2.1,
      "end": 9.8,
      "speaker": "A",
      "text": "Sejak kapan usahanya berjalan?"
    },
    {
      "id": 1,
      "start": 10.4,
      "end": 18.2,
      "speaker": "B",
      "text": "Kurang lebih sejak 2019, Mas."
    }
  ],
  "error": null
}
idstring
trn_ + 32 karakter heksadesimal.
statusstring
queued → processing → completed atau failed.
durationnumber
Durasi rekaman dalam detik.
billed_minutesinteger
Menit yang ditagih: dibulatkan ke atas, minimal 1.
price_idrinteger
Harga pekerjaan ini dalam Rupiah. Pakai angka ini, jangan menghitung sendiri dari tarif.
charged_idr · refundedinteger · boolean
Yang benar-benar dipotong, dan apakah saldo sudah dikembalikan.
progressnumber
Kemajuan 0–1.
queue_position · estimated_completion_atinteger · unix | null
Posisi antrean dan perkiraan waktu selesai selama belum selesai.
expires_atunix
Kapan teks dihapus otomatis (30 hari). Simpan hasilnya di sistem Anda sebelum itu.
sourcestring
web, api, atau api_sync (endpoint kompatibel OpenAI).
metadataobject | null
Dikembalikan apa adanya dari permintaan Anda.
speakersarray
{"id": "A", "label": "…"}. Label mengikuti nama yang diganti di dasbor.
segmentsarray
id, start dan end (detik), speaker (merujuk speakers[].id), text, uncertain (true = bagian yang perlu dicek: keyakinan rendah atau suara yang semula terlewat). Suntingan di web ikut terbaca.
errorobject | null
Terisi bila failed: code transcription_failed dan message.

Bila gagal, saldo kembali otomatis dan objeknya menjadi seperti ini (dipangkas):

failed
{
  "status": "failed",
  "refunded": true,
  "error": {
    "code": "transcription_failed",
    "message": "…"
  }
}

Endpoint lain

POST/v1/transcriptions

Membuat transkrip, jawaban 202. Badan: multipart, badan mentah (opsi di query), atau JSON {"upload_id"}. Header opsional Idempotency-Key.

fileberkas · wajib (multipart)
Rekaman audio atau video.
languageid | en | auto
Bahasa rekaman, bawaan id.
num_speakersinteger 1–6
Jumlah pembicara. Kosong = dideteksi otomatis.
titlestring ≤ 200
Judul yang tampil di dasbor.
webhook_urlhttps URL
Tujuan webhook untuk pekerjaan ini. Kosong = URL bawaan key.
metadatastring JSON objek
≤ 16 kunci, ≤ 8 KB. Dikembalikan apa adanya.
filenamestring (query, badan mentah)
Wajib untuk application/octet-stream.
upload_idstring (JSON)
Hasil unggahan bertahap.

GET/v1/transcriptions

Daftar transkrip terbaru, tanpa teks dan segmen. Query: limit (bawaan 20, maksimal 100), after (kursor trn_…, isi dengan last_id halaman sebelumnya), status (queued, processing, completed, failed).

curl
curl "https://api.transkrip.online/v1/transcriptions?limit=20&status=completed" \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY"
200 OK
{
  "object": "list",
  "data": [
    {
      "id": "trn_0f6c…",
      "object": "transcription",
      "status": "completed"
    }
  ],
  "first_id": "trn_0f6c…",
  "last_id": "trn_9b21…",
  "has_more": true
}

DELETE/v1/transcriptions/{id}

Menghapus teks dan audio sekarang juga. Tagihan tetap tercatat. Transkrip yang masih diproses tidak bisa dihapus (409 job_active).

200 OK
{
  "id": "trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1",
  "object": "transcription",
  "deleted": true
}

POST/v1/transcriptions/estimate

Menghitung biaya dari upload_id tanpa memotong saldo. Berguna sebelum batch.

curl
curl https://api.transkrip.online/v1/transcriptions/estimate \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"upload_id": "upl_8a1f..."}'
200 OK
{
  "upload_id": "upl_8a1f…",
  "duration": 2832.4,
  "billed_minutes": 48,
  "price_idr_per_minute": 50,
  "price_idr": 2400,
  "balance_idr": 5000,
  "enough_balance": true
}

GET/v1/balance

Saldo akun dalam Rupiah. reserved_idr = saldo yang sedang ditahan untuk transkrip yang belum selesai.

200 OK
{
  "object": "balance",
  "currency": "IDR",
  "balance_idr": 10000,
  "reserved_idr": 2400,
  "price_idr_per_minute": 50
}

Ekspor

GET/v1/transcriptions/{id}/export

Berkas jadi, sama persis dengan tombol Unduh di web. Hanya untuk transkrip completed; sebelum itu dijawab 409 not_ready.

  • format=docx: Word dengan tabel Waktu | Pembicara | Teks.
  • format=srt dan format=vtt: subtitle.
  • format=txt: teks polos; tambah waktu=0 tanpa stempel waktu atau waktu=1 dengan stempel waktu.
curl
# Word (.docx), sama dengan tombol Unduh di web
curl -o rapat.docx -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  "https://api.transkrip.online/v1/transcriptions/trn_0f6c.../export?format=docx"

# Subtitle
curl -o rapat.srt -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  "https://api.transkrip.online/v1/transcriptions/trn_0f6c.../export?format=srt"

# Teks polos tanpa stempel waktu
curl -o rapat.txt -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  "https://api.transkrip.online/v1/transcriptions/trn_0f6c.../export?format=txt&waktu=0"

Kompatibel OpenAI

POST/v1/audio/transcriptions

Endpoint sinkron berbentuk sama dengan API transkripsi audio OpenAI. Cocok untuk SDK OpenAI dan alat yang menyediakan isian base URL OpenAI: ganti base URL ke https://api.transkrip.online/v1, isi key trk_live_…, dan pakai model transkrip-1. Batas, biaya, dan saldonya sama dengan API asinkron, dan hasilnya tampil di dasbor (source: api_sync). Endpoint ini tidak mengirim webhook.

fileberkas · wajib
Rekaman audio atau video.
modelstring · wajib
transkrip-1. Nilai lain (termasuk nama model OpenAI) dijawab 400 model_not_found.
languageid | en | auto
Bawaan id.
response_formatstring
json (bawaan), text, srt, vtt, verbose_json, diarized_json.
num_speakersinteger 1–6
Tambahan kami, opsional. Di SDK Python lewat extra_body={"num_speakers": 2}.

Field OpenAI lain (prompt, temperature, timestamp_granularities[], chunking_strategy, include[]) diterima lalu diabaikan. stream=true dijawab 400 streaming_not_supported; language atau response_format di luar daftar di atas dijawab 400 unsupported_language / 400 invalid_response_format.

curl
curl https://api.transkrip.online/v1/audio/transcriptions \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  -F model=transkrip-1 \
  -F [email protected] \
  -F language=id \
  -F response_format=srt

Bentuk jawaban

  • text: teks polos. srt / vtt: subtitle, sama dengan ekspor.
json
{
  "text": "Sejak kapan usahanya berjalan? …",
  "usage": {
    "type": "duration",
    "seconds": 2833
  }
}
verbose_json
{
  "task": "transcribe",
  "language": "indonesian",
  "duration": 2832.4,
  "text": "Sejak kapan usahanya berjalan? …",
  "segments": [
    {
      "id": 0,
      "seek": 0,
      "start": 2.1,
      "end": 9.8,
      "text": "Sejak kapan usahanya berjalan?",
      "tokens": [],
      "temperature": 0,
      "speaker": "A"
    }
  ],
  "usage": {
    "type": "duration",
    "seconds": 2833
  }
}
diarized_json
{
  "task": "transcribe",
  "duration": 2832.4,
  "text": "Sejak kapan usahanya berjalan? …",
  "segments": [
    {
      "type": "transcript.text.segment",
      "id": "seg_0",
      "start": 2.1,
      "end": 9.8,
      "text": "Sejak kapan usahanya berjalan?",
      "speaker": "A"
    }
  ],
  "speakers": [
    {
      "id": "A",
      "label": "Pewawancara"
    }
  ],
  "usage": {
    "type": "duration",
    "seconds": 2833
  }
}

Hal yang perlu diketahui

  • Timeout. Panggilan menunggu sampai transkrip selesai. SDK OpenAI memakai batas waktu bawaan 10 menit; untuk rekaman panjang naikkan dengan OpenAI(timeout=…) (detik) atau new OpenAI({ timeout }) (milidetik), atau pakai API asinkron.
  • Keepalive. Agar koneksi tidak diputus perantara, status 200 bisa dikirim lebih dulu lalu diisi spasi (JSON) atau baris baru (text/srt/vtt) sampai hasil siap. Parser JSON dan SDK tidak terpengaruh; untuk text/srt/vtt lakukan trim(). Akibatnya, kegagalan setelah header terkirim dilaporkan di badan {"error": …} dengan status 200, jadi selalu periksa field error. Saldo tetap kembali otomatis.
  • Id pekerjaan. Header jawaban Transkrip-Transcription-Id: trn_… bisa dipakai untuk GET /v1/transcriptions/{id} bila koneksi putus.
  • Aman dicoba ulang. Mengirim ulang berkas yang identik (akun sama, dalam 1 jam, language dan num_speakers sama) saat pekerjaan sebelumnya masih berjalan atau sudah selesai akan menunggu atau mengembalikan pekerjaan itu, tanpa tagihan baru. Coba ulang otomatis bawaan SDK aman.
  • Koneksi putus. Pekerjaan tetap berjalan dan ditagih. Hasilnya ada di dasbor dan di GET /v1/transcriptions.
  • Galat sebelum keepalive dimulai dikirim dengan status HTTP biasa: 422 audio_unprocessable, 503 engine_unavailable, dan 504 still_processing (menunggu lebih dari 3 jam; ambil hasil lewat GET dengan id dari header).

Daftar kode galat

Kendalikan alur program dengan error.code; message berbahasa Indonesia dan bisa berubah. Untuk 429, tunggu sesuai header Retry-After (detik) sebelum mencoba lagi.

409 Conflict
{
  "error": {
    "message": "Transkrip belum selesai.",
    "type": "invalid_request_error",
    "code": "not_ready",
    "param": null
  }
}
HTTPcodeArti
400validation_failedParameter tidak sah. Lihat field param dan message.
400missing_fileField file tidak ada di badan multipart.
400invalid_multipartBadan multipart rusak atau terpotong.
400invalid_metadatametadata bukan objek JSON, lebih dari 16 kunci, atau lebih dari 8 KB.
400invalid_idempotency_keyIdempotency-Key kosong atau lebih dari 255 karakter.
400invalid_cursorNilai after tidak dikenal.
400missing_filenameBadan application/octet-stream tanpa query filename.
400invalid_webhook_urlwebhook_url bukan https atau bukan alamat publik.
400model_not_foundmodel selain transkrip-1 di endpoint kompatibel OpenAI.
400streaming_not_supportedstream=true tidak didukung.
400unsupported_languagelanguage selain id, en, atau auto (endpoint kompatibel OpenAI).
400invalid_response_formatresponse_format tidak dikenal (endpoint kompatibel OpenAI).
400upload_interruptedKoneksi putus saat berkas dikirim. Sisa berkas dihapus, tidak ada tagihan; kirim ulang.
401invalid_api_keyKey tidak dikirim, salah, atau sudah dicabut.
402insufficient_balanceSaldo kurang untuk harga pekerjaan ini. Isi saldo, lalu kirim lagi.
402key_limit_exceededBatas bulanan (Rupiah) key ini tercapai. Naikkan di dasbor.
403brand_forbiddenKey ini bukan key untuk API ini.
403insufficient_scopeKey tidak punya izin untuk endpoint ini.
404not_foundTranskrip atau unggahan tidak ada (atau milik akun lain).
409upload_not_readyUnggahan belum di-complete, gagal diperiksa, atau sudah dipakai.
409upload_incompletecomplete dipanggil sebelum semua bait terkirim.
409offset_mismatchoffset potongan tidak sama dengan received_bytes. Pesan menyebut offset yang benar.
409not_readyTranskrip belum selesai (mis. saat ekspor).
409job_activeTranskrip masih diproses; hapus setelah selesai.
409billing_pendingTagihan sedang diselesaikan. Coba lagi sebentar.
413file_too_largeBerkas melebihi batas ukuran per kiriman.
413chunk_too_largePotongan unggahan lebih dari 16 MB.
415unsupported_media_typeContent-Type badan tidak didukung.
415bad_chunkPotongan kosong atau bukan application/octet-stream.
422unsupported_formatFormat berkas tidak didukung.
422not_audioBerkas tidak berisi suara yang bisa dibaca.
422too_longDurasi rekaman melebihi batas.
422audio_unprocessableAudio tidak bisa diproses (endpoint sinkron). Saldo kembali otomatis.
429too_many_jobsSudah 5 transkrip aktif. Tunggu sesuai Retry-After.
429too_many_uploadsMasih ada unggahan bertahap yang belum dipakai.
429rate_limit_exceededLebih dari 60 permintaan/menit per key. Tunggu sesuai Retry-After.
503storage_fullSementara belum bisa menerima berkas. Coba lagi beberapa saat lagi.
503engine_unavailablePemrosesan sementara tidak tersedia. Coba lagi beberapa saat lagi.
504still_processingEndpoint sinkron menunggu lebih dari 3 jam. Ambil hasil lewat GET.

Batas

  • Ukuran berkas: 90 MB per kiriman (multipart, badan mentah, atau kompatibel OpenAI); sampai 2 GB lewat unggahan bertahap.
  • Durasi rekaman: maksimal 2 jam.
  • 5 transkrip aktif bersamaan per akun, dihitung bersama unggahan dari web.
  • 60 permintaan per menit per key.
  • 10 key aktif per akun.
  • metadata: maksimal 16 kunci dan 8 KB.
  • Potongan unggahan bertahap: maksimal 16 MB.
  • Masa simpan: teks 30 hari, audio 24 jam. Unggahan bertahap yang tidak dipakai kedaluwarsa 24 jam.

Harga

Rp50 per menit audio (Rp3.000 per jam), sama dengan web. Dibulatkan ke atas per menit, minimal 1 menit, berapa pun jumlah pembicara dan apa pun bahasanya. Tidak ada biaya langganan; saldo diisi lewat QRIS di dasbor dan tidak hangus.

DurasiBiaya
1 menitRp50
30 menitRp1.500
1 jamRp3.000
2 jamRp6.000

Tarif Rp50/menit, dibulatkan ke atas per menit. Isi saldo Rp5.000 cukup untuk sekitar 1 jam 40 menit rekaman.

  • Satu saldo untuk web dan API. Saldo ditahan saat transkrip dipesan, dipotong saat selesai, dan kembali utuh bila gagal.
  • Batas pemakaian bulanan per key bisa diatur dalam Rupiah di dasbor. Bila tercapai, permintaan dijawab 402 key_limit_exceeded.
  • Cek biaya sebelum memesan lewat POST /v1/uploads/{id}/complete atau POST /v1/transcriptions/estimate, dan saldo lewat GET /v1/balance.

Idempotensi

Kirim header Idempotency-Key (1–255 karakter) pada POST /v1/transcriptions. Permintaan ulang dengan kunci yang sama mengembalikan objek transkrip yang sama, tanpa tagihan kedua, dengan header jawaban Idempotency-Replayed: true. Kunci berlaku per akun.

curl
curl https://api.transkrip.online/v1/transcriptions \
  -H "Authorization: Bearer $TRANSKRIP_API_KEY" \
  -H "Idempotency-Key: rapat-2026-10-21-ruang-3" \
  -F [email protected]
  • Pakai satu kunci per rekaman, misalnya UUID yang Anda simpan bersama data rekaman, atau upload-upl_… untuk unggahan bertahap.
  • Dengan kunci ini, timeout atau galat jaringan saat mengirim aman diulang: paling banyak satu transkrip yang dibuat dan ditagih.
  • Endpoint kompatibel OpenAI tidak memerlukan header ini; pengamannya berupa pencocokan berkas identik (lihat Kompatibel OpenAI).
  • Webhook bisa tiba lebih dari sekali; pakai id peristiwa untuk menyaring duplikat.

Satu key, satu saldo, tarif yang sama dengan web.

Buat API key di dasbor, isi saldo lewat QRIS, dan kirim rekaman pertama Anda dari curl, Python, atau Node.

Buat API key

Rp3.000 per jam audio · saldo tidak hangus