Webhook
ご自身の側でHTTPSエンドポイントを購読すると、SecureCodingHubはあなたが関心を持つイベントが発生するたびに、署名付きJSONエンベロープをPOSTします。すべての配信はHMAC-SHA256で署名されているため、当社からのものであり、なりすましでないことを検証できます。
Webhookを使用するタイミング
- 開発者が必須課題を完了 (または失敗) するたびにJiraチケットを起票します。
- カテゴリ完了証明書が発行されたときにHRレコードを更新します。
- CIからのSARIF実行がトレーニング課題を自動作成したときにSlackに通知します。
- REST APIをポーリングすることなく、SCH状態を独自のデータウェアハウスにミラーします。
エンドポイントの作成
組織管理者としてサインインし、組織 → Webhookを開きます。
+ エンドポイントを追加をクリックします。配信を受信するパブリックHTTPS URLを入力します。Ngrok / cloudflaredトンネルはローカル開発に適しています。
購読したいイベントをチェックします (後で変更できます)。
エンドポイントを作成をクリックします。whsec_…署名シークレットが1回のみ表示されます。シークレットマネージャーにコピーしてください — これは各配信を検証するために使用するキーです。
イベントカタログ
v1は4つのイベントタイプを公開しています。カタログはGET /api/public/v1/webhook-endpoints/eventsでプログラム的にも利用可能です。
| イベント | 発火タイミング | ペイロードdata形状 |
|---|---|---|
assignment.created | 課題が作成された場合 — 管理者による手動、パブリックAPI経由、またはSARIF取り込みの副作用として。 | POST /assignmentsレスポンスと同じ形状。 |
assignment.completed | ユーザーが付与された課題で100%完了に達した場合。 | 課題サマリーに加えてuserId、userName、userEmail、completedAt。 |
sarif.ingested | SARIF実行が処理された場合 (それから課題が作成されたかどうかにかかわらず)。 | 同期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>の形式を持ちます。検証するには:
ヘッダーからtとv1を解析します。
HMAC-SHA256("<t>.<raw_body>", secret)を小文字の16進文字列として計算します。
定数時間でv1と比較します。
タイムスタンプがサーバークロックから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)
}配信保証と再試行
Webhook配信はキューに入れられ、バックグラウンドワーカーによって非同期にディスパッチされます。少なくとも1回保証です — まれな状況 (タイムアウト、レスポンス競合) では、同じイベントが2回配信される可能性があります。重複排除には、エンベロープのidフィールド (およびX-SecureCodingHub-Event-Idヘッダー) を使用してください。
エンドポイントは15秒以内に任意の2xxステータスで応答する必要があります。それ以外は失敗として扱われ、指数関数的バックオフで再試行をトリガーします:
| 試行 | 前回からの時間 |
|---|---|
| 1 (初回) | ソースイベント直後にキュー登録 |
| 2 | +1分 |
| 3 | +5分 |
| 4 | +30分 |
| 5 (最終) | +2時間 |
5回目の失敗後、配信はexhaustedとしてマークされ、エンドポイントは自動的に無効化されます。失敗と自動無効化のタイムスタンプは組織 → Webhook → 配信を表示で確認できます。
ベストプラクティス
- 常に署名を検証してください。そうしないと、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スコープが必要です。