Webhooks
Subscribe an HTTPS endpoint on your side and SecureCodingHub will POST a signed JSON envelope to it whenever the events you care about happen. Every delivery is signed with HMAC-SHA256 so you can verify it came from us, not a spoofer.
When to use webhooks
- Open a Jira ticket whenever a developer completes (or fails) a mandatory assignment.
- Update an HR record when a category-completion certificate is issued.
- Notify Slack when a SARIF run from CI auto-creates training assignments.
- Mirror SCH state into your own data warehouse without polling the REST API.
Creating an endpoint
Sign in as an organization admin and open Organization → Webhooks.
Click + Add endpoint. Enter the public HTTPS URL that will receive deliveries. Ngrok / cloudflared tunnels are fine for local development.
Tick the events you want to subscribe to (you can change this later).
Click Create endpoint. A whsec_… signing secret is shown once. Copy it to your secrets manager — this is the key you'll use to verify every delivery.
Event catalog
v1 publishes four event types. The catalog is also available programmatically at GET /api/public/v1/webhook-endpoints/events.
| Event | Fires when | Payload data shape |
|---|---|---|
assignment.created | An assignment is created — manually by an admin, via the public API, or as a side-effect of SARIF ingestion. | Same shape as POST /assignments response. |
assignment.completed | A user reaches 100% completion on an assignment they were given. | Assignment summary plus userId, userName, userEmail, completedAt. |
sarif.ingested | A SARIF run has been processed (whether or not any assignments were created from it). | Same shape as the synchronous POST /sarif response. |
certificate.issued | A category-completion certificate is auto-issued to a user. | certificateId, certificateNumber, user details, categoryId, categoryTitle, issuedAt. |
Request shape
Every delivery is a POST with these headers and a JSON body:
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": { … }
}The envelope is identical across event types. Only type and the inner data change.
Verifying the signature
The X-SecureCodingHub-Signature header has the format t=<unix_seconds>,v1=<hex>. To verify:
Parse t and v1 from the header.
Compute HMAC-SHA256("<t>.<raw_body>", secret) as a lowercase hex string.
Compare to v1 in constant time.
Reject if the timestamp is more than 5 minutes off your server clock (defends against replay).
Always verify against the raw request body — re-serializing the JSON will change byte ordering and invalidate the signature.
Node.js example
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) example
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 example
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)
}Delivery guarantees & retries
Webhook deliveries are queued and dispatched asynchronously by a background worker. They are at-least-once — under rare circumstances (timeouts, response races) the same event may be delivered twice. Use the id field on the envelope (and the X-SecureCodingHub-Event-Id header) to dedupe.
Your endpoint should respond with any 2xx status within 15 seconds. Anything else is treated as a failure and triggers retry with exponential backoff:
| Attempt | Time after previous |
|---|---|
| 1 (initial) | queued immediately after the source event |
| 2 | +1 minute |
| 3 | +5 minutes |
| 4 | +30 minutes |
| 5 (final) | +2 hours |
After the 5th failure the delivery is marked exhausted and the endpoint is automatically disabled. You'll see the failures and the auto-disable timestamp under Organization → Webhooks → View deliveries.
Best practices
- Always verify the signature. Anyone who guesses your URL could otherwise spam fake events into your pipeline.
- Respond fast. Hand work off to a queue and return
200within milliseconds — don't process synchronously inside the handler. - Dedupe by
event.id. Store seen IDs for at least 7 days. - Don't trust IP-based filtering. Cloudflare egress IPs may change without notice; the signature is the only stable trust anchor.
- Test before going live. Use cloudflared (
cloudflared tunnel --url http://localhost:7777) or ngrok to expose a local receiver, then trigger an event via the API to capture an end-to-end delivery.
Inspecting recent deliveries
The admin console lists the most recent 50 deliveries per endpoint with status, attempt count, response code, and any error message. Programmatic access is available at:
GET /api/public/v1/webhook-endpoints/{id}/deliveriesThis requires the webhook:manage scope.