Webhook payload¶
upgradescope serve --webhook <url> POSTs one JSON notification per
cluster and evaluation pass that changed its readiness. When it is sent,
how delivery is retried and how changes are grouped are in
Running the server.
The payload has a JSON Schema,
api/webhook.schema.json
(draft 2020-12). It allows fields it does not list, because fields may be
added within a schemaVersion; ignore the ones you do not know. A test
delivers real notifications through ingest, the outbox and a signed
request and validates them against it, so the schema and this page
cannot drift from what the server sends. Another test regenerates the
add-on changes of the example below from the embedded add-on registry at
the example's timestamp, so their titles and details are what the engine
prints.
Payload (schemaVersion 1)¶
POST with Content-Type: application/json:
{
"schemaVersion": 1,
"deliveryId": "3f2b8c1e9a7d4e6f8b0c2d4e6f8a0b1c",
"type": "readiness.changed",
"timestamp": "2026-10-02T09:30:00Z",
"cluster": { "id": 7, "name": "prod-eu-1" },
"targets": [
{ "target": "1.35", "verdict": "blocked", "score": 75, "blockers": 1 },
{ "target": "1.36", "verdict": "blocked", "score": 50, "blockers": 2 }
],
"changes": [
{
"kind": "new-blocker",
"key": "eol-addon/ingress-nginx",
"severity": "blocker",
"title": "Ingress NGINX Controller is end-of-life since 2026-03-24",
"detail": "Detected Ingress NGINX Controller version 1.11.3 via image in namespace(s): ingress-nginx. Upstream support has ended.",
"targets": ["1.35", "1.36"]
},
{
"kind": "eol-approaching",
"key": "eol-approaching/istio/1.30",
"severity": "warning",
"title": "Istio 1.30 reaches end-of-life on 2026-12-31",
"detail": "Detected Istio version 1.30.1 via image in namespace(s): istio-system. Upstream support for the 1.30 release line ends on 2026-12-31.",
"targets": ["1.36"]
}
],
"omitted": { "new-blocker": 3 }
}
| Field | Meaning |
|---|---|
schemaVersion |
1. Fields may be added within a version; a rename or removal bumps it. |
deliveryId |
32 hex characters, the same on every retry and for every sink. |
type |
readiness.changed. The set of types is closed: a new one bumps schemaVersion (compatibility policy). |
timestamp |
When the evaluation pass ran, RFC 3339 UTC. |
cluster |
id (the API's cluster id) and name. |
targets[] |
The verdict after the pass (ready, blocked or unknown), score and blocker count of every target with a change. |
changes[] |
kind: new-blocker, became-ready or eol-approaching. key: the finding's stable key (none for became-ready); severity: blocker or warning; title, detail; targets: the targets the change applies to. Ordered new-blocker, became-ready, eol-approaching. |
omitted |
Changes left out by the per-kind cap, by kind. Absent when nothing was left out. |
Headers: X-Upgradescope-Delivery (the delivery id),
X-Upgradescope-Event (the type) and, with a secret,
X-Upgradescope-Signature.
Messages queued by a server older than this format are delivered after an
upgrade as one-change notifications with a deliveryId of outbox-<n>.
Verifying the signature¶
serve --webhook-secret ($UPGRADESCOPE_WEBHOOK_SECRET,
--webhook-secret-file; chart server.webhookSecret) signs every webhook
request:
X-Upgradescope-Signature: sha256=<hex HMAC-SHA256(secret, raw request body)>
Compute the HMAC over the raw body bytes, before any JSON parsing, and compare in constant time:
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
want := "sha256=" + hex.EncodeToString(mac.Sum(nil))
ok := hmac.Equal([]byte(want), []byte(r.Header.Get("X-Upgradescope-Signature")))
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* /sha256=/'
The signature covers the body only; reject old timestamps and repeated
deliveryIds to resist replay.