Webhook
이슈 이벤트를 외부 URL 로 전송합니다. Slack · GitHub 외 커스텀 연동에 사용합니다.
QA Note 가 기본 제공하는 Slack · GitHub · Vercel 통합 외의 외부 시스템과 연동할 때 사용합니다. 이슈가 생성·수정·코멘트될 때마다 지정한 URL 로 JSON payload 가 전송됩니다. Linear · Jira · 자체 Discord 봇 · Notion DB 등 어디든 받을 수 있는 쪽에서 처리하면 됩니다.
사용 사례
| 시스템 | 용도 |
|---|---|
| Linear | QA Note 이슈를 Linear 이슈로 자동 미러링 |
| Jira | 우선순위 critical 이슈만 Jira 백로그에 자동 등록 |
| Discord | 디자이너 채널에 디자인 라벨 이슈만 알림 |
| Notion | 이슈 메타데이터를 Notion DB 행으로 추가 |
| 자체 알림 시스템 | 사내 Slackbot · Teams · 메일 · SMS 게이트웨이 |
이미 직접 연동(Slack · GitHub) 이 있는 경우 webhook 을 중복으로 켜둘 필요는 없습니다. webhook 은 항상 직접 연동이 커버하지 못하는 경로용 폴백입니다.
설정 방법
대시보드에서 다음 경로로 진입합니다.
프로젝트 설정 → 연동 → Webhook → URL · Secret 입력
경로: /settings/projects/[projectId]/integrations/webhook
| 입력 | 설명 |
|---|---|
| URL | POST 요청을 받을 외부 엔드포인트 (HTTPS 필수) |
| Secret | HMAC-SHA256 서명 검증용 임의 문자열 (32자 이상 권장) |
| 구독 이벤트 | issue.* 중 받을 이벤트 선택 |
| 활성/비활성 | 임시 중단 토글 |
URL 은 HTTPS 만 허용됩니다. localhost / 사설 IP 도 거부됩니다 (개발 중에는 cloudflared 등 터널을 통해 공개 URL 로 노출).
이벤트 종류
| 이벤트 | 발생 조건 |
|---|---|
issue.created | 새 이슈가 생성됨 (Extension · Widget · 대시보드 어디서든) |
issue.updated | 제목 · 본문 · 우선순위 등이 변경됨 |
issue.status_changed | 상태가 변경됨 (open → in_progress 등) |
issue.commented | 이슈에 코멘트가 추가됨 |
issue.assigned | 담당자가 변경됨 |
issue.label_added · issue.label_removed | 라벨이 추가/제거됨 |
issue.merged | 연결된 PR 이 머지되어 이슈가 자동 클로즈됨 (AI Fix · GitHub 통합) |
issue.deleted | 이슈가 삭제됨 (휴지통 행 · 영구 삭제 모두 포함) |
여러 이벤트를 동시에 구독할 수 있고, 한 프로젝트에 여러 webhook 을 등록할 수 있습니다 (현재 최대 5 개).
페이로드 스키마
POST body 는 다음 형태의 JSON 입니다.
{
"id": "evt_01H...",
"type": "issue.created",
"project": { "id": "proj_xyz", "slug": "acme-app" },
"issue": {
"id": "iss_abc",
"number": 42,
"title": "로그인 에러",
"status": "open",
"priority": "high",
"url": "https://qanote.app/i/abc-42"
},
"actor": { "id": "usr_123", "name": "홍길동", "email": "hong@example.com" },
"timestamp": "2026-05-01T09:30:12.000Z"
}
| 필드 | 타입 | 설명 |
|---|---|---|
id | string | 이벤트 ID (재시도 시에도 동일하게 유지 — idempotency key 로 사용 가능) |
type | string | 이벤트 종류 (위 §이벤트 종류 표) |
project | object | 이슈가 속한 프로젝트 |
issue | object | 이슈 핵심 필드 (전체 메타데이터는 url 로 추가 조회) |
actor | object | null | 이벤트를 발생시킨 사용자. 시스템 자동 변경(예: AI Fix 의 자동 클로즈) 시 null |
timestamp | ISO 8601 | 이벤트 발생 시각 (UTC) |
이슈의 풍부한 메타데이터(콘솔 로그 · 스크린샷 등)는 webhook payload 에 포함되지 않습니다. 필요한 경우 issue.url 로 이슈를 다시 조회하거나 MCP resolve_by_url 을 사용합니다.
서명 검증 (HMAC)
webhook 요청에는 다음 헤더가 포함됩니다.
| 헤더 | 값 |
|---|---|
Content-Type | application/json |
User-Agent | QANote-Webhook/1.0 |
X-Qanote-Event | issue.created 등 이벤트 종류 |
X-Qanote-Delivery | 이벤트 ID (evt_01H...) |
X-Qanote-Signature | sha256=<HMAC-SHA256(secret, rawBody)> |
받는 쪽은 raw body 와 secret 으로 직접 HMAC 을 계산해 헤더 값과 timing-safe 비교합니다.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, signatureHeader: string, secret: string): boolean {
// 헤더 형식: "sha256=<hex>"
const [scheme, signature] = signatureHeader.split("=");
if (scheme !== "sha256" || !signature) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
// 길이가 다르면 즉시 false (timingSafeEqual 은 길이 다르면 throw)
if (expected.length !== signature.length) return false;
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
JSON.parse 한 결과가 아니라 raw body 문자열 로 HMAC 을 계산해야 합니다. JSON.stringify 로 재직렬화하면 키 순서·공백 차이로 서명이 어긋납니다.
재시도 정책
수신 측이 200~299 가 아닌 응답을 반환하거나 타임아웃(10초) 되면 재시도합니다.
| 시도 | 대기 시간 |
|---|---|
| 1 차 | 즉시 |
| 2 차 | 30 초 후 |
| 3 차 | 5 분 후 |
| 4 차 | 30 분 후 |
| 5 차 (최종) | 4 시간 후 |
5 차까지 모두 실패하면 이벤트는 폐기되고, 프로젝트 대시보드 → Webhook 설정 → "최근 실패" 목록에 빨간 마커로 누적됩니다. 같은 webhook 이 24 시간 내 50 회 이상 연속 실패하면 자동으로 비활성화되고 알림이 발송됩니다.
재시도 시에도 X-Qanote-Delivery (이벤트 ID) 는 동일하게 유지됩니다. 받는 쪽은 이 값을 idempotency key 로 활용해 중복 처리를 회피하세요.
트러블슈팅
받기는 받는데 body 가 비어 있음
- Express / Next.js Route Handler 에서 raw body 가 아닌 JSON 파서가 먼저 소비한 경우입니다. 서명 검증을 위해 raw body 를 별도로 보존해야 합니다 (Next.js Route Handler 는
await request.text()로 한 번만 읽고 재사용). - proxy 가 body 를 누락한 경우 — Cloudflare · nginx · API Gateway 의 body 크기 한도 확인 (1MB 권장).
Signature 검증 실패
- raw body 에서 trailing newline 이 추가/제거된 경우. 받는 즉시 그대로 HMAC 에 넣어야 합니다.
- secret 이 대시보드에서 회전된 후 옛 값을 그대로 사용 중. 회전 시 24 시간 그레이스 윈도우는 없으므로 즉시 갱신 필요.
- header 파싱에서
sha256=prefix 를 빠뜨리지 않았는지 확인.
재시도가 너무 많음
- 수신 측이 5xx 또는 timeout 을 반환하고 있다는 신호입니다. 대시보드 → Webhook 설정 → "최근 실패" 에서 응답 코드와 응답 본문을 확인하세요.
- 의도적으로 처리를 미루려면 200 을 즉시 반환한 뒤 비동기 처리하세요. 5xx 는 재시도를 유발합니다.