Skip to content

Field notes

GitHub API fix guides

The GitHub API is generous until it is not, and the ways it stops working are quiet ones: a list that returns thirty items because nobody followed the Link header, a webhook that has been failing for a month, a secondary rate limit that answers 403 with no Retry-After. Each note here explains one such problem and gives you a script that finds it through the API.

Read-only tokenPython and Node.jsTests included
Browse all 101 scripts on GitHub Download them all as a zip Field guide PDF (101 fixes, 184 KB) Follow @allanninal Every fix on this page has a tested Python and Node.js script in the repo. Free, and MIT as stated in its README.
Not GitHub Actions

This section is about the GitHub API as an integration surface: authentication, rate limits, pagination, webhooks, Apps and GraphQL. Workflow problems — empty secrets in fork pull requests, silent cache misses, redundant billed runs — live in GitHub Actions field notes instead.

Every script here is read only. They report what is wrong and print the repair; they never write.

GitHub API

GitHub APIDiagnostic

Only the first page is read because the Link header is ignored

A repository with 340 open pull requests reports 30. The next page is advertised in the Link header, nothing errors, and page one is a truthful lie.

GitHub APIDiagnostic

Per_page is unset so every list costs 3.3x more requests

Reading 3,000 issues takes 100 requests instead of 30 because nobody set per_page. The default is 30, the maximum is 100, and the difference is free.

GitHub APIDiagnostic

Search returns at most 1,000 results whatever total_count says

total_count says 24,831 and page 11 returns 422 Validation Failed. The count is real; the results past the thousandth cannot be paged to at all.

GitHub APIDiagnostic

The compare endpoint stops at 250 commits and says nothing

A release-notes job diffs two tags and gets exactly 250 commits back. total_commits says 812. The response is a 200 with a truncated array and no flag.

GitHub APIDiagnostic

A permission error is disguised as 404 Not Found

GitHub answers 404 rather than 403 for private resources a token cannot see, so a missing scope, a missing installation and a deleted repo look identical.

GitHub APIDiagnostic

Org lists silently omit SSO-enforced organizations

GET /user/orgs returns 200 and leaves out the orgs your token is not SSO-authorized for, mentioning the omission only in an X-GitHub-SSO header.

GitHub APIDiagnostic

The installation covers only some repositories, silently

repository_selection is selected, the App sees 12 of 140 repositories, and every endpoint answers truthfully about those 12 and says nothing about the rest.

GitHub APIDiagnostic

Resource not accessible by integration on one endpoint

One endpoint 403s for a GitHub App while everything else works. The x-accepted-github-permissions header names exactly what the endpoint wanted.

GitHub APIDiagnostic

Webhook deliveries are failing and nobody reads the log

GitHub records every delivery attempt with the response it got. Your receiver has no record at all, which is why a month of 5xx goes unnoticed.

GitHub APIDiagnostic

A webhook with no secret sends no signature to verify

GitHub sends X-Hub-Signature-256 only when a secret is set. Without one the header is absent, and a receiver that checks it when present checks nothing.

GitHub APIDiagnostic

The hook is not subscribed to the event you are waiting for

A handler written for release or workflow_job never runs, and there is no error to find. An unsubscribed event does not fail: the delivery never exists.

GitHub APIDiagnostic

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

Every event is processed twice because two independent hooks point at one URL. Dormant idempotency bugs start firing and nothing in either hook looks wrong.

GitHub APIDiagnostic

Over 100 concurrent requests trips a secondary rate limit

A fan-out of parallel GETs returns 403 while x-ratelimit-remaining still shows thousands left. That combination is the whole diagnosis.

GitHub APIDiagnostic

Bulk issue or comment creation exceeds 80 requests a minute

Content-generating requests get 80 a minute and 500 an hour, separately from the quota. A migration runs clean for 80 items and then 403s on every one.

GitHub APIDiagnostic

The client ignores retry-after and keeps hammering the API

GitHub tells you exactly how long to wait. A fixed one-second backoff spends 60 refused requests inside that window and extends the throttle.

GitHub APIDiagnostic

Polling without ETags spends full quota on unchanged data

A 304 Not Modified does not count against the rate limit. Measure x-ratelimit-used before and after an If-None-Match request and the saving is exact.

GitHub APIDiagnostic

