Skip to content

Diagnostic GitHub API

the same webhook URL is registered on the org and the repo

The bot comments twice on every pull request. Someone checks the receiver for a retry loop, finds none, and blames GitHub for sending duplicates. GitHub is sending exactly one copy of the event to each hook that asked for it — and two hooks asked, one on the repository and one on the organization, both pointing at the same URL.

Read-only token Python and Node.js Tests included
Stacks of paper documents and file folders
Photo by Wesley Tingey on Unsplash
The short answer

Collect config.url from GET /orgs/{org}/hooks and from GET /repos/{owner}/{repo}/hooks for each repository, normalise the URLs, and group. Any endpoint reached by more than one active hook whose events arrays overlap receives every event in that overlap twice.

Two hooks on one URL are not automatically a duplicate: if their event sets are disjoint, that is a deliberate split and the script says so rather than raising it. The finding is the intersection, and it is what you print.

The problem in plain words

Duplicate delivery is the failure that turns latent bugs into visible ones. Every handler that was written as though it would run once — posting a comment, incrementing a counter, sending a notification, creating a deployment — now runs twice, and the ones that were quietly non-idempotent for years all surface in the same week.

It is also hard to see from the receiver, because both copies are legitimate. Each has a valid signature, each has a plausible payload, and the two arrive within a second of each other from the same source. Nothing about a single request marks it as the second copy. The evidence lives on the configuration side, in two objects that were created by different people at different times, neither of which is wrong on its own.

Repo hookcreatedby a setup scriptOrg hook addedlatersame URL, sameeventsEvent happensoncetwo hookssubscribeTwo deliveriessentone per hookHandler runstwicebot comments twice
Both copies are legitimate and both are correctly signed. Nothing about a single request marks it as the second one.

Why it happens

Org hooks and repo hooks are independent resources. Nothing warns you that a URL is already receiving these events from another scope. A platform team adds an org hook to cover every repository; a per-repo hook created two years earlier by a setup script is still there, and neither view shows the other.

URLs are compared by humans and they differ cosmetically. One hook is https://hooks.example.com/gh, the other https://hooks.example.com/gh/, and a third is on HTTPS://Hooks.Example.com/gh. Those are the same endpoint. Any comparison that does not lowercase the host and drop a trailing slash reports a clean account.

Overlap is the finding, not co-location. Two hooks on one URL where one carries push and the other carries issues is a reasonable arrangement. Reporting it as a duplicate is how a report loses trust on its first run, so the intersection of the event sets is computed and printed, with a wildcard treated as intersecting everything.

Delivery guids answer the question your receiver actually has. GitHub retries and redeliveries reuse a delivery's guid, so keying on X-GitHub-Delivery makes those harmless. Whether the org copy and the repo copy of one event share a guid is something you can observe rather than assume: the script reads both delivery logs and reports whether the same guid appears under both hooks, or whether the same event arrived under two different guids. That difference decides whether guid-based idempotency will save you or whether you need to key on something in the payload.

The fix, as a flow

The script normalises the URL before it groups, because two hooks created years apart differ by a trailing slash far more often than they differ by anything that matters.

Hooks grouped by endpointevent sets intersectedOne hook onlyunique, nothing to doShared URL, no shared eventsa deliberate splitSecond hook inactiveone toggle from doublingOverlapping eventsdelivered twice, delete one
Two hooks on one URL with disjoint events are a deliberate split, and reporting that as a duplicate is how the report loses trust.

How to fix it

Gather hooks from every scope that can reach the repository

GET /orgs/{org}/hooks and GET /repos/{owner}/{repo}/hooks. A GitHub App's webhook is a third possible source; it is configured on the App itself and read with the App's own credentials through GET /app/hook/config, which a repository token cannot do — so if an App is involved, count it manually.

Normalise before comparing

Reduce each config.url to a lowercase host plus path with no trailing slash and no query string. Scheme is dropped deliberately: an http hook and an https hook to the same host and path both deliver, so they are duplicates of each other regardless of the scheme problem they also represent.

Intersect the event sets

Group hooks by normalised endpoint and compute the intersection of their events arrays, treating ["*"] as intersecting everything. An empty intersection is a deliberate split and is reported as such; a non-empty one is the list of events being processed twice.

Check whether the copies share a delivery guid

