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

1

Sign in as an organization admin and open Organization → Webhooks.

2

Click + Add endpoint. Enter the public HTTPS URL that will receive deliveries. Ngrok / cloudflared tunnels are fine for local development.

3

Tick the events you want to subscribe to (you can change this later).

4

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.

EventFires whenPayload data shape
assignment.createdAn 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.completedA user reaches 100% completion on an assignment they were given.Assignment summary plus userId, userName, userEmail, completedAt.
sarif.ingestedA SARIF run has been processed (whether or not any assignments were created from it).Same shape as the synchronous POST /sarif response.
certificate.issuedA 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:

1

Parse t and v1 from the header.

2

Compute HMAC-SHA256("<t>.<raw_body>", secret) as a lowercase hex string.

3

Compare to v1 in constant time.

4

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:

AttemptTime 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 200 within 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}/deliveries

This requires the webhook:manage scope.