Core REST quota is exhausted and every call returns 403

GET /rate_limit is free and publishes used, limit and reset. Those three numbers say whether the drain you are running fits in the window left.

GitHub APIDiagnostic

Requests go out anonymous and are capped at 60 an hour

A limit of 60 in GET /rate_limit proves the Authorization header never reached GitHub. The repair is to fail at startup rather than degrade quietly.

GitHub APIDiagnostic

A hot endpoint burns 900 points a minute and gets throttled

Points and CPU time are two separate caps on one path. Measure an endpoint's response time and you can compute the request rate it will actually sustain.

GitHub APIDiagnostic

Search has its own 30-per-minute bucket and drains separately

resources.search is not resources.core, and its window is 60 seconds rather than an hour. A search-per-repository loop empties it in the first minute.

GitHub APIDiagnostic

Code search is billed to its own 10 a minute bucket

GET /search/code is metered by resources.code_search, not core and not search. Ten a minute is why a loop over repositories stops within seconds.

GitHub APIDiagnostic

Rotating the token invalidates every cached ETag at once

ETags are scoped to the credential that minted them. When an App installation token expires each hour, a cache that returned 304s returns 200s instead.

GitHub APIDiagnostic

The integration polls for events a webhook would push

An empty hook list next to a climbing core counter is the signature of a poller. Read the inventory, cost the loop in latency, and print the hook to create.

GitHub APIDiagnostic

The x-poll-interval header is ignored on events endpoints

Events endpoints return x-poll-interval, the minimum seconds to wait. Polling faster returns the same cached page, and without an ETag you pay for each one.

GitHub APIDiagnostic

GITHUB_TOKEN gets 1,000 an hour, shared across the repo

The Actions token has a 1,000 an hour ceiling belonging to the repository, not the job. GET /rate_limit names the class; arithmetic names the starving job.

GitHub APIDiagnostic

401 Bad credentials on every endpoint, even public ones

Bad credentials means GitHub parsed your token and refused it. Requires authentication means it never arrived. Two words apart, two different repairs.

GitHub APIDiagnostic

A classic PAT passed its expiry and everything broke at once

An expired token and a truncated one give the same 401. Running the same ladder under a control credential is what proves the token is the variable.

GitHub APIDiagnostic

The token expires in days and nothing is watching the clock

Every authenticated response carries the token's expiry in a header. Reading it turns a total outage into a calendar entry; absence is its own finding.

GitHub APIDiagnostic

The endpoint accepts a scope your token was never given

x-oauth-scopes and x-accepted-oauth-scopes arrive on the same refused response. One says what the token holds, the other what would have worked.

GitHub APIDiagnostic

A read-only job holds a token that can delete repositories

Nothing is failing, which is the finding. GET /user names the scopes the token holds, and a read-only job has never used most of the verbs they grant.

GitHub APIDiagnostic

The client still sends a username and password to the API

401 Support for password authentication was removed. The credential is not the problem, the mechanism is. Check the scheme before anything goes on the wire.

GitHub APIDiagnostic

The token is passed as an access_token query parameter

GitHub ignores ?access_token= and the call drops to anonymous. The real cost is the copies it left behind in proxy logs, CI output and browser history.

GitHub APIDiagnostic

Every request 403s because there is no User-Agent header

GitHub applies the User-Agent rule before it looks at your credential, so this 403 hits endpoints that need no credential at all. The body names the rule.

GitHub APIDiagnostic

A JWT sent as token, and the 401 blames the credential

Bearer and the legacy token word are not interchangeable. An App JWT sent under token fails as Bad credentials, and its own shape says which is right.

GitHub APIDiagnostic

A classic token nobody used for a year is deleted for you

GitHub removes classic tokens after a year of disuse, and that class emits no expiry header. The only clock is how often your own job exercises it.

GitHub APIDiagnostic

One user revoked your app and only their token is dead

A single 401 among healthy tokens is a revocation, not an outage. Read the population: one dead token is that person, every token dead at once is you.

GitHub APIDiagnostic

Some endpoints refuse an installation token whatever it holds

A valid installation token 403s on GET /user because that route needs a user. No permission opens it, so the repair is a different endpoint.

GitHub APIDiagnostic

A pinned X-GitHub-Api-Version stopped being supported

