# Webhook

> Nhận thông báo khi job hoàn tất hoặc thất bại. Kiểm tra chữ ký webhook để xác nhận request đến từ transcript1.

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

Muốn nhận thông báo thay vì liên tục kiểm tra trạng thái, thêm `webhook_url` lúc tạo job. Khi job hoàn tất hoặc thất bại, transcript1 gửi đến đó một request `POST` có chữ ký.

Bạn chỉ dùng được webhook qua REST API. Các tool của [MCP server](https://transcript1.com/vi/docs/mcp) không nhận tham số này.

## Khai báo URL nhận webhook

Khi gọi `POST /v1/transcriptions` bằng link video hoặc tạo phiên tải lên theo chunk, đặt `webhook_url` trong body JSON. Nếu gửi file bằng một request, đặt trong trường form. Ví dụ với link video:

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

Chọn URL HTTPS không kèm tên đăng nhập hoặc mật khẩu, phân giải ra địa chỉ IP công khai. Địa chỉ private, loopback, link-local và các địa chỉ không công khai khác sẽ nhận `422 invalid_request`. transcript1 kiểm tra địa chỉ lúc tạo job và trước mỗi lần gửi webhook.

Không gửi `webhook_url` cùng `reuse_recent=true`.

## Khi nào nhận được webhook?

| `type` | Điều kiện gửi |
|---|---|
| `transcription.completed` | Job chuyển sang `completed`. |
| `transcription.failed` | Job chuyển sang `failed`. Đọc `code` và `message` trong `data.error` để biết lỗi. |

Nếu job bị hủy, transcript1 không gửi webhook.

## Nội dung request

Bạn nhận body JSON theo dạng sau:

```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
  }
}
```

Dùng `created_at` để biết thời điểm tạo sự kiện. `data` chứa object transcription nhưng không kèm `transcript`; ví dụ trên đã lược bớt trường. Lấy `id` từ `data`, rồi gọi `GET /v1/transcriptions/:id` để đọc transcript.

Các header gồm `Content-Type: application/json`, `User-Agent: transcript1-webhooks/1.0` và header chữ ký ở phần dưới.

## Kiểm tra chữ ký webhook

Đọc chữ ký từ header `X-Transcript1-Signature` của mỗi request:

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

- `t` ghi thời điểm ký request theo Unix time, tính bằng giây.
- Để tính `v1`, ghép giá trị `t`, dấu chấm và body nguyên gốc thành chuỗi `"<t>.<raw body>"`. Tính HMAC-SHA256 bằng toàn bộ secret ký webhook, giữ cả tiền tố `whsec_`, rồi chuyển digest sang hex.

Kiểm tra chữ ký trên đúng các byte body nhận được, trước khi parse JSON. Nếu parse rồi serialize lại, khoảng trắng hay thứ tự key có thể đổi và chữ ký sẽ không khớp. So sánh bằng hàm constant-time. Đồng thời kiểm tra `t` để từ chối request quá lệch so với giờ hiện tại; các ví dụ dưới đây chấp nhận độ lệch tối đa 5 phút.

### 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));
};
```

Nếu dùng Express, đọc body dưới dạng raw text, không parse JSON trước bước kiểm tra:

```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)
```

Nếu dùng Flask, lấy body bằng `request.get_data(as_text=True)` và truyền vào `raw_body`.

## Secret dùng để ký webhook

Mỗi team dùng chung một secret có tiền tố `whsec_` để ký webhook cho mọi job của team. Bạn có thể xem hoặc đổi secret ở [“Webhooks”](https://transcript1.com/dashboard/webhooks), đường dẫn `/dashboard/webhooks`. Secret mới có hiệu lực ngay khi đổi. Mọi webhook gửi từ thời điểm đó đều dùng secret mới.

## Gửi lại và xử lý trùng lặp

- Trả mã `2xx` để xác nhận đã nhận webhook.
- Mã khác, lỗi kết nối hoặc không phản hồi trong 10 giây đều tính là gửi thất bại.
- Nếu gửi thất bại, transcript1 thử lại với thời gian chờ tăng theo cấp số nhân, tối đa 6 lần tính cả lần đầu. Các khoảng chờ là 2, 4, 8, 16 và 32 phút; lần cuối cách lần đầu khoảng một giờ.
- Cung cấp URL đích cuối cùng. transcript1 không theo redirect, nên response `3xx` vẫn tính là thất bại.
- Cùng sự kiện có thể đến nhiều lần, chẳng hạn khi server trả lời sau timeout. Dùng cặp `data.id` và `type` để nhận ra và bỏ qua bản trùng.