Read a page of deliveries from each hook on the shared endpoint. If the same guid appears under both, a receiver that dedupes on X-GitHub-Delivery already handles this. If the same event arrives in the same minute under two different guids, guid-level idempotency will not help and you need a key from the payload itself.

Delete one hook, and fix the handler anyway

Keep one source of truth — usually the org hook, or the App if you have one — and remove the redundant hook. Independently make the side effects idempotent, because retries, redeliveries and GitHub's at-least-once delivery all produce repeats that deleting a hook will not prevent.

How to check it worked

Re-run after removing the redundant hook. Each endpoint should be reached from exactly one scope, and any remaining shared endpoint should report a disjoint event split rather than an overlap.

python3 github_duplicate_hooks.py --org acme --repo acme/api --repo acme/web
# 6 hook(s) across 5 endpoint(s), 0 duplicated, 0 latent

The full code

The reads are trivial — two list endpoints and a page of deliveries — and every decision is in two pure functions: the URL normalisation, which is what makes the comparison find anything at all, and the grouping, which is what stops it from crying wolf about two hooks that deliberately split the events between them.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 GitHub API fixes, free and open source.
github_duplicate_hooks.py
"""Find one webhook URL registered by more than one GitHub hook.

Read only. Org hooks and repo hooks are independent objects, so the same URL can
be registered in both scopes and every overlapping event is then delivered twice.
The script prints which hook to remove; it never removes one.
"""
import argparse
import logging
import os
import sys
from urllib.parse import urlsplit

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_duplicate_hooks")

API = "https://api.github.com"
UA = "github-duplicate-hooks/1.0"

# A hook subscribed to "*" receives every event type, current and future, so it
# intersects with anything the other hook on the same URL carries.
WILDCARD = "*"


def endpoint(url):
    """Reduce a webhook URL to lowercase host plus path. Pure.

    Two hooks created years apart by different people differ cosmetically far
    more often than they differ meaningfully: a trailing slash, a capitalised
    host, http where the other is https. All of those deliver to the same server,
    and a raw string comparison across them finds nothing and reports a clean
    account.
    """
    if not url:
        return ""
    parts = urlsplit(str(url).strip())
    host = (parts.hostname or "").lower()
    if not host:
        return str(url).strip().lower().rstrip("/")
    port = ":%d" % parts.port if parts.port not in (None, 80, 443) else ""
    return host + port + (parts.path or "").rstrip("/")


def overlap(a, b):
    """Events both hooks carry, as a sorted list. Pure.

    A wildcard subscribes to everything, so it overlaps whatever the other hook
    lists; two wildcards overlap on everything and are reported as such.
    """
    sa, sb = set(a or []), set(b or [])
    if WILDCARD in sa and WILDCARD in sb:
        return [WILDCARD]
    if WILDCARD in sa:
        return sorted(sb)
    if WILDCARD in sb:
        return sorted(sa)
    return sorted(sa & sb)


def group(hooks):
    """Group hooks by endpoint and classify each group. Pure.

    hooks: dicts with source, id, url, events and active.
    Returns rows sorted by endpoint, each with a state:

      unique    one hook, nothing to do
      duplicate two or more active hooks with events in common
      latent    a second hook exists but is inactive; re-enabling doubles delivery
      disjoint  several hooks on one URL that deliberately split the events
    """
    by_endpoint = {}
    for h in hooks or []:
        by_endpoint.setdefault(endpoint(h.get("url")), []).append(h)

    rows = []
    for target, members in sorted(by_endpoint.items()):
        active = [m for m in members if m.get("active", True)]
        shared = []
        for i, first in enumerate(active):
            for second in active[i + 1:]:
                shared.extend(e for e in overlap(first.get("events"),
                                                 second.get("events"))
                              if e not in shared)
        if len(members) == 1:
            state = "unique"
        elif len(active) < 2:
            state = "latent"
        elif shared:
            state = "duplicate"
        else:
            state = "disjoint"
        rows.append({"endpoint": target, "state": state, "hooks": members,
                     "shared": sorted(shared)})
    return rows


