콘텐츠로 이동

Webhook 수신 검증

이슈·코멘트·프로젝트가 바뀌면 GoalTrack 이 등록한 주소로 POST 요청을 보냅니다. 받는 서버는 요청이 정말 GoalTrack 에서 왔는지 서명을 검증한 뒤 처리해야 합니다 — 검증하지 않으면 누구나 같은 주소로 가짜 이벤트를 보낼 수 있습니다.

워크스페이스 소유자·관리자가 설정 › Webhooks 에서 받을 주소(공개 인터넷에서 접속되는 https 주소)와 받을 이벤트를 고르고, 서명에 쓸 secret(8자 이상)을 정합니다. 같은 secret 을 받는 서버에도 두세요. 워크스페이스당 20개까지 등록할 수 있습니다.

  • POST, content-type: application/json
  • 본문: { "event", "event_id", "workspace_id", "occurred_at", "payload" }
이벤트 언제
work_item.created 이슈가 만들어졌을 때
work_item.updated 제목·우선순위·기한 같은 필드가 바뀌었을 때(payload.changed 에 바뀐 필드 이름)
work_item.assigned 담당자가 바뀌었을 때
work_item.status_changed 상태가 바뀌었을 때
work_item.deleted 이슈가 삭제됐을 때(id 와 최소 식별자만)
comment.created 이슈에 코멘트가 달렸을 때
comment.deleted 코멘트가 삭제됐을 때
project.created 프로젝트가 만들어졌을 때
project.status_changed 프로젝트 상태가 바뀌었을 때
project.deleted 프로젝트가 삭제됐을 때
import.completed 가져오기를 확정했을 때(행마다 보내지 않고 건수를 담은 1건)

payload 에는 식별자와 제목·이름 같은 최소 필드만 실립니다. 본문·설명·코멘트 내용이 필요하면 API Key 로 다시 읽으세요. 이벤트 종류는 늘어날 수 있으니 모르는 event 는 2xx 로 받고 무시하세요. 초안 이슈에 대한 이벤트는 보내지 않습니다.

헤더 뜻
x-goaltrack-event 이벤트 이름. 서명 밖이라 처리 분기는 본문의 event 로 하세요.
x-goaltrack-timestamp 보낸 시각(epoch 초).
x-goaltrack-event-id 중복 판별 키. 재시도에서도, 같은 주소를 두 번 등록해도 같은 값입니다.
x-goaltrack-delivery-id 발송 추적용. 등록한 주소마다 다르고 재시도에서는 같습니다.
x-goaltrack-signature-v2 t=<시각>,v2=<hex> 형식의 서명.

서명은 HMAC-SHA256(secret, "<event_id>.<t>.<rawBody>") 의 hex 입니다.

  1. 받은 본문을 바이트 그대로 씁니다. JSON 을 파싱했다가 다시 직렬화하면 서명이 맞지 않습니다.
  2. 서명의 t 가 지금과 300초 넘게 차이 나면 거부합니다(가로챈 요청을 나중에 다시 보내는 것을 막습니다).
  3. 계산한 값과 v2 를 상수 시간 비교로 맞춰 봅니다.
  4. 통과했으면 event_id 로 이미 처리한 이벤트인지 확인합니다. 처리한 적이 있으면 2xx 를 돌려주고 건너뜁니다.

Node 예제(복사해 쓰세요):

verify-goaltrack-webhook.mjs
import { createHmac, timingSafeEqual } from 'node:crypto';
// rawBody = the request body exactly as received (parsing and re-serializing JSON changes the bytes)
// headers = object with lowercase keys (Node req.headers). With the Fetch API (Next.js, Workers), pass Object.fromEntries(request.headers)
function verifyGoalTrackWebhook(rawBody, headers, secret, toleranceSec = 300) {
const eventId = String(headers['x-goaltrack-event-id'] ?? '');
const parts = Object.fromEntries(
String(headers['x-goaltrack-signature-v2'] ?? '').split(',').map((p) => p.split('=', 2)),
);
const t = Number(parts.t);
if (!/^[A-Za-z0-9_-]{1,128}$/.test(eventId)) return false;
if (!/^\d+$/.test(parts.t ?? '') || !/^[0-9a-f]{64}$/.test(parts.v2 ?? '')) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSec) return false; // replay or clock skew
const expected = createHmac('sha256', secret)
.update(`${eventId}.${t}.${rawBody}`, 'utf8')
.digest();
if (!timingSafeEqual(Buffer.from(parts.v2, 'hex'), expected)) return false;
// Verified: eventId is signed. If you already processed this eventId, return 2xx and skip it.
// Branch on JSON.parse(rawBody).event (the x-goaltrack-event header is not signed).
return true;
}
  • 2xx 만 성공입니다. 3xx 는 따라가지 않습니다.
  • 4xx·5xx·연결 실패·10초 안에 응답 없음은 실패로 보고 5 · 30 · 300 · 1800 · 7200초 뒤에 다시 보냅니다(처음 포함 최대 6회). 재시도는 같은 event_id 에 새 시각·새 서명입니다.
  • 429 에 Retry-After 를 주면 그 시각 뒤에 다시 보냅니다(최대 7200초).
  • 순서는 보장하지 않고, 같은 이벤트가 두 번 올 수 있습니다 — event_id 로 거르세요.
  • 재시도를 다 쓰고 끝내 실패한 발송이 연속 10건이 되면 그 webhook 은 자동으로 꺼집니다. 꺼 둔 동안 쌓인 이벤트는 다시 켜도 보내지 않습니다.