# Notifikasi webhook

> Terima notifikasi saat transkripsi selesai atau gagal. Verifikasi tanda tangannya sebelum memproses webhook.

Canonical: https://transcript1.com/id/docs/webhooks
Language: id
Last updated: 2026-10-14

Tambahkan `webhook_url` saat membuat transkripsi. Saat proses selesai atau gagal, transcript1 mengirim notifikasi ke URL tersebut lewat `POST` yang dilengkapi tanda tangan.

Gunakan REST API untuk webhook. Tool pada [server MCP](https://transcript1.com/id/docs/mcp) tidak menerima parameter ini.

## Tentukan alamat penerima

Saat mengirim link video yang didukung atau menyiapkan unggahan bertahap lewat `POST /v1/transcriptions`, sertakan `webhook_url` pada body JSON. Jika mengunggah file dalam satu request, isi sebagai field form:

```json
{
  "platform": "youtube",
  "url": "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
  "webhook_url": "https://example.com/hooks/transcript1"
}
```

Pakai URL HTTPS tanpa nama pengguna atau kata sandi. Hasil resolusi URL harus berupa alamat publik. Alamat privat, loopback, link-local, dan alamat nonpublik lain mendapat `422 invalid_request`. transcript1 memeriksanya saat transkripsi dibuat dan sebelum setiap pengiriman webhook.

Jangan gunakan `webhook_url` bersama `reuse_recent=true`; keduanya tidak bisa digabungkan.

## Kapan webhook dikirim

| `type` | Pemicu |
|---|---|
| `transcription.completed` | Status transkripsi berubah menjadi `completed`. |
| `transcription.failed` | Status transkripsi berubah menjadi `failed`. Baca `code` dan `message` di `data.error`. |

Jika transkripsi dibatalkan, tidak ada webhook yang dikirim.

## Isi notifikasi

Webhook membawa body JSON berikut:

```json
{
  "type": "transcription.completed",
  "created_at": "2026-10-13T09:30:04.000Z",
  "data": {
    "id": "tr_8fK2Lm0PqRsTuVwXyZaBcD",
    "object": "transcription",
    "status": "completed",
    "transcript_source": "youtube_captions",
    "billed_minutes": 4,
    "error": null
  }
}
```

Field `created_at` mencatat kapan event dibuat. Blok `data` memuat objek transkripsi, tetapi tanpa `transcript`; contoh di atas disingkat. Ambil teksnya lewat `GET /v1/transcriptions/:id` menggunakan `id` dari `data`.

Setiap request memakai `Content-Type: application/json` dan `User-Agent` bernilai `transcript1-webhooks/1.0`. Ada juga header tanda tangan untuk verifikasi berikut.

## Periksa tanda tangan sebelum membaca JSON

Baca tanda tangan dari header `X-Transcript1-Signature` pada setiap pengiriman:

```text
X-Transcript1-Signature: t=<unix seconds>,v1=<hex digest>
```

- `t` mencatat waktu penandatanganan request sebagai Unix time dalam detik.
- `v1` berisi HMAC-SHA256 dalam format heksadesimal. Hitung dari string `"<t>.<raw body>"`: gabungkan nilai `t`, titik, lalu body request persis seperti yang diterima. Gunakan signing secret lengkap sebagai kunci, termasuk awalan `whsec_`.

Verifikasi byte body asli sebelum melakukan parse JSON. Parse lalu serialisasi ulang bisa mengubah spasi atau urutan key, sehingga tanda tangan tidak lagi cocok. Bandingkan tanda tangan dengan metode constant-time. Tolak request jika nilai `t` terlalu jauh dari waktu sekarang; contoh berikut memakai toleransi 5 menit.

### Node.js

```javascript
import { Buffer } from 'node:buffer';
import crypto from 'node:crypto';

// header: X-Transcript1-Signature, rawBody: the request body exactly as received,
// secret: your full signing secret including the whsec_ prefix.
export const verifySignature = (secret, header, rawBody, nowSeconds = Math.floor(Date.now() / 1000)) => {
  const fields = Object.fromEntries(header.split(',').map(part => part.trim().split('=')));
  const t = Number(fields.t);

  if (!Number.isInteger(t) || !fields.v1 || Math.abs(nowSeconds - t) > 300) {
    return false;
  }

  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');

  return fields.v1.length === expected.length
    && crypto.timingSafeEqual(Buffer.from(fields.v1), Buffer.from(expected));
};
```

Di Express, baca body sebagai teks mentah agar JSON belum diparse saat verifikasi:

```javascript
import process from 'node:process';
import express from 'express';
import { verifySignature } from './verify-signature.js';

const app = express();

app.post('/hooks/transcript1', express.text({ type: 'application/json' }), (request, response) => {
  const header = request.get('X-Transcript1-Signature') ?? '';

  if (!verifySignature(process.env.TRANSCRIPT1_WEBHOOK_SECRET, header, request.body)) {
    response.sendStatus(400);
    return;
  }

  const { type, data } = JSON.parse(request.body);
  console.log(type, data.id, data.status);
  response.sendStatus(200);
});
```

### Python

```python
import hashlib
import hmac
import time


def verify_signature(secret, header, raw_body, now=None):
    # header: the X-Transcript1-Signature header, raw_body: the request body exactly as received,
    # secret: your full signing secret including the whsec_ prefix.
    fields = dict(part.strip().split("=", 1) for part in header.split(",") if "=" in part)
    try:
        t = int(fields["t"])
    except (KeyError, ValueError):
        return False

    now = int(time.time()) if now is None else now
    if abs(now - t) > 300:
        return False

    expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(fields.get("v1", ""), expected)
```

Di Flask, ambil body melalui `request.get_data(as_text=True)` dan teruskan sebagai `raw_body`.

## Kelola signing secret tim

Signing secret berawalan `whsec_` berlaku untuk seluruh tim. Semua webhook dari transkripsi tim memakai secret yang sama. Untuk melihat atau menggantinya, buka halaman [“Webhooks”](https://transcript1.com/dashboard/webhooks) di `/dashboard/webhooks`. Penggantian berlaku saat itu juga. Semua webhook yang dikirim setelahnya memakai secret baru.

## Respons, retry, dan notifikasi ganda

- Balas dengan status `2xx` agar pengiriman dianggap berhasil.
- Kode status lain, error koneksi, atau tidak ada respons dalam 10 detik dihitung sebagai percobaan gagal.
- Jika gagal, transcript1 mengirim ulang dengan jeda yang makin panjang (exponential backoff), maksimal 6 percobaan termasuk pengiriman pertama. Jedanya 2, 4, 8, 16, dan 32 menit. Percobaan terakhir berlangsung sekitar satu jam setelah yang pertama.
- Berikan URL tujuan akhir. transcript1 tidak mengikuti redirect; respons `3xx` dihitung gagal.
- Notifikasi yang sama bisa masuk lebih dari sekali, misalnya karena server Anda baru merespons setelah timeout. Periksa kombinasi `data.id` dan `type` untuk mengabaikan duplikat.
