# 웹훅 연동

> 변환 작업이 완료되거나 실패하면 웹훅으로 알림을 받으세요. 요청에 담긴 서명을 검증하는 방법도 안내해요.

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

변환 작업을 생성할 때 `webhook_url`을 지정하세요. 작업이 완료되거나 실패하면 해당 주소로 서명된 `POST` 요청을 보내 드려요.

웹훅은 REST API 전용 기능이에요. [MCP 서버](https://transcript1.com/ko/docs/mcp)에서는 웹훅 파라미터를 지원하지 않아요.

## 알림을 받을 URL 지정하기

영상 링크를 보내거나 청크 업로드를 예약할 때는 `POST /v1/transcriptions`의 JSON 본문에 `webhook_url`을 넣으세요. 파일을 요청 한 번으로 올릴 때는 폼 필드로 보내면 돼요.

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

HTTPS URL을 사용하고 URL에 사용자 이름이나 비밀번호를 넣지 마세요. 도메인을 조회했을 때 공개 IP 주소로 연결돼야 해요. 사설, 루프백, 링크 로컬 등 비공개 주소를 가리키면 `422 invalid_request`를 반환해요. 작업을 생성할 때뿐 아니라 웹훅을 보낼 때마다 주소를 다시 검사해요.

`reuse_recent=true`를 사용할 때는 `webhook_url`을 지정할 수 없어요.

## 알림이 오는 시점

| `type` | 발생 조건 |
|---|---|
| `transcription.completed` | 작업이 `completed` 상태로 끝났을 때 |
| `transcription.failed` | 작업이 `failed` 상태로 끝났을 때. 원인은 `data.error`의 `code`, `message`에서 확인해요. |

작업을 취소한 경우에는 웹훅을 보내지 않아요.

## 웹훅에 담기는 데이터

웹훅 본문은 다음과 같은 JSON이에요.

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

이벤트가 발생한 시각은 `created_at`에 있어요. `data`에는 작업 객체가 들어가지만 `transcript` 본문은 제외해요. 위 예제는 일부 필드만 보여 줘요. 스크립트는 `data`에 있는 `id`로 `GET /v1/transcriptions/:id`를 호출해 가져오세요.

요청 헤더에는 `Content-Type: application/json`과 `User-Agent: transcript1-webhooks/1.0`이 들어가요. 서명 검증에 쓰는 헤더도 함께 보내요.

## 요청 서명 검증하기

웹훅을 보낼 때마다 `X-Transcript1-Signature` 헤더를 넣어요.

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

- `t`: 요청에 서명한 시각을 Unix 시간의 초 단위로 나타내요.
- `v1`: `"<t>.<raw body>"`를 HMAC-SHA256으로 서명한 16진수 값이에요. `t` 값 뒤에 점을 붙이고, 그 뒤에 받은 요청 본문을 그대로 이어 붙여 계산해요. 키에는 `whsec_` 접두어까지 포함한 서명 시크릿 전체를 사용하세요.

서명은 JSON을 파싱하기 전에 받은 본문 바이트 그대로 검증하세요. 파싱 후 다시 JSON으로 만들면 공백이나 키 순서가 달라져 검증에 실패할 수 있어요. 서명값은 상수 시간으로 비교하세요. `t`가 현재 시각에서 너무 벗어난 요청도 거부해야 해요. 아래 예제에서는 5분 이내만 허용해요.

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

Express에서는 JSON 파서보다 먼저 원문 텍스트를 읽도록 설정하세요.

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

Flask를 쓴다면 `request.get_data(as_text=True)`로 읽은 값을 `raw_body`에 넘기세요.

## 서명 시크릿 확인과 교체

팀마다 `whsec_`로 시작하는 서명 시크릿이 있어요. 해당 팀에서 만든 모든 변환 작업의 웹훅에 같은 시크릿을 사용해요. `/dashboard/webhooks`의 [“Webhooks” 페이지](https://transcript1.com/dashboard/webhooks)에서 확인하거나 교체하세요. 교체는 즉시 적용되며, 이후 전송하는 웹훅부터 새 시크릿으로 서명해요.

## 전송과 재시도

- 서버가 `2xx`로 응답하면 전송 성공으로 처리해요.
- 다른 상태 코드, 연결 오류, 10초 내 응답 없음은 전송 실패로 처리해요.
- 실패하면 대기 시간을 늘려 다시 보내요. 첫 전송을 포함해 총 6번까지 시도하며, 각 시도 사이는 2, 4, 8, 16, 32분이에요. 마지막 시도는 첫 전송에서 약 한 시간 뒤예요.
- 리다이렉트는 따라가지 않아요. `3xx`도 전송 실패이므로 최종 도착 URL을 직접 지정하세요.
- 서버가 제한 시간 이후에 응답하는 경우처럼 같은 알림을 여러 번 받을 수 있어요. `data.id`와 `type`을 함께 확인해 중복 처리를 막으세요.
