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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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_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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.