GET /versions lists the versions GitHub still serves. Diff it against the date your client pins and a retirement becomes a calendar entry.

GitHub APIDiagnostic

The automation runs as a person who can leave the company

GET /user returns a type and a profile. A type of User behind a background job is a dependency on somebody's employment rather than on your code.

GitHub APIDiagnostic

A GitHub App JWT that expires in an hour is refused

The exp claim may sit at most ten minutes after iat. Decode the payload of your own JWT and the 401 becomes arithmetic, with no key and no request.

GitHub APIDiagnostic

Clock drift puts the JWT iat claim in GitHub's future

GitHub refuses a JWT whose iat has not happened yet by its clock. Measure the offset against the Date header, then backdate iat past the drift.

GitHub APIDiagnostic

The App JWT is signed with the wrong key or algorithm

A JSON web token could not be decoded means the key is not the one registered on that App. Fingerprint the PEM, then ask GET /app which App you are.

GitHub APIDiagnostic

The installation token expired an hour into the job

Installation access tokens last exactly one hour. A worker that mints one at startup runs green for 60 minutes and then 401s on everything at once.

GitHub APIDiagnostic

A 404 that means the App is not installed on that repo

An App gets 404, not 403, on a repository its installation does not cover. GET /repos/{owner}/{repo}/installation under the JWT answers yes or no.

GitHub APIDiagnostic

The installation is suspended and every call it makes 403s

A suspended App installation still lists, still resolves and refuses everything. suspended_at on the installation record is the only clean signal.

GitHub APIDiagnostic

A new App permission that installers never accepted

Adding a permission to a GitHub App does not apply it to existing installations. Diff the App declaration against each installation grant to find the laggards.

GitHub APIDiagnostic

The App was never subscribed to the event it waits for

A GitHub App receives only the events it declares, and can only declare ones its permissions gate. GET /app names both, so the gap is readable.

GitHub APIDiagnostic

The installation token was narrowed below what the job needs

An installation token can be minted for fewer repositories and permissions than the installation holds. One code path 404s while every other one works.

GitHub APIDiagnostic

The webhook exists but somebody switched it off

An inactive hook produces no deliveries at all, so there is no failure to find. Read the active flag, then work out which of three ways it got there.

GitHub APIDiagnostic

The receiver still checks the legacy SHA-1 signature

GitHub sends X-Hub-Signature and X-Hub-Signature-256. Which one your code verifies is invisible to the API, so read the headers, then read your source.

GitHub APIDiagnostic

SSL verification is switched off on the webhook

config.insecure_ssl set to 1 tells GitHub not to check your endpoint's certificate. Deliveries keep succeeding, which is exactly why nobody looks.

GitHub APIDiagnostic

The webhook posts your payloads to an http:// URL

GitHub delivers to http:// if you ask it to. Payloads and their signature cross the network in the clear, and insecure_ssl still reads a reassuring 0.

GitHub APIDiagnostic

The App's rate limit never grew with the installation

An App installation's hourly ceiling scales with the repositories and users it covers. A narrow installation stays at 5,000 however big the org is.

GitHub APIDiagnostic

A hardcoded installation id stops matching reality

Installation ids are not stable across a reinstall. A stored id either stops resolving or still resolves and belongs to a different account.

GitHub APIDiagnostic

The receiver takes longer than 10 seconds and times out

GitHub allows a receiver 10 seconds. Every delivery record carries a duration, so the slide toward the cutoff is readable long before anything fails.

GitHub APIDiagnostic

The hook subscribes to every event with a wildcard

A hook set to * receives everything GitHub has and everything it ships next. Nothing fails: the receiver pays for payloads it throws away.

GitHub APIDiagnostic

The hook sends form-encoded bodies to a JSON receiver

config.content_type defaults to form, which wraps the event in a payload= field. A strict receiver returns 400; a tolerant one returns 200 and does nothing.

GitHub APIDiagnostic

A firewall allow-list no longer matches GitHub's hook IPs

GET /meta publishes the CIDR ranges webhooks are delivered from, and they change. An allow-list pasted from the docs once starts blocking the new ones.

GitHub APIDiagnostic

The webhook secret is set and has never been rotated

config.secret comes back masked, so the API cannot date it. updated_at is the only clock there is, and it is conclusive in exactly one direction.