def guid_pairs(logs):
    """Do the copies share a delivery guid? Pure.

    logs: {source: [delivery, ...]} for one endpoint. Returns counts of guids
    seen under more than one source, and of (event, minute) slots covered by two
    sources under different guids. The first says guid-based idempotency already
    handles this; the second says it does not and the key has to come from the
    payload.
    """
    sources_by_guid = {}
    slots = {}
    for source, deliveries in (logs or {}).items():
        for d in deliveries or []:
            guid = d.get("guid")
            if guid:
                sources_by_guid.setdefault(guid, set()).add(source)
            when = str(d.get("delivered_at") or "")[:16]
            if when:
                slots.setdefault((str(d.get("event") or ""), when), {})[source] = guid
    shared = sum(1 for s in sources_by_guid.values() if len(s) > 1)
    twinned = sum(1 for seen in slots.values()
                  if len(seen) > 1 and len(set(seen.values())) > 1)
    return {"shared_guids": shared, "same_event_different_guid": twinned}


def next_link(response):
    """The rel=next URL from the Link header, or None."""
    for part in (response.headers.get("Link") or "").split(","):
        chunk = part.strip()
        if chunk.startswith("<") and chunk.endswith('rel="next"'):
            return chunk[1:chunk.index(">")]
    return None


def get(session, url, **params):
    r = session.get(url, params=params, timeout=30)
    if r.status_code == 401:
        raise SystemExit("401 from GitHub: GITHUB_TOKEN is missing, expired or "
                         "malformed")
    if r.status_code in (403, 404):
        raise SystemExit("%d from %s: repository hooks need admin:repo_hook and "
                         "organization hooks need admin:org_hook; GitHub answers "
                         "404 rather than 403 when the token cannot see the "
                         "resource" % (r.status_code, url))
    r.raise_for_status()
    return r


def page(session, url, limit=500, **params):
    out = []
    while url and len(out) < limit:
        r = get(session, url, **params)
        out.extend(r.json())
        url, params = next_link(r), {}
    return out[:limit]


def collect(session, scopes):
    """Flatten every hook from every scope into the shape group() expects."""
    hooks = []
    for label, base in scopes:
        for h in page(session, base, per_page=100):
            hooks.append({
                "source": label,
                "base": base,
                "id": h.get("id"),
                "url": (h.get("config") or {}).get("url"),
                "events": h.get("events") or [],
                "active": bool(h.get("active", True)),
            })
    return hooks


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--org", action="append", default=[],
                    help="organization login; repeat for several orgs")
    ap.add_argument("--repo", action="append", default=[],
                    help="owner/name; repeat for several repositories")
    ap.add_argument("--max-deliveries", type=int, default=100,
                    help="deliveries to read per hook when checking whether the "
                         "copies share a guid (0 to skip)")
    args = ap.parse_args()

    if not (args.org or args.repo):
        log.error("pass at least one --org login or --repo owner/name")
        return 2

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (a read-only token is enough)")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    scopes = []
    for org in args.org:
        scopes.append(("org " + org, "%s/orgs/%s/hooks" % (API, org)))
    for repo in args.repo:
        owner, _, name = repo.partition("/")
        if not (owner and name):
            log.error("--repo takes owner/name, for example acme/api")
            return 2
        scopes.append(("repo " + repo, "%s/repos/%s/%s/hooks" % (API, owner, name)))

    hooks = collect(session, scopes)
    rows = group(hooks)

    duplicated = latent = 0
    for row in rows:
        members = ", ".join("%s#%s%s" % (m["source"], m["id"],
                                         "" if m["active"] else " (inactive)")
                            for m in row["hooks"])
        line = "%-10s %s  %s" % (row["state"], row["endpoint"] or "?", members)
        if row["state"] in ("unique", "disjoint"):
            log.info(line)
            if row["state"] == "disjoint":
                log.info("  no shared events: a deliberate split, not a duplicate")
            continue

        log.warning(line)
        if row["state"] == "latent":
            latent += 1
            log.warning("  only one hook is active. Re-enabling the other "
                        "doubles delivery of: %s",
                        ", ".join(overlap(row["hooks"][0]["events"],
                                          row["hooks"][-1]["events"])) or "nothing")
            continue

        duplicated += 1
        log.warning("  delivered twice: %s", ", ".join(row["shared"]))
        if args.max_deliveries:
            logs = {}
            for m in row["hooks"]:
                logs[m["source"]] = page(
                    session, "%s/%s/deliveries" % (m["base"], m["id"]),
                    limit=args.max_deliveries, per_page=100)
            pairs = guid_pairs(logs)
            log.warning("  %d guid(s) seen under more than one hook, %d event(s) "
                        "arriving twice under different guids",
                        pairs["shared_guids"], pairs["same_event_different_guid"])
            if pairs["same_event_different_guid"]:
                log.warning("  deduplicating on X-GitHub-Delivery will not catch "
                            "these; key the side effect on something in the "
                            "payload instead")
        log.warning("  repair: keep one source of truth and delete the other "
                    "hook by hand (DELETE is not something this script will do)")

    log.info("%d hook(s) across %d endpoint(s), %d duplicated, %d latent",
             len(hooks), len(rows), duplicated, latent)
    return 1 if duplicated else 0


