Docs/API y webhooks/Webhooks

Webhooks

Suscribe un endpoint HTTPS en tu lado y SecureCodingHub hará POST de un envoltorio JSON firmado cada vez que ocurran los eventos que te interesan. Cada entrega se firma con HMAC-SHA256 para que puedas verificar que viene de nosotros y no de un suplantador.

Cuándo usar webhooks

  • Abrir un ticket de Jira cuando un desarrollador completa (o no supera) una asignación obligatoria.
  • Actualizar un registro de RR. HH. cuando se emite un certificado por completar una categoría.
  • Notificar a Slack cuando un run SARIF de CI crea asignaciones de formación automáticamente.
  • Replicar el estado de SCH en tu propio data warehouse sin tener que hacer polling de la API REST.

Crear un endpoint

1

Inicia sesión como administrador de la organización y abre Organización → Webhooks.

2

Haz clic en + Añadir endpoint. Introduce la URL HTTPS pública que recibirá las entregas. Los túneles de Ngrok o cloudflared están bien para desarrollo local.

3

Marca los eventos a los que quieres suscribirte (lo puedes cambiar luego).

4

Haz clic en Crear endpoint. Se muestra un secreto de firma whsec_… una sola vez. Cópialo a tu gestor de secretos: es la clave con la que verificarás cada entrega.

Catálogo de eventos

v1 publica cuatro tipos de evento. El catálogo también está disponible de forma programática en GET /api/public/v1/webhook-endpoints/events.

EventoSe dispara cuandoForma del data del payload
assignment.createdSe crea una asignación, ya sea manualmente por un administrador, vía la API pública o como efecto secundario de la ingesta de SARIF.Misma forma que la respuesta de POST /assignments.
assignment.completedUn usuario alcanza el 100% de finalización en una asignación que se le había dado.Resumen de la asignación más userId, userName, userEmail y completedAt.
sarif.ingestedSe ha procesado un run SARIF (haya generado o no asignaciones).Misma forma que la respuesta síncrona de POST /sarif.
certificate.issuedSe emite automáticamente un certificado por finalización de categoría para un usuario.certificateId, certificateNumber, datos del usuario, categoryId, categoryTitle, issuedAt.

Forma de la solicitud

Cada entrega es un POST con estos headers y un cuerpo 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": { … }
}

El envoltorio es idéntico entre tipos de evento. Solo cambian type y el data interno.

Verificación de la firma

El header X-SecureCodingHub-Signature tiene el formato t=<unix_seconds>,v1=<hex>. Para verificar:

1

Parsea t y v1 del header.

2

Calcula HMAC-SHA256("<t>.<raw_body>", secret) como cadena hexadecimal en minúsculas.

3

Compara con v1 en tiempo constante.

4

Rechaza si la marca de tiempo se desvía más de 5 minutos del reloj de tu servidor (defensa contra replay).

Verifica siempre contra el cuerpo raw de la solicitud: reserializar el JSON cambiará el orden de bytes e invalidará la firma.

Ejemplo en 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)

Ejemplo en 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}

Ejemplo en 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)
}

Garantías de entrega y reintentos

Las entregas de webhook se encolan y se despachan de forma asíncrona por un worker en segundo plano. Son al menos una vez: en circunstancias raras (timeouts, carreras de respuesta) el mismo evento puede entregarse dos veces. Usa el campo id del envoltorio (y el header X-SecureCodingHub-Event-Id) para deduplicar.

Tu endpoint debe responder con cualquier estado 2xx en menos de 15 segundos. Cualquier otra cosa se trata como fallo y dispara un reintento con backoff exponencial:

IntentoTiempo desde el anterior
1 (inicial)encolado inmediatamente tras el evento de origen
2+1 minuto
3+5 minutos
4+30 minutos
5 (final)+2 horas

Tras el 5.º fallo, la entrega se marca como exhausted y el endpoint se deshabilita automáticamente. Verás los fallos y la marca de tiempo de auto-deshabilitación en Organización → Webhooks → Ver entregas.

Buenas prácticas

  • Verifica siempre la firma. Cualquiera que adivine tu URL podría, de otro modo, inundar tu pipeline con eventos falsos.
  • Responde rápido. Delega el trabajo en una cola y devuelve 200 en milisegundos: no proceses de forma síncrona dentro del handler.
  • Deduplica por event.id. Guarda los IDs vistos durante al menos 7 días.
  • No te fíes del filtrado por IP. Las IPs de salida de Cloudflare pueden cambiar sin previo aviso; la firma es el único anclaje de confianza estable.
  • Prueba antes de salir a producción. Usa cloudflared (cloudflared tunnel --url http://localhost:7777) o ngrok para exponer un receptor local y luego dispara un evento vía la API para capturar una entrega de extremo a extremo.

Inspeccionar entregas recientes

La consola de administración lista las 50 entregas más recientes por endpoint con estado, número de intentos, código de respuesta y cualquier mensaje de error. El acceso programático está disponible en:

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

Esto requiere el scope webhook:manage.