GitHub APIDiagnostic

The GitHub App has no webhook URL configured

An App's webhook lives on the App, not the installation. GET /app/hook/config reads it, and a blank or smee.io URL means no event has ever arrived.

GitHub APIDiagnostic

Per_page above 100 is clamped and never rejected

A client asks for per_page=500, receives exactly 100, and reads the short page as the end of the list. GitHub lowers the value instead of refusing it.

GitHub APIDiagnostic

The Link header has no rel=last so the page count breaks

GitHub only sends rel=last when it can compute a final page. Where it cannot, a pager that indexes pages or draws a progress bar reads one page and stops.

GitHub APIDiagnostic

The endpoint ignores page and returns page one forever

Passing page=2 returns the same rows as page=1. A few endpoints ignore offset paging entirely and expect before/after cursors, so the loop never ends.

GitHub APIDiagnostic

Incomplete_results is true and the search answer is partial

A search can return 200 with incomplete_results true: the server timed out and sent what it had. The count moves between runs and nothing errors.

GitHub APIDiagnostic

A pull request's files and commits lists are both capped

A review bot reports three files changed on a pull request that changed 900. The files list stops at 3,000 and the commits list at 250, with no error.

GitHub APIDiagnostic

Expensive requests are killed at ten seconds with a 502

A heavy diff or a list on an enormous repository returns 502 after ten seconds. It is not a rate limit, and retrying the same call buys the same failure.

GitHub APIDiagnostic

Rows move between pages and the walk skips records

A nightly sync misses issues at random and processes others twice. Offset pagination over a list sorted on a field that changes is not a stable walk.

GitHub APIDiagnostic

The repository was renamed and every call now 301s

A renamed repository leaves a redirect at the old path. A client that ignores it sees an empty body; one that follows it pays an extra round trip forever.

GitHub APIDiagnostic

GraphQL returns 200 with an errors array and null data

The response is a 200, response.ok is true, and data.repository is null because the failure was written into the body instead of the status line.

GitHub APIDiagnostic

GraphQL data is present but individual fields are null

A query over 50 repositories returns 50 objects and eight of them have null where a private field should be. Nothing throws and the totals under-count.

GitHub APIDiagnostic

GraphQL points run out in a bucket separate from REST

Every GraphQL call returns RATE_LIMITED while REST calls with the same token still work, because GraphQL is billed in points from its own hourly budget.

GitHub APIDiagnostic

A nested GraphQL query requests more than 500,000 nodes

Three levels of first: 100 asks for 1,010,100 nodes against a cap of 500,000. GitHub rejects the query before running it, however small the org is.

GitHub APIDiagnostic

A GraphQL connection asks for first: 500 and is rejected

Every GraphQL connection caps first and last at 100. Unlike REST, which clamps per_page silently, the query is rejected before it runs and returns no data key.

GitHub APIDiagnostic

Nested GraphQL connections truncate at 100 per parent

The outer connection pages correctly and the inner ones restart each time, so every parent quietly returns its first 100 children and totalCount proves it.

GitHub APIDiagnostic

Nobody measured what the GraphQL query actually costs

A query's point cost comes from the connections it could traverse, not the data it returns, so a query returning twelve rows can quietly cost fourteen points.

GitHub APIDiagnostic

A GraphQL query times out at 10s and is charged anyway

GitHub kills a GraphQL request that runs past ten seconds and deducts extra points for it, so a retry loop spends the budget twice and returns nothing.

GitHub APIDiagnostic

A mutation costs five secondary points, a query costs one

Secondary limits weigh a GraphQL document with a mutation at 5 points and one without at 1, against 2,000 a minute. Writes hit the wall sooner.

GitHub APIDiagnostic

GraphQL search stops at the same 1,000 results as REST

issueCount reports 18,000 and hasNextPage turns false after ten pages. Moving the query to GraphQL to escape the REST cap moves it onto the same index.

GitHub APIDiagnostic

GraphQL node ids get stored where REST ids are expected

GraphQL id is an opaque node id, REST id is an integer, and both are called the id. Store whichever arrived and the join between the two returns zero rows.

GitHub APIDiagnostic

403 Resource not accessible by personal access token

A fine-grained token carries permissions rather than scopes and sends no header saying which. Only the endpoint says what it wanted, and only on REST.