if __name__ == "__main__":
    sys.exit(main())
github-duplicate-hooks.mjs
/**
 * Find one webhook URL registered by more than one GitHub hook.
 *
 * Read only. Org hooks and repo hooks are independent objects, so the same URL
 * can be registered in both scopes and every overlapping event is delivered
 * twice. The script prints which hook to remove; it never removes one.
 */
const API = 'https://api.github.com';
const UA = 'github-duplicate-hooks/1.0';

// A hook subscribed to "*" receives every event type, so it intersects with
// anything the other hook on the same URL carries.
const WILDCARD = '*';

/**
 * Reduce a webhook URL to lowercase host plus path. Pure. Two hooks created
 * years apart differ cosmetically far more often than meaningfully, and a raw
 * string comparison across them reports a clean account.
 */
export function endpoint(url) {
  if (!url) return '';
  let parsed;
  try {
    parsed = new URL(String(url).trim());
  } catch {
    return String(url).trim().toLowerCase().replace(/\/+$/, '');
  }
  const host = parsed.hostname.toLowerCase();
  const port = (parsed.port && parsed.port !== '80' && parsed.port !== '443')
    ? `:${parsed.port}` : '';
  return host + port + parsed.pathname.replace(/\/+$/, '');
}

/** Events both hooks carry, sorted. Pure. A wildcard overlaps everything. */
export function overlap(a, b) {
  const sa = new Set(a ?? []);
  const sb = new Set(b ?? []);
  if (sa.has(WILDCARD) && sb.has(WILDCARD)) return [WILDCARD];
  if (sa.has(WILDCARD)) return [...sb].sort();
  if (sb.has(WILDCARD)) return [...sa].sort();
  return [...sa].filter((e) => sb.has(e)).sort();
}

/**
 * Group hooks by endpoint and classify each group. Pure. States: unique,
 * duplicate, latent, disjoint.
 */
export function group(hooks) {
  const byEndpoint = new Map();
  for (const h of hooks ?? []) {
    const key = endpoint(h.url);
    if (!byEndpoint.has(key)) byEndpoint.set(key, []);
    byEndpoint.get(key).push(h);
  }

  const rows = [];
  for (const target of [...byEndpoint.keys()].sort()) {
    const members = byEndpoint.get(target);
    const active = members.filter((m) => m.active !== false);
    const shared = [];
    for (let i = 0; i < active.length; i += 1) {
      for (let j = i + 1; j < active.length; j += 1) {
        for (const e of overlap(active[i].events, active[j].events)) {
          if (!shared.includes(e)) shared.push(e);
        }
      }
    }
    let state;
    if (members.length === 1) state = 'unique';
    else if (active.length < 2) state = 'latent';
    else if (shared.length) state = 'duplicate';
    else state = 'disjoint';
    rows.push({ endpoint: target, state, hooks: members, shared: shared.sort() });
  }
  return rows;
}

/**
 * Do the copies share a delivery guid? Pure. logs is {source: [delivery, ...]}
 * for one endpoint.
 */
export function guidPairs(logs) {
  const sourcesByGuid = new Map();
  const slots = new Map();
  for (const [source, deliveries] of Object.entries(logs ?? {})) {
    for (const d of deliveries ?? []) {
      if (d.guid) {
        if (!sourcesByGuid.has(d.guid)) sourcesByGuid.set(d.guid, new Set());
        sourcesByGuid.get(d.guid).add(source);
      }
      const when = String(d.delivered_at ?? '').slice(0, 16);
      if (when) {
        const key = `${d.event ?? ''}@${when}`;
        if (!slots.has(key)) slots.set(key, new Map());
        slots.get(key).set(source, d.guid);
      }
    }
  }
  let shared = 0;
  for (const sources of sourcesByGuid.values()) if (sources.size > 1) shared += 1;
  let twinned = 0;
  for (const seen of slots.values()) {
    if (seen.size > 1 && new Set(seen.values()).size > 1) twinned += 1;
  }
  return { shared_guids: shared, same_event_different_guid: twinned };
}

