Diagnostic GitHub API
the GitHub App has no webhook URL configured
The App is installed on forty repositories, the permissions were approved, the event subscriptions are exactly right, and it has never once reacted to anything. There is nothing in the delivery log to read, no failures to group, no status codes to explain. Nothing is failing, because nothing is being attempted.
A GitHub App's webhook is configured on the App, not on each installation, and it is a separate thing from the App's event subscriptions. You can subscribe an App to a dozen events and leave the destination blank, and GitHub will not complain: there is simply nowhere to deliver to.
Authenticate as the App with a JWT and read GET /app/hook/config, which returns url, content_type, insecure_ssl and a masked secret. An empty url is the finding outright. A url that still points at smee.io, localhost or example.com is the same finding wearing a disguise, and it is the more common one, because those are what the tutorials put there. Corroborate with GET /app/hook/deliveries: an empty list where the App subscribes to busy events says the destination has never worked. Pair it with GET /app, which lists the events the App is subscribed to, because an App subscribed to nothing has a different problem.
The problem in plain words
Every webhook diagnostic anybody knows starts with the delivery log, and this one has nothing in it. That is not a subtle signal; it is the absence of a signal, and absence gets read as "we must be looking in the wrong place". So the search moves outward. People check the installation, the permissions, the event subscriptions, whether the App is suspended, whether the customer really did install it on that repository. All of those are readable, all of them come back correct, and none of them is the destination.
The reason the destination gets skipped is that it lives somewhere nobody is looking. Repository webhooks are configured on the repository, and that is where a decade of habit says to look. An App's webhook is configured once, on the App itself, in a settings page most of the team has never opened and which is not visible from any installation. Reading it through the API needs a JWT signed with the App's private key, which is a different credential from the installation token everything else uses, so the check is not one that falls out of normal work.
The version that costs the most is not a blank field. It is https://smee.io/ followed by a random string, put there by the quickstart during the first hour of development, working perfectly on a laptop, and never changed because it never broke. The App ships. Events flow to a proxy that nobody is listening to. It looks configured, it passes a glance, and it is exactly as broken as a blank field while being much harder to see.
Why it happens
The webhook is App-level, and so is the blast radius. One url, one secret, one content type, shared by every installation of the App. That is convenient — you fix it once for every customer — and it is why the failure is total rather than partial. There is no installation you can compare against a working one, because they all point at the same nothing.
Subscriptions and destination are independent. GET /app lists the events the App is subscribed to. GET /app/hook/config holds where they go. Neither validates the other, so an App can be subscribed to pull_request, push and issues with nowhere to send them, and nothing in the interface objects. If the events array is empty as well, that is a different note, and this script says which of the two you are looking at instead of merging them.
A placeholder is a finding, not a URL. smee.io, localhost, ngrok and example.com are the four ways this actually happens, and they all pass any check that only asks whether the field is empty. The script classifies the host rather than testing for blankness, because "there is a URL there" is the sentence that ends most investigations of this prematurely.
An empty delivery list corroborates and does not prove. Deliveries are retained for a limited window, so an empty list can also mean a quiet App or a long weekend. It is evidence when read next to the subscriptions: no deliveries and a subscription to push on forty active repositories is conclusive; no deliveries on an App subscribed to member is a Tuesday. The script reports the two together and never treats emptiness alone as the finding.
The JWT is the price of admission, and the script does not mint it. Reading an App's own configuration requires authenticating as the App, which an installation token cannot do. The script takes a JWT from the environment rather than loading a private key and signing one, so the key never enters this process. That is a smaller program and a much smaller thing to have in your history.
The fix, as a flow
Three GETs against the App itself, authenticated with a JWT the script takes from the environment rather than signing, so the private key never enters the process. The classifier is the part that matters: it sorts unset, malformed, placeholder, tunnel, loopback and plain http before it will call anything a production destination, because every expensive version of this problem has a URL in the field. The delivery log corroborates and is only read next to the subscription list, never on its own.
How to fix it
Authenticate as the App, not as an installation
Everything here needs a JWT signed with the App's private key. An installation access token — the one the rest of your code uses — gets a 403 on these endpoints, and that 403 is not a permissions problem to chase. Mint the JWT wherever you already mint them and hand it to the script through the environment.
Read the destination
GET /app/hook/config returns url, content_type, insecure_ssl and secret, with the secret masked when set. An empty url is the finding immediately. Anything else needs the host looked at rather than the field checked for emptiness.
Classify the host instead of testing for blank
smee.io and ngrok are development proxies; localhost and 127.0.0.1 are unreachable from GitHub entirely; example.com is a placeholder from a template. Each of them is as broken as an empty field and none of them looks it. A plain http:// host is a separate and worse problem covered in its own note, and the script names it rather than absorbing it.
Read the subscriptions and the deliveries together
GET /app gives the events the App is subscribed to and how many installations it has. GET /app/hook/deliveries gives what has actually arrived. Empty deliveries plus busy subscriptions plus installations is conclusive. Empty deliveries plus an empty events array is a different finding entirely, and one this script hands off rather than reporting as a broken URL.
Set the destination, then prove it with the delivery log
Point the App's webhook at the production receiver, set a secret, set content_type to json, and then come back to GET /app/hook/deliveries and watch events appear. That last read is the only confirmation that means anything: the settings page will happily show you a URL that nothing can reach.
How to check it worked
One JWT and three GETs. Nothing is created and nothing is redelivered.
GITHUB_APP_JWT=$APP_JWT python3 github_app_hook_config.py
# app: acme-deploy-bot, 41 installation(s), subscribed to 4 event(s)
# hook config: url=https://smee.io/aB3xQ9pLm content_type=form secret=set
# tunnel-url: the App delivers to smee.io, which is a development proxy from
# the quickstart. Every event goes to a channel nobody is listening to.
# deliveries: 128 in the retained window, most recent 2026-08-30
# repair: point the App's webhook at the production receiver, keep the secret,
# and set content_type to json.
# and the blank case, which is quieter
# no-url-subscribed: the App subscribes to 4 events and has no webhook URL, so
# nothing is delivered and nothing fails. There is no log to read.
The full code
The interesting function is the one that classifies a host, because everything this note is about is the difference between a field that is empty and a field that is full of something useless. It sorts unset, malformed, placeholder, tunnel, loopback and plain-http before it will call anything a production destination, and the ordering matters: http://localhost:3000 is a loopback problem rather than a transport one, and saying so sends the reader to the right repair. The delivery evidence is deliberately weak on its own and only firms up next to the subscription list.
"""Say whether a GitHub App has a webhook destination that can work.
Read only. Three GETs against the App itself: its own record, its webhook
configuration, and a page of its deliveries. Nothing is created or changed.
A GitHub App's webhook lives on the App rather than on each installation, and it
is independent of the App's event subscriptions. It can be blank, or left
pointing at the smee.io proxy the quickstart hands out, and nothing complains:
there are no failed deliveries where there are no deliveries at all.
Authentication is a JWT signed with the App's private key. This script takes the
JWT from the environment and never loads or signs with the key, so the key never
enters this process.
Environment:
GITHUB_APP_JWT a JWT signed with the App's private key
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime, timezone
from urllib.parse import urlsplit
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_app_hook_config")
API = "https://api.github.com"
UA = "github-app-hook-config/1.0"
# The four ways this actually happens. All of them pass a check that only asks
# whether the field is empty, which is why the host is classified instead.
PLACEHOLDER_HOSTS = ("example.com", "example.org", "example.net",
"your-domain.com", "yourdomain.com", "changeme", "todo")
TUNNEL_HOSTS = ("smee.io", "ngrok.io", "ngrok-free.app", "ngrok.app",
"loca.lt", "trycloudflare.com", "serveo.net")
LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "::1")
# Past this, a destination that has delivered before has gone quiet.
DEFAULT_STALE_DAYS = 30
def host_of(url):
"""The lowercase hostname of a URL, or an empty string. Pure."""
try:
parts = urlsplit(str(url or "").strip())
except ValueError:
return ""
return (parts.hostname or "").lower()
def host_matches(host, suffixes):
"""Whether a host is one of these names or a subdomain of one. Pure."""
for suffix in suffixes:
if host == suffix or host.endswith("." + suffix):
return True
return False
def url_class(url):
"""Sort a webhook destination into what it can actually reach. Pure.
Ordered so the most specific reason wins: http://localhost is a loopback
problem, not a transport one, and sending the reader to the transport note
would waste their afternoon.
"""
raw = str(url or "").strip()
if not raw:
return "unset"
parts = urlsplit(raw)
host = host_of(raw)
if parts.scheme not in ("http", "https") or not host:
return "malformed"
if host_matches(host, PLACEHOLDER_HOSTS) or host.startswith("example."):
return "placeholder"
if host_matches(host, TUNNEL_HOSTS):
return "tunnel"
if host in LOOPBACK_HOSTS or host.endswith(".local"):
return "loopback"
if parts.scheme == "http":
return "insecure"
return "production"
def secret_state(config):
"""Whether a secret is set. Never returns the value. Pure."""
if not isinstance(config, dict):
return "unknown"
return "set" if config.get("secret") is not None else "absent"
def content_type_of(config):
"""The App hook's body encoding, with the documented default applied. Pure."""
if not isinstance(config, dict):
return "unknown"
raw = config.get("content_type")
if raw is None:
return "form"
value = str(raw).strip().lower()
return value if value in ("json", "form") else "unknown"
def subscribed_events(app):
"""The events the App is subscribed to. Pure."""
events = (app or {}).get("events") if isinstance(app, dict) else None
return [str(e) for e in events] if isinstance(events, list) else []
def parse_time(text):
"""An ISO 8601 timestamp as an aware datetime, or None. Pure."""
raw = str(text or "").strip()
if not raw:
return None
if raw.endswith("Z"):
raw = raw[:-1] + "+00:00"
try:
moment = datetime.fromisoformat(raw)
except ValueError:
return None
return moment if moment.tzinfo else moment.replace(tzinfo=timezone.utc)
def last_delivery(deliveries):
"""The most recent delivered_at in a delivery list, or None. Pure."""
stamps = []
for record in deliveries or []:
if not isinstance(record, dict):
continue
moment = parse_time(record.get("delivered_at"))
if moment is not None:
stamps.append(moment)
return max(stamps) if stamps else None
def delivery_state(deliveries, now, stale_days=DEFAULT_STALE_DAYS):
"""Whether anything has arrived recently. Corroboration, never proof. Pure.
Deliveries are retained for a limited window, so an empty list can also mean
a quiet App. This is only worth reading next to the subscription list.
"""
if deliveries is None:
return "unknown"
if not deliveries:
return "none"
latest = last_delivery(deliveries)
if latest is None or now is None:
return "unknown"
days = int((now - latest).total_seconds() // 86400)
return "stale" if days >= int(stale_days) else "recent"
def verdict(url, events, deliveries_state, installations=None):
"""Turn the destination, the subscriptions and the log into a finding. Pure."""
klass = url_class(url)
count = len(events or [])
if klass == "unset":
if count:
return ("no-url-subscribed",
"the App subscribes to %d event(s) and has no webhook URL, "
"so nothing is delivered and nothing fails. There is no log "
"to read because there are no deliveries." % count)
return ("no-url",
"the App has no webhook URL and subscribes to no events. That "
"is a coherent configuration for an App that only polls or "
"creates its own repository hooks, so this is reported rather "
"than judged.")
if klass == "malformed":
return ("malformed-url",
"the webhook URL is not a usable http or https URL, so no "
"delivery can be attempted against it.")
if klass == "placeholder":
return ("placeholder-url",
"the webhook URL points at a placeholder host from a template. "
"It looks configured and it reaches nothing you own.")
if klass == "tunnel":
return ("tunnel-url",
"the App delivers to a development proxy from the quickstart. "
"Every event goes to a channel nobody is listening to, and the "
"field looks filled in to anyone glancing at it.")
if klass == "loopback":
return ("loopback-url",
"the webhook URL is a loopback or link-local address, which "
"GitHub cannot reach from the internet at all.")
if klass == "insecure":
return ("insecure-url",
"the App delivers over plain http, so payloads and signatures "
"cross the network in the clear. Deliveries do arrive, which is "
"why this survives so long.")
if deliveries_state == "none" and count:
return ("no-deliveries",
"the URL looks like a real destination and the App subscribes "
"to %d event(s), but nothing has been delivered in the retained "
"window. Either the events have genuinely not happened or the "
"destination has never worked." % count)
if deliveries_state == "stale" and count:
return ("silent",
"the destination has delivered before and has gone quiet. That "
"is a receiver or subscription question rather than a "
"configuration one.")
if not count:
return ("no-events",
"the webhook URL is a real destination but the App subscribes "
"to no events, so nothing will ever be sent to it. That is a "
"subscription finding, not a URL one.")
return ("delivering",
"the App has a real destination, subscribes to %d event(s), and "
"events are arriving." % count)
def repair(state):
"""The sentence a reader has to act on. Pure."""
if state in ("no-url-subscribed", "placeholder-url", "tunnel-url",
"loopback-url", "malformed-url"):
return ("point the App's webhook at the production receiver, set a "
"secret, set content_type to json, and then confirm with GET "
"/app/hook/deliveries that events start arriving. The settings "
"page will show a URL that nothing can reach.")
if state == "insecure-url":
return ("move the destination to https before anything else. The "
"payload and its signature are readable in transit today.")
if state == "no-deliveries":
return ("check the receiver is reachable from the internet, then wait "
"for an event you can cause on purpose and read the delivery "
"log again. An empty log alone is not proof of anything.")
if state == "no-events":
return ("subscribe the App to the events it handles. The destination is "
"fine and there is nothing being sent to it.")
if state == "no-url":
return ("nothing, if the App is meant to poll or manage its own "
"repository hooks. If it is meant to react to events, this is "
"the whole problem.")
if state == "silent":
return ("look at the receiver and the subscription list rather than the "
"URL, which is working.")
return "nothing."
def get(session, path):
"""One GET. Returns (status, json-or-None)."""
r = session.get(API + path, timeout=30)
try:
body = r.json()
except ValueError:
body = None
return r.status_code, body
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--stale-days", type=int, default=DEFAULT_STALE_DAYS,
help="days without a delivery before a destination is quiet")
args = ap.parse_args()
jwt = os.environ.get("GITHUB_APP_JWT")
if not jwt:
log.error("set GITHUB_APP_JWT to a JWT signed with the App's private "
"key. An installation token cannot read the App's own "
"configuration and will answer 403 here")
return 2
session = requests.Session()
session.headers.update({
"Authorization": "Bearer " + jwt,
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
"User-Agent": UA,
})
status, app = get(session, "/app")
if status != 200 or not isinstance(app, dict):
log.error("GET /app returned %d; the JWT is not being accepted as an App", status)
return 2
events = subscribed_events(app)
log.info("app: %s, %s installation(s), subscribed to %d event(s)",
app.get("slug"), app.get("installations_count"), len(events))
status, config = get(session, "/app/hook/config")
if status != 200 or not isinstance(config, dict):
log.error("GET /app/hook/config returned %d", status)
return 2
url = config.get("url")
log.info("hook config: url=%s content_type=%s secret=%s",
url or "(empty)", content_type_of(config), secret_state(config))
status, deliveries = get(session, "/app/hook/deliveries?per_page=100")
records = deliveries if status == 200 and isinstance(deliveries, list) else None
now = datetime.now(timezone.utc)
state_of_log = delivery_state(records, now, args.stale_days)
latest = last_delivery(records or [])
log.info("deliveries: %s in the retained window, most recent %s",
len(records) if records is not None else "unreadable",
str(latest)[:10] if latest else "none")
state, detail = verdict(url, events, state_of_log, app.get("installations_count"))
log.info("%s: %s", state, detail)
log.info("repair: %s", repair(state))
print(json.dumps({
"app": app.get("slug"),
"installations": app.get("installations_count"),
"events": events,
"hook_url": url,
"url_class": url_class(url),
"content_type": content_type_of(config),
"secret": secret_state(config),
"deliveries_retained": len(records) if records is not None else None,
"last_delivery": str(latest) if latest else None,
"delivery_state": state_of_log,
"state": state,
"detail": detail,
"repair": repair(state),
}, indent=2, default=str))
return 1 if state in ("no-url-subscribed", "placeholder-url", "tunnel-url",
"loopback-url", "malformed-url", "insecure-url") else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Say whether a GitHub App has a webhook destination that can work.
*
* Read only. Three GETs against the App itself: its own record, its webhook
* configuration, and a page of its deliveries. Nothing is created or changed.
*
* Authentication is a JWT signed with the App's private key, taken from the
* environment. The key never enters this process.
*
* Environment:
* GITHUB_APP_JWT a JWT signed with the App's private key
*/
const API = 'https://api.github.com';
const UA = 'github-app-hook-config/1.0';
/** The four ways this actually happens, none of which is an empty field. */
export const PLACEHOLDER_HOSTS = ['example.com', 'example.org', 'example.net',
'your-domain.com', 'yourdomain.com', 'changeme', 'todo'];
export const TUNNEL_HOSTS = ['smee.io', 'ngrok.io', 'ngrok-free.app', 'ngrok.app',
'loca.lt', 'trycloudflare.com', 'serveo.net'];
export const LOOPBACK_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', '::1'];
export const DEFAULT_STALE_DAYS = 30;
/** The lowercase hostname of a URL, or an empty string. Pure. */
export function hostOf(url) {
try {
return new URL(String(url ?? '').trim()).hostname.toLowerCase().replace('[', '').replace(']', '');
} catch {
return '';
}
}
/** Whether a host is one of these names or a subdomain of one. Pure. */
export function hostMatches(host, suffixes) {
return (suffixes || []).some((suffix) => host === suffix || host.endsWith(`.${suffix}`));
}
/** Sort a webhook destination into what it can actually reach. Pure. */
export function urlClass(url) {
const raw = String(url ?? '').trim();
if (!raw) return 'unset';
let parsed = null;
try { parsed = new URL(raw); } catch { return 'malformed'; }
const host = hostOf(raw);
if (!['http:', 'https:'].includes(parsed.protocol) || !host) return 'malformed';
if (hostMatches(host, PLACEHOLDER_HOSTS) || host.startsWith('example.')) return 'placeholder';
if (hostMatches(host, TUNNEL_HOSTS)) return 'tunnel';
if (LOOPBACK_HOSTS.includes(host) || host.endsWith('.local')) return 'loopback';
if (parsed.protocol === 'http:') return 'insecure';
return 'production';
}
/** Whether a secret is set. Never returns the value. Pure. */
export function secretState(config) {
if (!config || typeof config !== 'object') return 'unknown';
return config.secret !== null && config.secret !== undefined ? 'set' : 'absent';
}
/** The App hook's body encoding, with the documented default applied. Pure. */
export function contentTypeOf(config) {
if (!config || typeof config !== 'object') return 'unknown';
const raw = config.content_type;
if (raw === null || raw === undefined) return 'form';
const value = String(raw).trim().toLowerCase();
return ['json', 'form'].includes(value) ? value : 'unknown';
}
/** The events the App is subscribed to. Pure. */
export function subscribedEvents(app) {
const events = (app || {}).events;
return Array.isArray(events) ? events.map((e) => String(e)) : [];
}
/** An ISO 8601 timestamp as a Date, or null. Pure. */
export function parseTime(text) {
const raw = String(text ?? '').trim();
if (!raw) return null;
const moment = new Date(raw);
return Number.isNaN(moment.getTime()) ? null : moment;
}
/** The most recent delivered_at in a delivery list, or null. Pure. */
export function lastDelivery(deliveries) {
let latest = null;
for (const record of deliveries || []) {
if (!record || typeof record !== 'object') continue;
const moment = parseTime(record.delivered_at);
if (moment && (latest === null || moment > latest)) latest = moment;
}
return latest;
}
/** Whether anything has arrived recently. Corroboration, never proof. Pure. */
export function deliveryState(deliveries, now, staleDays = DEFAULT_STALE_DAYS) {
if (deliveries === null || deliveries === undefined) return 'unknown';
if (deliveries.length === 0) return 'none';
const latest = lastDelivery(deliveries);
if (latest === null || !now) return 'unknown';
const days = Math.floor((now.getTime() - latest.getTime()) / 86400000);
return days >= Number(staleDays) ? 'stale' : 'recent';
}
/** Turn the destination, the subscriptions and the log into a finding. Pure. */
export function verdict(url, events, deliveriesState) {
const klass = urlClass(url);
const count = (events || []).length;
if (klass === 'unset') {
if (count) {
return ['no-url-subscribed',
`the App subscribes to ${count} event(s) and has no webhook URL, so `
+ 'nothing is delivered and nothing fails. There is no log to read '
+ 'because there are no deliveries.'];
}
return ['no-url',
'the App has no webhook URL and subscribes to no events. That is a '
+ 'coherent configuration for an App that only polls or creates its own '
+ 'repository hooks, so this is reported rather than judged.'];
}
if (klass === 'malformed') {
return ['malformed-url',
'the webhook URL is not a usable http or https URL, so no delivery can be '
+ 'attempted against it.'];
}
if (klass === 'placeholder') {
return ['placeholder-url',
'the webhook URL points at a placeholder host from a template. It looks '
+ 'configured and it reaches nothing you own.'];
}
if (klass === 'tunnel') {
return ['tunnel-url',
'the App delivers to a development proxy from the quickstart. Every event '
+ 'goes to a channel nobody is listening to, and the field looks filled in '
+ 'to anyone glancing at it.'];
}
if (klass === 'loopback') {
return ['loopback-url',
'the webhook URL is a loopback or link-local address, which GitHub cannot '
+ 'reach from the internet at all.'];
}
if (klass === 'insecure') {
return ['insecure-url',
'the App delivers over plain http, so payloads and signatures cross the '
+ 'network in the clear. Deliveries do arrive, which is why this survives so long.'];
}
if (deliveriesState === 'none' && count) {
return ['no-deliveries',
`the URL looks like a real destination and the App subscribes to ${count} `
+ 'event(s), but nothing has been delivered in the retained window. Either '
+ 'the events have genuinely not happened or the destination has never worked.'];
}
if (deliveriesState === 'stale' && count) {
return ['silent',
'the destination has delivered before and has gone quiet. That is a '
+ 'receiver or subscription question rather than a configuration one.'];
}
if (!count) {
return ['no-events',
'the webhook URL is a real destination but the App subscribes to no '
+ 'events, so nothing will ever be sent to it. That is a subscription '
+ 'finding, not a URL one.'];
}
return ['delivering',
`the App has a real destination, subscribes to ${count} event(s), and events are arriving.`];
}
/** The sentence a reader has to act on. Pure. */
export function repair(state) {
if (['no-url-subscribed', 'placeholder-url', 'tunnel-url', 'loopback-url',
'malformed-url'].includes(state)) {
return "point the App's webhook at the production receiver, set a secret, "
+ 'set content_type to json, and then confirm with GET /app/hook/deliveries '
+ 'that events start arriving. The settings page will show a URL that '
+ 'nothing can reach.';
}
if (state === 'insecure-url') {
return 'move the destination to https before anything else. The payload and '
+ 'its signature are readable in transit today.';
}
if (state === 'no-deliveries') {
return 'check the receiver is reachable from the internet, then wait for an '
+ 'event you can cause on purpose and read the delivery log again. An empty '
+ 'log alone is not proof of anything.';
}
if (state === 'no-events') {
return 'subscribe the App to the events it handles. The destination is fine '
+ 'and there is nothing being sent to it.';
}
if (state === 'no-url') {
return 'nothing, if the App is meant to poll or manage its own repository '
+ 'hooks. If it is meant to react to events, this is the whole problem.';
}
if (state === 'silent') {
return 'look at the receiver and the subscription list rather than the URL, '
+ 'which is working.';
}
return 'nothing.';
}
function headers(jwt) {
return {
Authorization: `Bearer ${jwt}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function get(jwt, path) {
const res = await fetch(API + path, { headers: headers(jwt) });
let body = null;
try { body = await res.json(); } catch { body = null; }
return { status: res.status, body };
}
async function main() {
const jwt = process.env.GITHUB_APP_JWT;
if (!jwt) {
console.error("set GITHUB_APP_JWT to a JWT signed with the App's private key");
process.exitCode = 2;
return;
}
const staleDays = Number(process.env.GITHUB_STALE_DAYS || DEFAULT_STALE_DAYS);
const app = await get(jwt, '/app');
if (app.status !== 200 || !app.body) {
console.error(`GET /app returned ${app.status}; the JWT is not being accepted as an App`);
process.exitCode = 2;
return;
}
const events = subscribedEvents(app.body);
console.log(`app: ${app.body.slug}, ${app.body.installations_count} installation(s), `
+ `subscribed to ${events.length} event(s)`);
const cfg = await get(jwt, '/app/hook/config');
if (cfg.status !== 200 || !cfg.body) {
console.error(`GET /app/hook/config returned ${cfg.status}`);
process.exitCode = 2;
return;
}
const url = cfg.body.url;
console.log(`hook config: url=${url || '(empty)'} content_type=${contentTypeOf(cfg.body)} `
+ `secret=${secretState(cfg.body)}`);
const dl = await get(jwt, '/app/hook/deliveries?per_page=100');
const records = dl.status === 200 && Array.isArray(dl.body) ? dl.body : null;
const now = new Date();
const stateOfLog = deliveryState(records, now, staleDays);
const latest = lastDelivery(records || []);
console.log(`deliveries: ${records === null ? 'unreadable' : records.length} in the `
+ `retained window, most recent ${latest ? latest.toISOString().slice(0, 10) : 'none'}`);
const [state, detail] = verdict(url, events, stateOfLog);
console.log(`${state}: ${detail}`);
console.log(`repair: ${repair(state)}`);
console.log(JSON.stringify({
app: app.body.slug,
installations: app.body.installations_count,
events,
hook_url: url,
url_class: urlClass(url),
content_type: contentTypeOf(cfg.body),
secret: secretState(cfg.body),
deliveries_retained: records === null ? null : records.length,
delivery_state: stateOfLog,
state,
}, null, 2));
process.exitCode = ['no-url-subscribed', 'placeholder-url', 'tunnel-url',
'loopback-url', 'malformed-url', 'insecure-url'].includes(state) ? 1 : 0;
}
// Guarded so importing this file from the test runner does not start a run and
// fail the suite even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The host classifier gets a case each for the four ways this happens in the wild, plus the ordering rule that keeps http://localhost:3000 a loopback finding rather than a transport one. After that, the pairs that decide the verdict: a blank URL means something different when the App subscribes to a dozen events than when it subscribes to none, and an empty delivery list is corroboration on its own and a conclusion only when there is something that should have arrived.
from datetime import datetime, timezone
from github_app_hook_config import (
content_type_of, delivery_state, host_of, last_delivery, repair,
secret_state, subscribed_events, url_class, verdict,
)
NOW = datetime(2026, 8, 31, tzinfo=timezone.utc)
EVENTS = ["push", "pull_request", "issues", "release"]
RECENT = [{"delivered_at": "2026-08-30T10:00:00Z"}, {"delivered_at": "2026-08-29T10:00:00Z"}]
OLD = [{"delivered_at": "2026-01-04T10:00:00Z"}]
def test_a_host_is_pulled_out_of_a_url_or_admitted_missing():
assert host_of("https://Hooks.Example.COM/github") == "hooks.example.com"
assert host_of("nonsense") == ""
assert host_of(None) == ""
def test_the_four_ways_this_actually_happens_are_each_named():
assert url_class("") == "unset"
assert url_class(None) == "unset"
assert url_class("https://smee.io/aB3xQ9pLm") == "tunnel"
assert url_class("https://1a2b3c.ngrok-free.app/hook") == "tunnel"
assert url_class("https://example.com/webhook") == "placeholder"
assert url_class("https://localhost:3000/hook") == "loopback"
def test_a_real_destination_is_not_swept_up_by_the_placeholder_list():
assert url_class("https://hooks.acme.dev/github") == "production"
assert url_class("https://api.example-corp.com/github") == "production"
def test_loopback_beats_transport_so_the_reader_goes_to_the_right_note():
assert url_class("http://localhost:3000/hook") == "loopback"
assert url_class("http://hooks.acme.dev/github") == "insecure"
assert url_class("ftp://hooks.acme.dev/github") == "malformed"
assert url_class("just-a-string") == "malformed"
def test_the_config_is_read_without_touching_the_secret():
assert secret_state({"secret": "********"}) == "set"
assert secret_state({"url": "https://x.dev"}) == "absent"
assert content_type_of({}) == "form"
assert content_type_of({"content_type": "JSON"}) == "json"
assert subscribed_events({"events": EVENTS}) == EVENTS
assert subscribed_events({}) == []
def test_a_blank_url_with_subscriptions_is_the_sharpest_form():
state, detail = verdict("", EVENTS, "none")
assert state == "no-url-subscribed"
assert "4 event(s)" in detail
assert "no log to read" in detail
def test_a_blank_url_with_no_subscriptions_is_reported_not_judged():
state, detail = verdict("", [], "none")
assert state == "no-url"
assert "reported rather than judged" in detail
def test_a_tunnel_url_is_as_broken_as_a_blank_one_and_harder_to_see():
state, detail = verdict("https://smee.io/aB3xQ9pLm", EVENTS, "recent")
assert state == "tunnel-url"
assert "nobody is listening" in detail
def test_the_delivery_log_is_read_and_never_trusted_alone():
assert delivery_state([], NOW) == "none"
assert delivery_state(RECENT, NOW) == "recent"
assert delivery_state(OLD, NOW) == "stale"
assert delivery_state(None, NOW) == "unknown"
assert last_delivery(RECENT).day == 30
assert last_delivery([]) is None
def test_an_empty_log_on_a_real_url_is_a_question_and_not_a_verdict():
state, detail = verdict("https://hooks.acme.dev/github", EVENTS, "none")
assert state == "no-deliveries"
assert "genuinely not happened" in detail
assert "not proof of anything" in repair("no-deliveries")
def test_a_real_url_with_no_subscriptions_is_handed_to_the_other_note():
state, detail = verdict("https://hooks.acme.dev/github", [], "none")
assert state == "no-events"
assert "subscription finding" in detail
def test_a_working_app_is_not_a_finding():
assert verdict("https://hooks.acme.dev/github", EVENTS, "recent")[0] == "delivering"
assert verdict("https://hooks.acme.dev/github", EVENTS, "stale")[0] == "silent"
def test_the_repair_ends_at_the_delivery_log_rather_than_the_settings_page():
assert "app/hook/deliveries" in repair("tunnel-url")
assert "settings page" in repair("no-url-subscribed")
assert "https" in repair("insecure-url")
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
contentTypeOf, deliveryState, hostOf, lastDelivery, repair, secretState,
subscribedEvents, urlClass, verdict,
} from './github-app-hook-config.mjs';
const NOW = new Date('2026-08-31T00:00:00Z');
const EVENTS = ['push', 'pull_request', 'issues', 'release'];
const RECENT = [{ delivered_at: '2026-08-30T10:00:00Z' }, { delivered_at: '2026-08-29T10:00:00Z' }];
const OLD = [{ delivered_at: '2026-01-04T10:00:00Z' }];
test('a host is pulled out of a URL or admitted missing', () => {
assert.equal(hostOf('https://Hooks.Example.COM/github'), 'hooks.example.com');
assert.equal(hostOf('nonsense'), '');
assert.equal(hostOf(null), '');
});
test('the four ways this actually happens are each named', () => {
assert.equal(urlClass(''), 'unset');
assert.equal(urlClass(null), 'unset');
assert.equal(urlClass('https://smee.io/aB3xQ9pLm'), 'tunnel');
assert.equal(urlClass('https://1a2b3c.ngrok-free.app/hook'), 'tunnel');
assert.equal(urlClass('https://example.com/webhook'), 'placeholder');
assert.equal(urlClass('https://localhost:3000/hook'), 'loopback');
});
test('a real destination is not swept up by the placeholder list', () => {
assert.equal(urlClass('https://hooks.acme.dev/github'), 'production');
assert.equal(urlClass('https://api.example-corp.com/github'), 'production');
});
test('loopback beats transport so the reader goes to the right note', () => {
assert.equal(urlClass('http://localhost:3000/hook'), 'loopback');
assert.equal(urlClass('http://hooks.acme.dev/github'), 'insecure');
assert.equal(urlClass('ftp://hooks.acme.dev/github'), 'malformed');
assert.equal(urlClass('just-a-string'), 'malformed');
});
test('the config is read without touching the secret', () => {
assert.equal(secretState({ secret: '********' }), 'set');
assert.equal(secretState({ url: 'https://x.dev' }), 'absent');
assert.equal(contentTypeOf({}), 'form');
assert.equal(contentTypeOf({ content_type: 'JSON' }), 'json');
assert.deepEqual(subscribedEvents({ events: EVENTS }), EVENTS);
assert.deepEqual(subscribedEvents({}), []);
});
test('a blank URL with subscriptions is the sharpest form', () => {
const [state, detail] = verdict('', EVENTS, 'none');
assert.equal(state, 'no-url-subscribed');
assert.ok(detail.includes('4 event(s)'));
assert.match(detail, /no log to read/);
});
test('a blank URL with no subscriptions is reported, not judged', () => {
const [state, detail] = verdict('', [], 'none');
assert.equal(state, 'no-url');
assert.match(detail, /reported rather than judged/);
});
test('a tunnel URL is as broken as a blank one and harder to see', () => {
const [state, detail] = verdict('https://smee.io/aB3xQ9pLm', EVENTS, 'recent');
assert.equal(state, 'tunnel-url');
assert.match(detail, /nobody is listening/);
});
test('the delivery log is read and never trusted alone', () => {
assert.equal(deliveryState([], NOW), 'none');
assert.equal(deliveryState(RECENT, NOW), 'recent');
assert.equal(deliveryState(OLD, NOW), 'stale');
assert.equal(deliveryState(null, NOW), 'unknown');
assert.equal(lastDelivery(RECENT).getUTCDate(), 30);
assert.equal(lastDelivery([]), null);
});
test('an empty log on a real URL is a question and not a verdict', () => {
const [state, detail] = verdict('https://hooks.acme.dev/github', EVENTS, 'none');
assert.equal(state, 'no-deliveries');
assert.match(detail, /genuinely not happened/);
assert.match(repair('no-deliveries'), /not proof of anything/);
});
test('a real URL with no subscriptions is handed to the other note', () => {
const [state, detail] = verdict('https://hooks.acme.dev/github', [], 'none');
assert.equal(state, 'no-events');
assert.match(detail, /subscription finding/);
});
test('a working App is not a finding', () => {
assert.equal(verdict('https://hooks.acme.dev/github', EVENTS, 'recent')[0], 'delivering');
assert.equal(verdict('https://hooks.acme.dev/github', EVENTS, 'stale')[0], 'silent');
});
test('the repair ends at the delivery log rather than the settings page', () => {
assert.ok(repair('tunnel-url').includes('app/hook/deliveries'));
assert.match(repair('no-url-subscribed'), /settings page/);
assert.match(repair('insecure-url'), /https/);
});
FAQ
Why is there nothing in the delivery log?
Because there is nothing to log. A failed delivery is a record of an attempt; with no destination there is no attempt, so the log is not empty because something went wrong, it is empty because nothing happened. That is why this problem defeats the usual webhook playbook, which starts by reading the failures. The absence of failures is being read as a good sign, when here it is the finding itself.
Do I need the App's private key to run this?
You need a JWT signed with it, which is not quite the same thing. The script takes the JWT from the environment and never loads a key or signs anything, so the key stays wherever you already keep it and never enters this process. If you have no JWT to hand, mint one with whatever your App already uses to authenticate. An installation access token will not work here and will answer 403: reading an App's own configuration is something only the App can do.
Our App creates a webhook on each repository instead. Is a blank App URL wrong?
No, and the script does not call it wrong. That is a real architecture: the App manages its own repository hooks and the App-level webhook stays unused. What tells the two apart is the events array on <code>GET /app</code>. A blank URL with no App-level subscriptions is coherent and gets reported rather than judged; a blank URL with a dozen subscriptions is an App configured to receive things it has nowhere to put. Only the second is a finding.
Why is smee.io treated as harshly as an empty field?
Because it is exactly as broken and much harder to notice. It is what the quickstart hands you, it works beautifully on a laptop, and it never breaks in a way that forces anybody to revisit it. Then the App ships and every event goes to a proxy channel with no listener. An audit that only asks whether the URL field is populated passes it. That is the reason the script classifies the host rather than testing for blankness, and the same goes for <code>ngrok</code>, <code>localhost</code> and <code>example.com</code>.
The delivery list is empty but our URL looks fine. Is that the same problem?
Not necessarily, and the script reports it as a question rather than a verdict. Deliveries are retained for a limited window, so an empty list can mean a quiet App, a long weekend, or a subscription to events that genuinely have not happened. What makes it conclusive is what sits next to it: no deliveries while subscribed to <code>push</code> across forty active installations is a destination that has never worked. Cause an event you control, then read the log again.
Related field notes
- The App is not subscribed to the event
- A webhook posting to a plain http:// URL
- Form-encoded bodies sent to a JSON receiver
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.
- App webhooks — GitHub REST API
- Using webhooks with GitHub Apps — GitHub Docs
- Authenticating as a GitHub App — GitHub Docs
- Handling failed webhook deliveries — GitHub Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.