Документация/API и Webhook'и/Webhook'и

Webhook'и

Подпишите HTTPS-эндпоинт на Вашей стороне, и SecureCodingHub будет POST подписанный JSON-конверт на него всякий раз, когда происходят интересующие Вас события. Каждая доставка подписана HMAC-SHA256, чтобы Вы могли верифицировать, что она пришла от нас, а не от спуфера.

Когда использовать webhook'и

  • Открывать тикет Jira, когда разработчик завершает (или проваливает) обязательное задание.
  • Обновлять HR-запись, когда выпускается сертификат завершения категории.
  • Уведомлять Slack, когда SARIF-ран из CI автоматически создаёт учебные задания.
  • Зеркалить состояние SCH в Ваш собственный data warehouse без опросов REST API.

Создание эндпоинта

1

Войдите как администратор организации и откройте Организация → Webhook'и.

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.

Форма запроса

Каждая доставка — это POST с этими заголовками и JSON-телом:

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

Распарсите t и v1 из заголовка.

2

Вычислите HMAC-SHA256("<t>.<raw_body>", secret) как нижнерегистровую hex-строку.

3

Сравните с v1 в постоянное время.

4

Отклоните, если временная метка отличается более чем на 5 минут от часов сервера (защита от replay).

Всегда верифицируйте против сырого тела запроса — повторная сериализация 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-доставки ставятся в очередь и диспетчеризуются асинхронно фоновым воркером. Они минимум один раз — в редких обстоятельствах (таймауты, гонки ответов) одно и то же событие может быть доставлено дважды. Используйте поле id в конверте (и заголовок X-SecureCodingHub-Event-Id) для дедупликации.

Ваш эндпоинт должен ответить любым 2xx-статусом в течение 15 секунд. Что-либо ещё рассматривается как сбой и запускает повтор с экспоненциальным backoff:

ПопыткаВремя после предыдущей
1 (начальная)поставлена в очередь сразу после исходного события
2+1 минута
3+5 минут
4+30 минут
5 (финальная)+2 часа

После 5-го сбоя доставка помечается как exhausted, и эндпоинт автоматически отключается. Вы увидите сбои и временную метку авто-отключения в Организация → Webhook'и → Просмотреть доставки.

Лучшие практики

  • Всегда верифицируйте подпись. Любой, кто угадает Ваш URL, мог бы иначе спамить фальшивые события в Ваш пайплайн.
  • Отвечайте быстро. Передавайте работу в очередь и возвращайте 200 в миллисекундах — не обрабатывайте синхронно внутри обработчика.
  • Дедуплицируйте по event.id. Храните увиденные ID как минимум 7 дней.
  • Не доверяйте IP-фильтрации. Egress IP Cloudflare могут меняться без уведомления; подпись — единственный стабильный якорь доверия.
  • Тестируйте перед выходом в эксплуатацию. Используйте cloudflared (cloudflared tunnel --url http://localhost:7777) или ngrok для выставления локального приёмника, затем триггерите событие через API для захвата сквозной доставки.

Просмотр недавних доставок

Админ-консоль перечисляет 50 последних доставок на эндпоинт со статусом, числом попыток, кодом ответа и любым сообщением об ошибке. Программный доступ доступен по:

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

Это требует области webhook:manage.