function nextLink(res) {
  for (const part of (res.headers.get('link') ?? '').split(',')) {
    const chunk = part.trim();
    if (chunk.startsWith('<') && chunk.endsWith('rel="next"')) {
      return chunk.slice(1, chunk.indexOf('>'));
    }
  }
  return null;
}

async function get(token, url) {
  const res = await fetch(url, {
    headers: {
      Authorization: `Bearer ${token}`,
      Accept: 'application/vnd.github+json',
      'X-GitHub-Api-Version': '2022-11-28',
      'User-Agent': UA,
    },
  });
  if (res.status === 401) {
    throw new Error('401 from GitHub: GITHUB_TOKEN is missing, expired or malformed');
  }
  if (res.status === 403 || res.status === 404) {
    throw new Error(`${res.status} from ${url}: repository hooks need ` +
      'admin:repo_hook and organization hooks need admin:org_hook; GitHub ' +
      'answers 404 rather than 403 when the token cannot see the resource');
  }
  if (!res.ok) throw new Error(`${res.status} from ${url}`);
  return res;
}

async function page(token, url, limit = 500) {
  const out = [];
  let next = url;
  while (next && out.length < limit) {
    const res = await get(token, next);
    out.push(...(await res.json()));
    next = nextLink(res);
  }
  return out.slice(0, limit);
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    console.error('set GITHUB_TOKEN (a read-only token is enough)');
    process.exitCode = 2;
    return;
  }

  const scopes = [];
  for (const arg of process.argv.slice(2)) {
    if (arg.includes('/')) scopes.push([`repo ${arg}`, `${API}/repos/${arg}/hooks`]);
    else scopes.push([`org ${arg}`, `${API}/orgs/${arg}/hooks`]);
  }
  if (scopes.length === 0) {
    console.error('usage: node github-duplicate-hooks.mjs acme acme/api acme/web');
    process.exitCode = 2;
    return;
  }

  const hooks = [];
  for (const [source, base] of scopes) {
    for (const h of await page(token, `${base}?per_page=100`)) {
      hooks.push({ source, base, id: h.id, url: h.config?.url,
        events: h.events ?? [], active: h.active !== false });
    }
  }

  const rows = group(hooks);
  let duplicated = 0;
  let latent = 0;
  for (const row of rows) {
    const members = row.hooks
      .map((m) => `${m.source}#${m.id}${m.active ? '' : ' (inactive)'}`).join(', ');
    const line = `${row.state.padEnd(10)} ${row.endpoint || '?'}  ${members}`;
    if (row.state === 'unique' || row.state === 'disjoint') {
      console.log(line);
      if (row.state === 'disjoint') {
        console.log('  no shared events: a deliberate split, not a duplicate');
      }
      continue;
    }

    console.warn(line);
    if (row.state === 'latent') {
      latent += 1;
      const would = overlap(row.hooks[0].events, row.hooks[row.hooks.length - 1].events);
      console.warn('  only one hook is active. Re-enabling the other doubles ' +
        `delivery of: ${would.join(', ') || 'nothing'}`);
      continue;
    }

    duplicated += 1;
    console.warn(`  delivered twice: ${row.shared.join(', ')}`);
    const logs = {};
    for (const m of row.hooks) {
      logs[m.source] = await page(token,
        `${m.base}/${m.id}/deliveries?per_page=100`, 100);
    }
    const pairs = guidPairs(logs);
    console.warn(`  ${pairs.shared_guids} guid(s) seen under more than one hook, ` +
      `${pairs.same_event_different_guid} event(s) arriving twice under different guids`);
    if (pairs.same_event_different_guid) {
      console.warn('  deduplicating on X-GitHub-Delivery will not catch these; ' +
        'key the side effect on something in the payload instead');
    }
    console.warn('  repair: keep one source of truth and delete the other hook ' +
      'by hand (removal is not something this script will do)');
  }

  console.log(`${hooks.length} hook(s) across ${rows.length} endpoint(s), ` +
    `${duplicated} duplicated, ${latent} latent`);
  process.exitCode = duplicated ? 1 : 0;
}

