문서/API 및 웹훅/웹훅

웹훅

측의 HTTPS 엔드포인트를 구독하면 관심 있는 이벤트가 발생할 때마다 SecureCodingHub가 서명된 JSON 봉투를 그곳으로 POST합니다. 모든 전달은 HMAC-SHA256으로 서명되므로 스푸퍼가 아닌 우리로부터 왔다는 것을 검증할 수 있습니다.

웹훅을 사용해야 할 때

  • 개발자가 필수 과제를 완료하거나 실패할 때마다 Jira 티켓을 엽니다.
  • 카테고리 완료 인증서가 발급될 때 HR 레코드를 업데이트합니다.
  • CI의 SARIF 실행이 교육 과제를 자동 생성할 때 Slack에 알립니다.
  • REST API를 폴링하지 않고도 SCH 상태를 자체 데이터 웨어하우스에 미러링합니다.

엔드포인트 생성

1

조직 관리자로 로그인하고 조직 → 웹훅을 엽니다.

2

+ 엔드포인트 추가을 클릭합니다. 전달을 받을 공개 HTTPS URL을 입력합니다. 로컬 개발에는 Ngrok / cloudflared 터널도 괜찮습니다.

3

구독할 이벤트를 선택합니다(나중에 변경할 수 있음).

4

엔드포인트 만들기을 클릭합니다. whsec_… 서명 시크릿이 한 번 표시됩니다. 시크릿 매니저에 복사하세요 — 모든 전달을 검증하는 데 사용할 키입니다.

이벤트 카탈로그

v1은 네 가지 이벤트 유형을 게시합니다. 카탈로그는 GET /api/public/v1/webhook-endpoints/events에서 프로그래밍 방식으로도 사용할 수 있습니다.

이벤트발생 시점페이로드 data 형식
assignment.created과제가 생성됨 — 관리자가 수동으로, 공개 API를 통해 또는 SARIF 수집의 부작용으로.POST /assignments 응답과 동일한 형식입니다.
assignment.completed사용자가 받은 과제에서 100% 완료에 도달합니다.과제 요약 및 userId, userName, userEmail, completedAt입니다.
sarif.ingestedSARIF 실행이 처리되었습니다(과제가 생성되었든 아니든).동기 POST /sarif 응답과 동일한 형식입니다.
certificate.issued카테고리 완료 인증서가 사용자에게 자동 발급됩니다.certificateId, certificateNumber, 사용자 상세, categoryId, categoryTitle, issuedAt입니다.

요청 형식

모든 전달은 다음 헤더와 JSON 본문이 있는 POST입니다:

POST /your-endpoint HTTP/1.1
Host: example.com
Content-Type: application/json
User-Agent: SecureCodingHub-Webhooks/1.0
X-SecureCodingHub-Event: assignment.completed
X-SecureCodingHub-Event-Id: evt_8a3d…
X-SecureCodingHub-Delivery: 9c1b…
X-SecureCodingHub-Signature: t=1717003245,v1=ad8c…

{
  "id": "evt_8a3d…",
  "type": "assignment.completed",
  "createdAt": "2026-05-29T13:45:12.412Z",
  "orgId": "e188fc87-…",
  "data": { … }
}

봉투는 이벤트 유형 간에 동일합니다. type과 내부 data만 변경됩니다.

서명 검증

X-SecureCodingHub-Signature 헤더 형식은 t=<unix_seconds>,v1=<hex>입니다. 검증 방법:

1

헤더에서 tv1을 파싱합니다.

2

HMAC-SHA256("<t>.<raw_body>", secret)을 소문자 16진수 문자열로 계산합니다.

3

상수 시간에 v1과 비교합니다.

4

타임스탬프가 서버 시계에서 5분 이상 벗어난 경우 거부합니다(재생 공격 방어).

항상 원시 요청 본문에 대해 검증하세요 — JSON을 다시 직렬화하면 바이트 순서가 변경되고 서명이 무효화됩니다.

Node.js 예제

import crypto from 'crypto'
import express from 'express'

const app = express()
const SECRET = process.env.SCH_WEBHOOK_SECRET

app.post('/webhook',
  express.raw({ type: 'application/json' }),
  (req, res) => {
    const header = req.header('X-SecureCodingHub-Signature') || ''
    const parts = Object.fromEntries(
      header.split(',').map(p => p.trim().split('='))
    )
    const ts = parseInt(parts.t, 10)
    const sig = parts.v1
    if (!ts || !sig) return res.status(400).send('bad signature header')
    if (Math.abs(Date.now() / 1000 - ts) > 300) {
      return res.status(400).send('stale')
    }
    const expected = crypto
      .createHmac('sha256', SECRET)
      .update(`${ts}.${req.body.toString('utf8')}`)
      .digest('hex')
    const ok = crypto.timingSafeEqual(
      Buffer.from(expected, 'hex'),
      Buffer.from(sig, 'hex')
    )
    if (!ok) return res.status(401).send('bad signature')

    const event = JSON.parse(req.body.toString('utf8'))
    console.log(`✓ ${event.type} ${event.id}`)
    res.json({ received: true })
  })

