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
Inicia sesión como administrador de la organización y abre Organización → Webhooks.
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.
Marca los eventos a los que quieres suscribirte (lo puedes cambiar luego).
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.
| Evento | Se dispara cuando | Forma del data del payload |
|---|---|---|
assignment.created | Se 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.completed | Un 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.ingested | Se ha procesado un run SARIF (haya generado o no asignaciones). | Misma forma que la respuesta síncrona de POST /sarif. |
certificate.issued | Se 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:
Parsea t y v1 del header.
Calcula HMAC-SHA256("<t>.<raw_body>", secret) como cadena hexadecimal en minúsculas.
Compara con v1 en tiempo constante.
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:
| Intento | Tiempo 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
200en 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}/deliveriesEsto requiere el scope webhook:manage.