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 입니다.
- 받은 본문을 바이트 그대로 씁니다. JSON 을 파싱했다가 다시 직렬화하면 서명이 맞지 않습니다.
- 서명의
t가 지금과 300초 넘게 차이 나면 거부합니다(가로챈 요청을 나중에 다시 보내는 것을 막습니다). - 계산한 값과
v2를 상수 시간 비교로 맞춰 봅니다. - 통과했으면
event_id로 이미 처리한 이벤트인지 확인합니다. 처리한 적이 있으면 2xx 를 돌려주고 건너뜁니다.
Node 예제(복사해 쓰세요):
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 은 자동으로 꺼집니다. 꺼 둔 동안 쌓인 이벤트는 다시 켜도 보내지 않습니다.