Diagnostic GitHub API
resource not accessible by integration on one endpoint
Nineteen endpoints work. The twentieth returns 403 {"message":"Resource not accessible by integration"}, which names no permission, no resource and no level, and reads like a platform bug rather than a configuration one. It is the one failure in this cluster where GitHub actually tells you the answer: it is in the x-accepted-github-permissions response header on that very 403, which almost no HTTP client shows you by default.
Make the failing call again and read x-accepted-github-permissions off the response. It names the permission and level the endpoint accepts, in the form pull_requests=write. Then read what the App actually holds from GET /app under the App's JWT, which returns the full permissions map. The difference is the answer.
Two cases hide in here. A 403 with no such header means the endpoint does not accept installation tokens at all, so no permission will open it. And adding a permission to the App is only half a repair: it stays inert until each installation accepts the upgrade.
The problem in plain words
"Resource not accessible by integration" is one of the most-searched GitHub errors in existence, and the reason is that the message contains no nouns. It does not say which resource, which integration, or what would make it accessible. Read cold, it suggests the endpoint is broken for Apps generally, and the natural next move — trying a different endpoint, or a different token, or the same call from a workflow — wastes an afternoon confirming that the rest of the integration is healthy.
The information is right there in the response. It is simply in a header, and the code that raised the exception kept the status and the body and dropped everything else. That is why this note is the odd one in this group: it is not GitHub withholding anything. It is GitHub answering a question nobody read the reply to.
Why it happens
App permissions are per resource and per level, and there are dozens of them. An App with contents: read cannot list pull requests; an App with pull_requests: read cannot request a reviewer. Each endpoint has its own requirement, so an integration can be nineteen-twentieths correct and the twentieth call is the one that finds the hole.
The error message is deliberately generic and the header is deliberately specific. x-accepted-github-permissions exists exactly because the message cannot carry this. It is on the 403 itself, so no second call is needed — only a client that keeps headers.
A 403 with no header is a completely different problem. Some endpoints do not accept a server-to-server installation token at all: GET /user is the classic case, because an installation has no current user. That failure looks identical from the message alone and no permission change will ever fix it. The absence of the header is the signal, which means you have to distinguish "header absent" from "header not read".
Adding the permission does not grant it. A new or widened permission on a GitHub App is pending until each installation's owner accepts it. The App's own settings page will show the permission, GET /app will show the permission, and installations that have not accepted keep 403ing exactly as before — which is the second afternoon this error costs people.
The fix, as a flow
This is the one where GitHub does answer, so the script mostly reads: the header on the failing 403 names what the endpoint wanted, and GET /app names what the App holds. The diff is the whole diagnosis.
How to fix it
Repeat the failing call and keep the headers
Any client that exposes the raw response will do. The value looks like x-accepted-github-permissions: pull_requests=write; where an endpoint accepts more than one way in, more than one pair appears. Log the whole header verbatim before parsing it, because the parse is a convenience and the raw string is the evidence.
Read what the App holds, not what you remember granting
GET /app authenticated with the App's JWT returns the permissions map. This is the only authoritative statement of what the App asks for. An installation token cannot read it — which is itself worth reporting rather than papering over, since it tells you which credential to go and fetch.
Diff by level, not by presence
read is not write. Half of these incidents are a permission that is present in the map at the wrong level, which looks correct in a glance down a settings page and is not correct to the endpoint. Rank the levels and compare them numerically so contents: read against contents=write reports as a level problem rather than as satisfied.
Treat a 403 with no header as a different diagnosis entirely
If the header is genuinely absent on a 403, the endpoint does not accept installation tokens. Stop looking at the permission map and switch that specific call to the App-appropriate equivalent — GET /installation/repositories rather than GET /user/repos, GET /app for the App's own identity — or to a user-to-server token obtained through the App's OAuth flow.
Add the permission, then chase the acceptances
Add exactly what the header named and nothing more. Then remember that every existing installation keeps its old permission set until an owner accepts the upgrade, so the endpoint keeps failing for them after your change looks complete. Notify the installers, and keep this check running until the 403 stops rather than until the settings page looks right.
How to check it worked
Re-run against the endpoint that was failing. It should answer, and the verdict should be accessible.
python3 github_app_permission_diff.py --path /repos/acme/api/pulls
# accessible HTTP 200: the endpoint answered, so there is nothing to diff.
The full code
One GET at the endpoint that fails and one at GET /app, both read-only. The pure functions are the header parser and the diff, and the diff carries the two distinctions that make this note worth writing: a permission held at too low a level is not a permission that is absent, and a 403 with no header at all is not a permission problem in the first place.
"""Name the GitHub App permission a 403 was actually asking for.
Read only. GET requests and nothing else. The repair is printed, never
performed, because this script holds a credential that reaches repositories.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("github_app_permission_diff")
API = "https://api.github.com"
UA = "github-app-permission-diff/1.0"
# Ordered so a comparison is arithmetic. "read" satisfying a "write" requirement
# is the single most common way this error survives a careful look at a settings
# page, and only a ranking catches it.
LEVELS = {"none": 0, "read": 1, "write": 2, "admin": 3}
def parse_accepted(value):
"""Parse x-accepted-github-permissions into (permission, level) pairs. Pure.
The value is a list of name=level pairs. Endpoints that accept more than one
route in list more than one pair, and the separator is not consistent across
every endpoint, so both commas and semicolons are accepted here rather than
depending on which one a given endpoint used.
"""
raw = (value or "").strip()
if not raw:
return []
out = []
for chunk in raw.replace(";", ",").split(","):
chunk = chunk.strip()
name, sep, level = chunk.partition("=")
if not sep or not name.strip():
continue
out.append((name.strip(), level.strip().lower()))
return out
def diff(held, accepted, status=403):
"""Compare what the App holds against what the endpoint asked for. Pure.
`held` is the permissions map from GET /app, or None when it could not be
read. `accepted` is the parsed header. Returns (state, detail).
Where an endpoint lists alternatives, holding one of them can be enough, so
reporting every unmet pair is a superset. That is the safe direction for a
diagnostic: it can send you to check a permission you did not need, but it
will never report one as fine when it is not.
"""
if status < 400:
return ("accessible",
"HTTP %s: the endpoint answered, so there is nothing to diff."
% (status,))
if status != 403:
return ("not-a-permission-error",
"HTTP %s is not 'Resource not accessible by integration'. A 404 "
"here is the masked-permission case and a 401 is a dead "
"credential." % (status,))
if not accepted:
return ("endpoint-refuses-apps",
"403 with no x-accepted-github-permissions header. The endpoint "
"does not accept an installation token at all, so no permission "
"you add will open it: use the App equivalent, or a "
"user-to-server token from the App's OAuth flow.")
wanted = ", ".join("%s: %s" % (n, l) for n, l in accepted)
if held is None:
return ("needed",
"the endpoint accepts %s. The App's own permission map is not "
"readable with this credential; read it with GET /app under the "
"App JWT to see which of those it is missing." % (wanted,))
missing, low = [], []
for name, level in accepted:
have = str(held.get(name) or "none").strip().lower()
rank = LEVELS.get(have, 0)
need = LEVELS.get(level, 0)
if rank == 0:
missing.append("%s: %s" % (name, level))
elif rank < need:
low.append("%s has %s and needs %s" % (name, have, level))
if not missing and not low:
return ("sufficient",
"the App already holds %s, so the permission map is not the "
"cause. Check that the installation covers this repository and "
"that the permission upgrade was accepted by this installation."
% (wanted,))
if not missing:
return ("level-too-low",
"held, but at the wrong level: %s. A permission at 'read' looks "
"correct on a settings page and is not correct to the endpoint."
% ("; ".join(low),))
return ("permission-absent",
"not held at all: %s.%s" % (", ".join(missing),
(" Also at the wrong level: %s."
% "; ".join(low)) if low else ""))
def get(session, url, **params):
return session.get(url, params=params, timeout=30)
def held_permissions(session, api):
"""The App's own permission map, or None when the credential cannot read it.
GET /app needs the App JWT. An installation token gets a 403 here, which is
a fact about the credential rather than about the App, so None is returned
and the caller says so out loud.
"""
r = get(session, api + "/app")
if r.status_code != 200:
return None
return r.json().get("permissions") or {}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--path", required=True,
help="the API path that returns 403, e.g. /repos/acme/api/pulls")
ap.add_argument("--api", default=API,
help="API host, for GitHub Enterprise Server")
args = ap.parse_args()
token = os.environ.get("GITHUB_TOKEN")
if not token:
log.error("set GITHUB_TOKEN (an App installation token, or the App JWT "
"if you also want the permission map)")
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,
})
path = args.path if args.path.startswith("/") else "/" + args.path
probe = get(session, args.api + path)
raw = probe.headers.get("x-accepted-github-permissions")
log.info("%s -> HTTP %s", path, probe.status_code)
log.info("x-accepted-github-permissions: %s", raw if raw is not None else "absent")
accepted = parse_accepted(raw)
held = held_permissions(session, args.api)
state, detail = diff(held, accepted, probe.status_code)
if state == "accessible":
log.info("%-24s %s", state, detail)
return 0
log.warning("%-24s %s", state, detail)
if held is not None:
log.warning(" the App holds: %s",
", ".join("%s: %s" % (k, v) for k, v in sorted(held.items()))
or "nothing")
if state in ("permission-absent", "level-too-low"):
log.warning(" repair: add exactly the permission named above to the App, "
"then have every installation owner accept the upgrade. Until "
"an installation accepts it, that installation keeps the old "
"permission set and keeps returning this same 403.")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Name the GitHub App permission a 403 was actually asking for.
*
* Read only. GET requests and nothing else. The repair is printed, never
* performed.
*/
const API = 'https://api.github.com';
const UA = 'github-app-permission-diff/1.0';
// Ordered so a comparison is arithmetic. "read" satisfying a "write" requirement
// is the most common way this error survives a careful look at a settings page.
const LEVELS = { none: 0, read: 1, write: 2, admin: 3 };
/**
* Parse x-accepted-github-permissions into [permission, level] pairs. Pure.
* Both commas and semicolons are accepted as separators rather than depending on
* which one a given endpoint used.
*/
export function parseAccepted(value) {
const raw = String(value ?? '').trim();
if (!raw) return [];
const out = [];
for (const chunk of raw.replace(/;/g, ',').split(',')) {
const at = chunk.indexOf('=');
if (at < 0) continue;
const name = chunk.slice(0, at).trim();
const level = chunk.slice(at + 1).trim().toLowerCase();
if (!name) continue;
out.push([name, level]);
}
return out;
}
/**
* Compare what the App holds against what the endpoint asked for. Pure.
* `held` is the map from GET /app, or null when it could not be read.
* Returns [state, detail].
*
* Where an endpoint lists alternatives, holding one can be enough, so reporting
* every unmet pair is a superset: it may send you to check a permission you did
* not need, but it never reports one as fine when it is not.
*/
export function diff(held, accepted, status = 403) {
if (status < 400) {
return ['accessible',
`HTTP ${status}: the endpoint answered, so there is nothing to diff.`];
}
if (status !== 403) {
return ['not-a-permission-error',
`HTTP ${status} is not 'Resource not accessible by integration'. A 404 ` +
'here is the masked-permission case and a 401 is a dead credential.'];
}
if (!accepted || accepted.length === 0) {
return ['endpoint-refuses-apps',
'403 with no x-accepted-github-permissions header. The endpoint does not ' +
'accept an installation token at all, so no permission you add will open ' +
"it: use the App equivalent, or a user-to-server token from the App's " +
'OAuth flow.'];
}
const wanted = accepted.map(([n, l]) => `${n}: ${l}`).join(', ');
if (held === null || held === undefined) {
return ['needed',
`the endpoint accepts ${wanted}. The App's own permission map is not ` +
'readable with this credential; read it with GET /app under the App JWT ' +
'to see which of those it is missing.'];
}
const missing = [];
const low = [];
for (const [name, level] of accepted) {
const have = String(held[name] ?? 'none').trim().toLowerCase();
const rank = LEVELS[have] ?? 0;
const need = LEVELS[level] ?? 0;
if (rank === 0) missing.push(`${name}: ${level}`);
else if (rank < need) low.push(`${name} has ${have} and needs ${level}`);
}
if (missing.length === 0 && low.length === 0) {
return ['sufficient',
`the App already holds ${wanted}, so the permission map is not the cause. ` +
'Check that the installation covers this repository and that the ' +
'permission upgrade was accepted by this installation.'];
}
if (missing.length === 0) {
return ['level-too-low',
`held, but at the wrong level: ${low.join('; ')}. A permission at 'read' ` +
'looks correct on a settings page and is not correct to the endpoint.'];
}
const extra = low.length ? ` Also at the wrong level: ${low.join('; ')}.` : '';
return ['permission-absent', `not held at all: ${missing.join(', ')}.${extra}`];
}
function headers(token) {
return {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': UA,
};
}
async function get(token, url) {
return fetch(url, { headers: headers(token) });
}
export async function heldPermissions(token, api = API) {
const res = await get(token, `${api}/app`);
if (res.status !== 200) return null;
return (await res.json()).permissions ?? {};
}
async function main() {
const token = process.env.GITHUB_TOKEN;
if (!token) {
console.error('set GITHUB_TOKEN (an App installation token, or the App JWT ' +
'if you also want the permission map)');
process.exitCode = 2;
return;
}
const at = process.argv.indexOf('--path');
let path = at >= 0 ? process.argv[at + 1] : null;
if (!path) {
console.error('pass --path /repos/owner/name/pulls');
process.exitCode = 2;
return;
}
if (!path.startsWith('/')) path = `/${path}`;
const probe = await get(token, API + path);
const raw = probe.headers.get('x-accepted-github-permissions');
console.log(`${path} -> HTTP ${probe.status}`);
console.log(`x-accepted-github-permissions: ${raw ?? 'absent'}`);
const accepted = parseAccepted(raw);
const held = await heldPermissions(token);
const [state, detail] = diff(held, accepted, probe.status);
if (state === 'accessible') {
console.log(`${state.padEnd(24)} ${detail}`);
return;
}
console.warn(`${state.padEnd(24)} ${detail}`);
if (held !== null) {
const shown = Object.entries(held).sort()
.map(([k, v]) => `${k}: ${v}`).join(', ');
console.warn(` the App holds: ${shown || 'nothing'}`);
}
if (state === 'permission-absent' || state === 'level-too-low') {
console.warn(' repair: add exactly the permission named above to the App, ' +
'then have every installation owner accept the upgrade. Until an ' +
'installation accepts it, that installation keeps the old ' +
'permission set and keeps returning this same 403.');
}
process.exitCode = 1;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing token, and set a
// non-zero exit code that fails the whole test file 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
Three rules earn their tests. A permission held at read where the endpoint wants write has to report as its own state rather than as satisfied, because it is the case that survives a careful reading of the settings page. A 403 with no header must never be diagnosed as a missing permission, since nothing you add will fix it. And an empty permission map is not the same as an unreadable one — the first says the App has nothing, the second says you brought the wrong credential.
from github_app_permission_diff import diff, parse_accepted
def test_the_header_parses_to_name_and_level_pairs():
assert parse_accepted("pull_requests=write") == [("pull_requests", "write")]
assert parse_accepted("contents=read, metadata=read") == [
("contents", "read"), ("metadata", "read")]
assert parse_accepted("issues=write; pull_requests=write") == [
("issues", "write"), ("pull_requests", "write")]
def test_an_absent_header_parses_to_nothing_rather_than_a_guess():
assert parse_accepted(None) == []
assert parse_accepted("") == []
assert parse_accepted("garbage-with-no-equals") == []
def test_a_403_with_no_header_is_not_a_permission_problem():
# GET /user under an installation token. No permission will ever open it.
state, detail = diff({"contents": "read"}, [], 403)
assert state == "endpoint-refuses-apps"
assert "installation token" in detail
def test_read_where_write_is_needed_is_its_own_state():
state, detail = diff({"pull_requests": "read"},
parse_accepted("pull_requests=write"))
assert state == "level-too-low"
assert "has read and needs write" in detail
def test_a_permission_that_is_absent_is_named():
state, detail = diff({"contents": "read"},
parse_accepted("pull_requests=write"))
assert state == "permission-absent"
assert "pull_requests: write" in detail
def test_holding_everything_asked_for_points_elsewhere():
state, detail = diff({"pull_requests": "write", "metadata": "read"},
parse_accepted("pull_requests=write, metadata=read"))
assert state == "sufficient"
assert "accepted" in detail
def test_write_satisfies_a_read_requirement():
assert diff({"contents": "write"}, parse_accepted("contents=read"))[0] == "sufficient"
def test_an_unreadable_map_is_not_an_empty_one():
# None means "wrong credential"; {} means "the App holds nothing".
assert diff(None, parse_accepted("issues=write"))[0] == "needed"
assert diff({}, parse_accepted("issues=write"))[0] == "permission-absent"
def test_a_success_and_a_non_403_are_not_diffed_at_all():
assert diff({}, parse_accepted("issues=write"), 200)[0] == "accessible"
state, detail = diff({}, parse_accepted("issues=write"), 404)
assert state == "not-a-permission-error"
assert "masked" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { diff, parseAccepted } from './github-app-permission-diff.mjs';
test('the header parses to name and level pairs', () => {
assert.deepEqual(parseAccepted('pull_requests=write'), [['pull_requests', 'write']]);
assert.deepEqual(parseAccepted('contents=read, metadata=read'),
[['contents', 'read'], ['metadata', 'read']]);
assert.deepEqual(parseAccepted('issues=write; pull_requests=write'),
[['issues', 'write'], ['pull_requests', 'write']]);
});
test('an absent header parses to nothing rather than a guess', () => {
assert.deepEqual(parseAccepted(null), []);
assert.deepEqual(parseAccepted(''), []);
assert.deepEqual(parseAccepted('garbage-with-no-equals'), []);
});
test('a 403 with no header is not a permission problem', () => {
const [state, detail] = diff({ contents: 'read' }, [], 403);
assert.equal(state, 'endpoint-refuses-apps');
assert.match(detail, /installation token/);
});
test('read where write is needed is its own state', () => {
const [state, detail] = diff({ pull_requests: 'read' },
parseAccepted('pull_requests=write'));
assert.equal(state, 'level-too-low');
assert.match(detail, /has read and needs write/);
});
test('a permission that is absent is named', () => {
const [state, detail] = diff({ contents: 'read' },
parseAccepted('pull_requests=write'));
assert.equal(state, 'permission-absent');
assert.match(detail, /pull_requests: write/);
});
test('holding everything asked for points elsewhere', () => {
const [state, detail] = diff({ pull_requests: 'write', metadata: 'read' },
parseAccepted('pull_requests=write, metadata=read'));
assert.equal(state, 'sufficient');
assert.match(detail, /accepted/);
});
test('write satisfies a read requirement', () => {
assert.equal(diff({ contents: 'write' }, parseAccepted('contents=read'))[0],
'sufficient');
});
test('an unreadable map is not an empty one', () => {
assert.equal(diff(null, parseAccepted('issues=write'))[0], 'needed');
assert.equal(diff({}, parseAccepted('issues=write'))[0], 'permission-absent');
});
test('a success and a non-403 are not diffed at all', () => {
assert.equal(diff({}, parseAccepted('issues=write'), 200)[0], 'accessible');
const [state, detail] = diff({}, parseAccepted('issues=write'), 404);
assert.equal(state, 'not-a-permission-error');
assert.match(detail, /masked/);
});
FAQ
What does 'Resource not accessible by integration' actually mean?
That a GitHub App called an endpoint its permission set does not cover. The message names nothing because the specifics live in the x-accepted-github-permissions response header on the same 403, which lists the permission and level the endpoint accepts.
Where do I see the x-accepted-github-permissions header?
On the 403 response itself, so no second request is needed. You do need a client that surfaces response headers: most SDKs raise an exception carrying the status and the body and discard the rest, which is why so much time gets spent on this error with the answer already on the wire.
I added the permission and it still returns 403. Why?
Because a new or widened permission on a GitHub App is pending until each installation's owner accepts it. Your settings page and GET /app both show the permission immediately; installations that have not accepted keep the old set and keep failing. Notify the installers and keep checking the endpoint rather than the settings page.
The 403 has no x-accepted-github-permissions header at all. What then?
That is a different diagnosis: the endpoint does not accept installation tokens. GET /user is the standard example, since an installation has no current user. Use the App-appropriate equivalent, such as GET /installation/repositories instead of GET /user/repos, or a user-to-server token from the App's OAuth flow.
The endpoint lists two permissions. Do I need both?
Sometimes one is enough. Where an endpoint offers more than one route in, holding either satisfies it, so a script that reports every unmet pair is giving you a superset. That is the safe direction for a diagnostic: it can send you to check something you did not need, but it will never tell you a permission is fine when it is not.
Related field notes
- A permission error disguised as 404 Not Found
- An installation that covers only some repositories
- Org lists that silently omit SSO-enforced orgs
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.
- Permissions required for GitHub Apps — GitHub Docs
- Choosing permissions for a GitHub App — GitHub Docs
- Editing a GitHub App's permissions — GitHub Docs
- GitHub Apps — GitHub REST API
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.