Webhooks
Contents
Signed webhooks
Each delivery is an HTTPS POST whose body is the decision JSON, with three headers:
webhook-id | The delivery id. It is stable across retries, so deduplicate on it. |
webhook-timestamp | Unix seconds, refreshed on each attempt. |
webhook-signature | One or more space-separated v1,<base64> values. |
The signature is HMAC-SHA256, keyed with the base64-decoded part of the whsec_… secret after the whsec_ prefix, over the exact bytes of `${id}.${timestamp}.` followed by the raw body.
Receivers must
- verify against the raw body bytes, never re-serialized JSON;
- compare in constant time;
- accept the request if any one signature matches;
- reject timestamps more than 300 seconds from now;
- return 2xx quickly.
Verifying in Node.js
const { createHmac, timingSafeEqual } = require("node:crypto");
// rawBody: the request body exactly as received (a Buffer, or the raw string).
// now: the current time in Unix seconds.
function verifyWebhook(secret, headers, rawBody, now) {
const id = headers["webhook-id"];
const timestamp = headers["webhook-timestamp"];
const signatures = headers["webhook-signature"];
if (!id || !timestamp || !signatures) return false;
const seconds = Number(timestamp);
if (!Number.isInteger(seconds) || Math.abs(now - seconds) > 300) {
return false;
}
const key = Buffer.from(secret.slice("whsec_".length), "base64");
const expected = Buffer.from(
"v1," +
createHmac("sha256", key)
.update(`${id}.${timestamp}.`)
.update(rawBody)
.digest("base64"),
);
return signatures.split(" ").some((candidate) => {
const given = Buffer.from(candidate);
return given.length === expected.length && timingSafeEqual(given, expected);
});
}Verifying in Python
import base64, hashlib, hmac, time
# raw_body: the request body bytes exactly as received.
def verify_webhook(secret, headers, raw_body, now=None):
msg_id = headers.get("webhook-id")
timestamp = headers.get("webhook-timestamp")
signatures = headers.get("webhook-signature")
if not (msg_id and timestamp and signatures and timestamp.isdigit()):
return False
if now is None:
now = int(time.time())
if abs(now - int(timestamp)) > 300:
return False
key = base64.b64decode(secret[len("whsec_"):])
signed = f"{msg_id}.{timestamp}.".encode() + raw_body
expected = "v1," + base64.b64encode(
hmac.new(key, signed, hashlib.sha256).digest()
).decode()
return any(
hmac.compare_digest(expected, candidate)
for candidate in signatures.split(" ")
)Rotation and verification
When you rotate, the new secret is shown once. For 24 hours both secrets sign, so accept either. Verify in Routing → Destinations sends a signed ping and needs a 2xx response.
Decision payload
Every webhook body is a decision JSON card, versioned interfacedelta-decision-v1. The card is frozen when the decision is made, so history shows what was decided with which lockfile. The body is at most 64 KB.
formatVersion | Always interfacedelta-decision-v1. |
marker | interfacedelta-decision:<id>, a stable marker used to deduplicate GitHub issues. |
id, comparisonId, lockfileId, revisionId | UUIDs. id is the decision id, distinct from the webhook-id of a delivery. |
lockfileHash | SHA-256 hex of the normalized dependencies file the decision was made with. |
commitSha, repository, ref, targetKey | Where and what: the commit that was watched, the lowercase owner/name repository, the branch and the checked target. |
dependency, dependencies | The first matching dependency name, and every name this target has in the file. |
decision | The most serious result: block, then warn, then allow. |
results | Per-change results in file and change order: dependency, change id, action, source, and the owner, rule, note and acceptance when there are any. |
rulesVersion | decision-rules-v1 |
evaluatedAt | When the comparison was made, in UTC, even when evaluation ran later. |
headline, changes, evidence | A one-line summary, the changes behind the decision, and the observation ids of the evidence. |
totalResults, totalChanges, truncated | The full counts, and whether the card omits any results or changes. |
Cards hold at most 50 results and 50 changes; truncated says when some were omitted.
Next: REST API