GitHub APIDiagnostic

Branch protection is unreadable without admin, not absent

GET .../protection answers 403 without repository admin. An auditor that reads that as absence reports a fully protected organisation as unprotected.

GitHub APIDiagnostic

The repository is archived so every write returns 403

Reads work perfectly and writes 403 forever. The archived boolean on the repository explains it in one field, and it is a permanent skip, not a retry.

GitHub APIDiagnostic

The repository is disabled and behaves like a ghost

A disabled repository still appears in the org listing while its sub-resources stop answering, so every org-wide aggregate quietly counts it as zero.

GitHub APIDiagnostic

The deploy key is read-only and the push needs write

Clones succeed and the push fails from Git rather than the API. read_only on the deploy key object is the finding, and no scope change moves it.

GitHub APIDiagnostic

The scope is right, the account's role on the repo is read

GET /repos carries a permissions object for the authenticated user. push: false explains every refused write, and widening the token's scopes cannot lift it.

GitHub APIDiagnostic

A 403 that means the feature is disabled, not the permission

One switched-off feature answers 403, 404 or 410 depending on the endpoint. security_and_analysis and the has_ flags say which before you call any of them.

GitHub APIDiagnostic

Every call succeeds and reports on a fork, not the upstream

fork is true and source.full_name names the repository you meant. Nothing errors, nothing retries, and the audit is entirely accurate about the wrong object.

GitHub APIDiagnostic

A repository went private and anonymous callers now see 404

The same URL answers 200 with a token and 404 without one. That pair is the visibility change; deletion answers 404 both ways and a rename answers 301.

GitHub APIDiagnostic

The token is valid and was never SSO-authorized for the org

An org that enforces SAML refuses every token until a human authorizes it. The refusal carries x-github-sso: required and the URL that fixes it.

GitHub APIDiagnostic

The SAML session lapsed and the authorization went with it

A token authorized weeks ago starts failing again. The org's credential authorizations carry an expiry date, so the next lapse is a forecast, not a surprise.

GitHub APIDiagnostic

The org restricts OAuth Apps and this one was never approved

An org policy blocks OAuth Apps wholesale. The token works on personal repos, 403s on every org resource, and the refusal carries no SSO header at all.

GitHub APIDiagnostic

A fine-grained token that is waiting for an org owner

GET /user works, the permissions are right, and every org resource 403s. A pending approval fails per owner; a missing permission fails per endpoint.

GitHub APIDiagnostic

The org's IP allow list refuses the runner, not the token

The same token succeeds from a laptop and 403s from CI. The refusal names the address GitHub saw, which is the one piece of evidence no other 403 carries.

GitHub APIDiagnostic

Enforcing 2FA removed the machine account from the org

Turning on required two-factor authentication removes members who do not comply. The token stays valid, the account stops being a member, and org repos 404.

GitHub APIDiagnostic

The org's base permission dropped and repos vanished

default_repository_permission is one org-wide field. Moving it from read to none removes implicit access for every member at once, machine accounts included.

GitHub APIDiagnostic

The App installation was requested and never approved

A non-owner's install becomes a request an owner has to approve. Until they do there is no installation at all, while the product still shows connected.

GitHub APIDiagnostic

The signature audit reads verified and never reads reason

verification is a five-field object. verified: false covers a dozen reasons that are not the same finding, and a missing object is not a false one.

GitHub APIDiagnostic

GitHub answers 404 for the wrong verb, never 405

The path is right and the method is not. The documentation_url in the 404 body separates a route that is not there from one that refuses your verb.

GitHub APIDiagnostic

The org caps token lifetime below your rotation interval

An org policy caps how long a fine-grained token may live. The finding is the granted lifetime against your rotation period, not the days remaining.

GitHub APIDiagnostic

An outside collaborator has repos in an org, not the org

Repository reads work and every org-level call 404s. The account holds repositories inside the organization without being in it, and no scope fixes that.

GitHub APIDiagnostic

The client is pointed at the wrong GitHub host entirely

GET /meta returns installed_version on Enterprise Server and not on github.com, and the root endpoint map names the host that actually answered.

Something not covered here?

These are the ones I keep hitting. If yours is broken in a way none of them describes, tell me on LinkedIn — it is usually how the next note gets written.