// Only run when invoked directly, so importing this module from the test file
// does not run main(), fail on the missing token, and fail the test file with it.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The two failure modes of a duplicate report are pinned here: missing a real duplicate because one URL had a trailing slash, and flagging a pair of hooks that deliberately split the events between them. The inactive case has its own state because a disabled second hook is not delivering anything today and is one toggle away from delivering everything twice.

test_github_duplicate_hooks.py
from github_duplicate_hooks import endpoint, group, guid_pairs, overlap


def hook(source, url, events, active=True, hid=1):
    return {"source": source, "id": hid, "url": url, "events": events,
            "active": active}


def test_endpoint_ignores_the_ways_two_urls_differ_cosmetically():
    same = "hooks.example.com/gh"
    assert endpoint("https://hooks.example.com/gh") == same
    assert endpoint("https://hooks.example.com/gh/") == same
    assert endpoint("HTTPS://Hooks.Example.com/gh") == same
    assert endpoint("http://hooks.example.com/gh?token=x") == same
    assert endpoint("https://hooks.example.com:8443/gh") == "hooks.example.com:8443/gh"
    assert endpoint(None) == ""


def test_overlap_treats_a_wildcard_as_covering_everything():
    assert overlap(["push"], ["push", "issues"]) == ["push"]
    assert overlap(["*"], ["push", "issues"]) == ["issues", "push"]
    assert overlap(["*"], ["*"]) == ["*"]
    assert overlap(["push"], ["issues"]) == []


def test_one_url_in_two_scopes_with_shared_events_is_the_finding():
    rows = group([hook("org acme", "https://hooks.example.com/gh", ["push"], hid=1),
                  hook("repo acme/api", "https://hooks.example.com/gh/",
                       ["push", "issues"], hid=2)])
    assert len(rows) == 1
    assert rows[0]["state"] == "duplicate"
    assert rows[0]["shared"] == ["push"]


def test_a_deliberate_split_is_not_reported_as_a_duplicate():
    rows = group([hook("org acme", "https://hooks.example.com/gh", ["push"], hid=1),
                  hook("repo acme/api", "https://hooks.example.com/gh",
                       ["issues"], hid=2)])
    assert rows[0]["state"] == "disjoint"
    assert rows[0]["shared"] == []


def test_an_inactive_second_hook_is_latent_rather_than_duplicate():
    rows = group([hook("org acme", "https://hooks.example.com/gh", ["push"], hid=1),
                  hook("repo acme/api", "https://hooks.example.com/gh", ["push"],
                       active=False, hid=2)])
    assert rows[0]["state"] == "latent"


def test_a_single_hook_is_unique():
    rows = group([hook("repo acme/api", "https://hooks.example.com/gh", ["push"])])
    assert rows[0]["state"] == "unique"


def test_guid_pairs_says_whether_delivery_id_dedup_would_help():
    shared = guid_pairs({
        "org acme": [{"guid": "g1", "event": "push",
                      "delivered_at": "2026-08-01T10:00:03Z"}],
        "repo acme/api": [{"guid": "g1", "event": "push",
                           "delivered_at": "2026-08-01T10:00:03Z"}],
    })
    assert shared["shared_guids"] == 1
    assert shared["same_event_different_guid"] == 0

    split = guid_pairs({
        "org acme": [{"guid": "g1", "event": "push",
                      "delivered_at": "2026-08-01T10:00:03Z"}],
        "repo acme/api": [{"guid": "g2", "event": "push",
                           "delivered_at": "2026-08-01T10:00:04Z"}],
    })
    assert split["shared_guids"] == 0
    assert split["same_event_different_guid"] == 1
github-duplicate-hooks.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  endpoint, group, guidPairs, overlap,
} from './github-duplicate-hooks.mjs';

const hook = (source, url, events, active = true, id = 1) =>
  ({ source, id, url, events, active });

