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.
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 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"{
"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 https://api.transkrip.online/v1/transcriptions/trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1 \
-H "Authorization: Bearer $TRANSKRIP_API_KEY"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.
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.
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 — 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.
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/*, atauapplication/octet-stream. Opsi dikirim lewat query string. Untukapplication/octet-stream, queryfilenamewajib diisi; untukaudio/*danvideo/*boleh kosong.
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.
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())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.
POST /v1/uploadsdengan{"filename", "bytes"}→201, berisiid(upl_…) danpart_bytes(16 MB).PUT /v1/uploads/{id}/parts?offset=…dengan badan binerapplication/octet-stream, maksimal 16 MB per potongan. Potongan yang sudah diterima dijawab sukses tanpa ditulis ulang. Offset yang salah dijawab409 offset_mismatch, dan pesannya menyebut offset yang benar.GET /v1/uploads/{id}untuk melihatstatusdanreceived_bytessaat melanjutkan.POST /v1/uploads/{id}/complete: durasi diukur dan harga dihitung. Jawabannya objek unggahan ditambahduration,billed_minutes,price_idr,balance_idr, danenough_balance. Saldo belum dipotong.POST /v1/transcriptionsdengan JSON{"upload_id": "upl_…"}dan opsi yang sama seperti multipart. Unggahan yang tidak dipakai kedaluwarsa setelah 24 jam.
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 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 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.completeddantranscription.failed, satu per jenis per transkrip.webhook.testdari tombol Kirim uji di dasbor, dengandata.idbernilainulldandata.statustest.
Payload sengaja ringkas. Anggap sebagai petunjuk, lalu ambil hasilnya dengan GET.
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…{
"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
idperistiwa (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
tlebih 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.
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 "", 200import 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.
{
"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
}idstringtrn_+ 32 karakter heksadesimal.statusstringqueued→processing→completedataufailed.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.
sourcestringweb,api, atauapi_sync(endpoint kompatibel OpenAI).metadataobject | null- Dikembalikan apa adanya dari permintaan Anda.
speakersarray{"id": "A", "label": "…"}. Label mengikuti nama yang diganti di dasbor.segmentsarrayid,startdanend(detik),speaker(merujukspeakers[].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:codetranscription_faileddanmessage.
Bila gagal, saldo kembali otomatis dan objeknya menjadi seperti ini (dipangkas):
{
"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 "https://api.transkrip.online/v1/transcriptions?limit=20&status=completed" \
-H "Authorization: Bearer $TRANSKRIP_API_KEY"{
"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).
{
"id": "trn_0f6c2b9e4d1a47c8b3e5a6f7d8c9b0a1",
"object": "transcription",
"deleted": true
}POST/v1/transcriptions/estimate
Menghitung biaya dari upload_id tanpa memotong saldo. Berguna sebelum batch.
curl https://api.transkrip.online/v1/transcriptions/estimate \
-H "Authorization: Bearer $TRANSKRIP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"upload_id": "upl_8a1f..."}'{
"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.
{
"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=srtdanformat=vtt: subtitle.format=txt: teks polos; tambahwaktu=0tanpa stempel waktu atauwaktu=1dengan stempel waktu.
# 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 · wajibtranskrip-1. Nilai lain (termasuk nama model OpenAI) dijawab400 model_not_found.languageid | en | auto- Bawaan
id. response_formatstringjson(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 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=srtBentuk jawaban
text: teks polos.srt/vtt: subtitle, sama dengan ekspor.
{
"text": "Sejak kapan usahanya berjalan? …",
"usage": {
"type": "duration",
"seconds": 2833
}
}{
"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
}
}{
"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) ataunew 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 fielderror. Saldo tetap kembali otomatis. - Id pekerjaan. Header jawaban
Transkrip-Transcription-Id: trn_…bisa dipakai untukGET /v1/transcriptions/{id}bila koneksi putus. - Aman dicoba ulang. Mengirim ulang berkas yang identik (akun sama, dalam 1 jam,
languagedannum_speakerssama) 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, dan504 still_processing(menunggu lebih dari 3 jam; ambil hasil lewatGETdengan 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.
{
"error": {
"message": "Transkrip belum selesai.",
"type": "invalid_request_error",
"code": "not_ready",
"param": null
}
}| HTTP | code | Arti |
|---|---|---|
| 400 | validation_failed | Parameter tidak sah. Lihat field param dan message. |
| 400 | missing_file | Field file tidak ada di badan multipart. |
| 400 | invalid_multipart | Badan multipart rusak atau terpotong. |
| 400 | invalid_metadata | metadata bukan objek JSON, lebih dari 16 kunci, atau lebih dari 8 KB. |
| 400 | invalid_idempotency_key | Idempotency-Key kosong atau lebih dari 255 karakter. |
| 400 | invalid_cursor | Nilai after tidak dikenal. |
| 400 | missing_filename | Badan application/octet-stream tanpa query filename. |
| 400 | invalid_webhook_url | webhook_url bukan https atau bukan alamat publik. |
| 400 | model_not_found | model selain transkrip-1 di endpoint kompatibel OpenAI. |
| 400 | streaming_not_supported | stream=true tidak didukung. |
| 400 | unsupported_language | language selain id, en, atau auto (endpoint kompatibel OpenAI). |
| 400 | invalid_response_format | response_format tidak dikenal (endpoint kompatibel OpenAI). |
| 400 | upload_interrupted | Koneksi putus saat berkas dikirim. Sisa berkas dihapus, tidak ada tagihan; kirim ulang. |
| 401 | invalid_api_key | Key tidak dikirim, salah, atau sudah dicabut. |
| 402 | insufficient_balance | Saldo kurang untuk harga pekerjaan ini. Isi saldo, lalu kirim lagi. |
| 402 | key_limit_exceeded | Batas bulanan (Rupiah) key ini tercapai. Naikkan di dasbor. |
| 403 | brand_forbidden | Key ini bukan key untuk API ini. |
| 403 | insufficient_scope | Key tidak punya izin untuk endpoint ini. |
| 404 | not_found | Transkrip atau unggahan tidak ada (atau milik akun lain). |
| 409 | upload_not_ready | Unggahan belum di-complete, gagal diperiksa, atau sudah dipakai. |
| 409 | upload_incomplete | complete dipanggil sebelum semua bait terkirim. |
| 409 | offset_mismatch | offset potongan tidak sama dengan received_bytes. Pesan menyebut offset yang benar. |
| 409 | not_ready | Transkrip belum selesai (mis. saat ekspor). |
| 409 | job_active | Transkrip masih diproses; hapus setelah selesai. |
| 409 | billing_pending | Tagihan sedang diselesaikan. Coba lagi sebentar. |
| 413 | file_too_large | Berkas melebihi batas ukuran per kiriman. |
| 413 | chunk_too_large | Potongan unggahan lebih dari 16 MB. |
| 415 | unsupported_media_type | Content-Type badan tidak didukung. |
| 415 | bad_chunk | Potongan kosong atau bukan application/octet-stream. |
| 422 | unsupported_format | Format berkas tidak didukung. |
| 422 | not_audio | Berkas tidak berisi suara yang bisa dibaca. |
| 422 | too_long | Durasi rekaman melebihi batas. |
| 422 | audio_unprocessable | Audio tidak bisa diproses (endpoint sinkron). Saldo kembali otomatis. |
| 429 | too_many_jobs | Sudah 5 transkrip aktif. Tunggu sesuai Retry-After. |
| 429 | too_many_uploads | Masih ada unggahan bertahap yang belum dipakai. |
| 429 | rate_limit_exceeded | Lebih dari 60 permintaan/menit per key. Tunggu sesuai Retry-After. |
| 503 | storage_full | Sementara belum bisa menerima berkas. Coba lagi beberapa saat lagi. |
| 503 | engine_unavailable | Pemrosesan sementara tidak tersedia. Coba lagi beberapa saat lagi. |
| 504 | still_processing | Endpoint 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.
| Durasi | Biaya |
|---|---|
| 1 menit | Rp50 |
| 30 menit | Rp1.500 |
| 1 jam | Rp3.000 |
| 2 jam | Rp6.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}/completeatauPOST /v1/transcriptions/estimate, dan saldo lewatGET /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 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
idperistiwa 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.
Rp3.000 per jam audio · saldo tidak hangus