app.listen(7777)

Python (Flask) 예제

import hashlib, hmac, os, time
from flask import Flask, request, abort

SECRET = os.environ['SCH_WEBHOOK_SECRET'].encode()
app = Flask(__name__)

@app.post('/webhook')
def webhook():
    header = request.headers.get('X-SecureCodingHub-Signature', '')
    parts = dict(p.split('=') for p in header.split(','))
    try:
        ts = int(parts['t']); sig = parts['v1']
    except (KeyError, ValueError):
        abort(400, 'bad signature header')
    if abs(time.time() - ts) > 300:
        abort(400, 'stale')
    raw = request.get_data()  # raw bytes
    expected = hmac.new(SECRET, f"{ts}.".encode() + raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401, 'bad signature')
    event = request.get_json()
    print('OK', event['type'], event['id'])
    return {'received': True}

Go 예제

package main

import (
  "crypto/hmac"; "crypto/sha256"; "encoding/hex"; "io"
  "net/http"; "os"; "strconv"; "strings"; "time"
)

var secret = []byte(os.Getenv("SCH_WEBHOOK_SECRET"))

func webhook(w http.ResponseWriter, r *http.Request) {
  header := r.Header.Get("X-SecureCodingHub-Signature")
  var ts int64; var sig string
  for _, p := range strings.Split(header, ",") {
    kv := strings.SplitN(strings.TrimSpace(p), "=", 2)
    if len(kv) != 2 { continue }
    if kv[0] == "t" { ts, _ = strconv.ParseInt(kv[1], 10, 64) }
    if kv[0] == "v1" { sig = kv[1] }
  }
  if ts == 0 || sig == "" { http.Error(w, "bad header", 400); return }
  if abs(time.Now().Unix() - ts) > 300 { http.Error(w, "stale", 400); return }
  body, _ := io.ReadAll(r.Body)
  mac := hmac.New(sha256.New, secret)
  mac.Write([]byte(strconv.FormatInt(ts, 10) + "."))
  mac.Write(body)
  if !hmac.Equal(mac.Sum(nil), mustHex(sig)) {
    http.Error(w, "bad signature", 401); return
  }
  w.Write([]byte(`{"received":true}`))
}

func mustHex(s string) []byte { b, _ := hex.DecodeString(s); return b }
func abs(x int64) int64 { if x < 0 { return -x }; return x }

func main() {
  http.HandleFunc("/webhook", webhook)
  http.ListenAndServe(":7777", nil)
}

전달 보장 및 재시도

웹훅 전달은 백그라운드 워커에 의해 큐에 넣어지고 비동기적으로 디스패치됩니다. 이들은 최소 1회 보장입니다 — 드문 상황(시간 초과, 응답 경쟁)에서 동일한 이벤트가 두 번 전달될 수 있습니다. 봉투의 id 필드(및 X-SecureCodingHub-Event-Id 헤더)를 사용하여 중복을 제거하세요.

엔드포인트는 15초 이내에 2xx 상태로 응답해야 합니다. 그 외의 것은 모두 실패로 취급되며 지수 백오프와 함께 재시도를 트리거합니다:

시도이전 이후 시간
1 (초기)소스 이벤트 직후 큐에 넣음
2+1분
3+5분
4+30분
5 (최종)+2시간

5번째 실패 후 전달은 exhausted로 표시되고 엔드포인트는 자동으로 비활성화됩니다. 실패와 자동 비활성화 타임스탬프는 조직 → 웹훅 → 전달 보기에서 볼 수 있습니다.

모범 사례

  • 항상 서명을 검증하세요. URL을 추측하는 사람은 누구나 가짜 이벤트를 파이프라인에 스팸할 수 있습니다.
  • 빠르게 응답하세요. 작업을 큐로 넘기고 밀리초 내에 200를 반환하세요 — 핸들러 내부에서 동기적으로 처리하지 마세요.
  • event.id로 중복 제거하세요. 본 ID를 최소 7일 동안 저장하세요.
  • IP 기반 필터링을 신뢰하지 마세요. Cloudflare 송신 IP는 사전 통지 없이 변경될 수 있습니다. 서명만이 유일하게 안정적인 신뢰 앵커입니다.
  • 라이브 전에 테스트하세요. cloudflared(cloudflared tunnel --url http://localhost:7777) 또는 ngrok을 사용하여 로컬 수신기를 노출한 다음 API를 통해 이벤트를 트리거하여 종단 간 전달을 캡처하세요.

최근 전달 검사

관리자 콘솔은 상태, 시도 횟수, 응답 코드 및 오류 메시지와 함께 엔드포인트당 가장 최근 50개의 전달을 나열합니다. 프로그래밍 방식 액세스는 다음에서 사용할 수 있습니다:

GET /api/public/v1/webhook-endpoints/{id}/deliveries

이는 webhook:manage 범위가 필요합니다.