test('endpoint ignores the ways two urls differ cosmetically', () => {
  const same = 'hooks.example.com/gh';
  assert.equal(endpoint('https://hooks.example.com/gh'), same);
  assert.equal(endpoint('https://hooks.example.com/gh/'), same);
  assert.equal(endpoint('HTTPS://Hooks.Example.com/gh'), same);
  assert.equal(endpoint('http://hooks.example.com/gh?token=x'), same);
  assert.equal(endpoint('https://hooks.example.com:8443/gh'),
    'hooks.example.com:8443/gh');
  assert.equal(endpoint(null), '');
});

test('overlap treats a wildcard as covering everything', () => {
  assert.deepEqual(overlap(['push'], ['push', 'issues']), ['push']);
  assert.deepEqual(overlap(['*'], ['push', 'issues']), ['issues', 'push']);
  assert.deepEqual(overlap(['*'], ['*']), ['*']);
  assert.deepEqual(overlap(['push'], ['issues']), []);
});

test('one url in two scopes with shared events is the finding', () => {
  const rows = group([
    hook('org acme', 'https://hooks.example.com/gh', ['push'], true, 1),
    hook('repo acme/api', 'https://hooks.example.com/gh/', ['push', 'issues'], true, 2),
  ]);
  assert.equal(rows.length, 1);
  assert.equal(rows[0].state, 'duplicate');
  assert.deepEqual(rows[0].shared, ['push']);
});

test('a deliberate split is not reported as a duplicate', () => {
  const rows = group([
    hook('org acme', 'https://hooks.example.com/gh', ['push'], true, 1),
    hook('repo acme/api', 'https://hooks.example.com/gh', ['issues'], true, 2),
  ]);
  assert.equal(rows[0].state, 'disjoint');
  assert.deepEqual(rows[0].shared, []);
});

test('an inactive second hook is latent rather than duplicate', () => {
  const rows = group([
    hook('org acme', 'https://hooks.example.com/gh', ['push'], true, 1),
    hook('repo acme/api', 'https://hooks.example.com/gh', ['push'], false, 2),
  ]);
  assert.equal(rows[0].state, 'latent');
});

test('a single hook is unique', () => {
  const rows = group([hook('repo acme/api', 'https://hooks.example.com/gh', ['push'])]);
  assert.equal(rows[0].state, 'unique');
});

test('guidPairs says whether delivery id dedup would help', () => {
  const shared = guidPairs({
    'org acme': [{ guid: 'g1', event: 'push', delivered_at: '2026-08-01T10:00:03Z' }],
    'repo acme/api': [{ guid: 'g1', event: 'push', delivered_at: '2026-08-01T10:00:03Z' }],
  });
  assert.equal(shared.shared_guids, 1);
  assert.equal(shared.same_event_different_guid, 0);

  const split = guidPairs({
    'org acme': [{ guid: 'g1', event: 'push', delivered_at: '2026-08-01T10:00:03Z' }],
    'repo acme/api': [{ guid: 'g2', event: 'push', delivered_at: '2026-08-01T10:00:04Z' }],
  });
  assert.equal(split.shared_guids, 0);
  assert.equal(split.same_event_different_guid, 1);
});

FAQ

Why does GitHub deliver the same event twice?

It does not. It delivers once per hook that subscribes to the event, and two hooks subscribed. Organization webhooks and repository webhooks are independent objects with independent event lists, so a URL registered in both scopes receives one copy from each and neither hook looks wrong on its own.

Will deduplicating on X-GitHub-Delivery fix it?

It fixes retries and redeliveries, which reuse a delivery's guid. Whether the org copy and the repo copy of one event carry the same guid is worth measuring rather than assuming, which is why the script reads both delivery logs and reports the answer for your account. If the same event arrives under two different guids, the idempotency key has to come from the payload instead.

Two hooks point at one URL but nothing is duplicated. Is that a problem?

Not if their event arrays are disjoint, which is a legitimate way to split traffic between scopes. The script reports that as disjoint and moves on. The finding is the intersection of the event sets, not the shared URL.

Does the script delete the redundant hook?

No. This section is read only, so it names the hook, its scope and its id, and leaves the removal to you. Deleting the wrong one of two identical-looking hooks stops delivery entirely, which is a worse outage than the duplicates.

What about a GitHub App as a third source?

An App's webhook is configured on the App itself, not per installation, and is read through GET /app/hook/config with the App's JWT rather than a repository token. A repository token cannot see it, so if an App is installed on these repositories, count its webhook as another potential copy of the same events.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.