Webhooks

Contents

Signed webhooks

Each delivery is an HTTPS POST whose body is the decision JSON, with three headers:

webhook-idThe delivery id. It is stable across retries, so deduplicate on it.
webhook-timestampUnix seconds, refreshed on each attempt.
webhook-signatureOne 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.

formatVersionAlways interfacedelta-decision-v1.
markerinterfacedelta-decision:<id>, a stable marker used to deduplicate GitHub issues.
id, comparisonId, lockfileId, revisionIdUUIDs. id is the decision id, distinct from the webhook-id of a delivery.
lockfileHashSHA-256 hex of the normalized dependencies file the decision was made with.
commitSha, repository, ref, targetKeyWhere and what: the commit that was watched, the lowercase owner/name repository, the branch and the checked target.
dependency, dependenciesThe first matching dependency name, and every name this target has in the file.
decisionThe most serious result: block, then warn, then allow.
resultsPer-change results in file and change order: dependency, change id, action, source, and the owner, rule, note and acceptance when there are any.
rulesVersiondecision-rules-v1
evaluatedAtWhen the comparison was made, in UTC, even when evaluation ran later.
headline, changes, evidenceA one-line summary, the changes behind the decision, and the observation ids of the evidence.
totalResults, totalChanges, truncatedThe 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.

Back to contents

Next: REST API