The REST API exposes GitHub resources and actions through HTTP endpoints.
Resources have URLs, methods do things to them, and responses are JSON. If you have used any HTTP API the shape will be familiar; what follows concentrates on the parts specific to GitHub, and on the three that cause most real bugs — versioning, pagination and rate limits.
The base and the shape
Section titled “The base and the shape”Everything hangs off https://api.github.com.
GET /repos/OWNER/REPO retrieve a repositoryGET /repos/OWNER/REPO/issues list its issuesPOST /repos/OWNER/REPO/issues create oneGET /repos/OWNER/REPO/issues/ISSUE_NUMBER retrieve onePATCH /repos/OWNER/REPO/issues/ISSUE_NUMBER update itDELETE /repos/OWNER/REPO/issues/comments/ID delete a commentNote the asymmetry in that last line. Issues cannot be deleted through this endpoint; comments can.
GitHub’s REST surface reflects the product’s semantics rather than a uniform CRUD mapping, and
assuming DELETE exists because GET does is a reliable way to get a 404.
The four headers
Section titled “The four headers”curl -sS https://api.github.com/repos/OWNER/REPO \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ -H "User-Agent: my-tool/1.0"What it doesMakes an authenticated request with the headers GitHub expects.
Why we run itEach header does a distinct job: the token authenticates, Accept selects the response format, the version header pins the API's behaviour, and User-Agent is required.
Expected resultA JSON object describing the repository.
Authorization: Bearer carries the credential. token is also accepted for historical reasons;
Bearer is the current form.
Accept: application/vnd.github+json selects the standard JSON representation. Some endpoints
offer alternatives — .raw for file contents, .diff and .patch for pull requests,
.star+json for star timestamps.
X-GitHub-Api-Version pins the API version.
User-Agent is required. Requests without one are rejected. Use something identifying your tool.
Determining the current version
Section titled “Determining the current version”Do not copy a version string from a tutorial, including this one. Ask the API, which reports the version it selected:
curl -sI https://api.github.com/ | grep -i x-github-api-version-selectedOutput at the time of writing:
x-github-api-version-selected: 2022-11-28That is the value to send. Pinning it means a future breaking version does not silently change your client’s behaviour; omitting it means you get the default, which can move.
Authentication in brief
Section titled “Authentication in brief”Full treatment is in API Authentication. The essentials:
- Unauthenticated requests work for public data and are heavily rate-limited.
- A fine-grained personal access token is the usual choice for personal scripts.
- A GitHub App installation token is the usual choice for organisation automation.
- In Actions, the workflow token is provided automatically.
export GITHUB_TOKEN="YOUR_TOKEN_HERE"Status codes worth handling
Section titled “Status codes worth handling”| Code | Meaning | Usual cause |
|---|---|---|
200 | OK | |
201 | Created | A successful POST |
204 | No content | Successful DELETE or an empty result |
301 | Moved | Repository renamed or transferred |
304 | Not modified | Conditional request; costs no rate limit |
401 | Unauthorised | Missing, malformed or revoked token |
403 | Forbidden | Insufficient permission — or a rate limit |
404 | Not found | Missing, or private and invisible to your token |
409 | Conflict | e.g. an empty repository |
422 | Unprocessable | Valid JSON, invalid content |
429 | Too many requests | Rate limited |
Two deserve emphasis.
404 is ambiguous by design. GitHub returns 404 rather than 403 for private resources your token cannot see, so that the API does not disclose the existence of private repositories. “Not found” frequently means “not permitted”.
403 may be a rate limit. Historically rate-limit rejections came back as 403 with a specific
message; 429 is also used. Check x-ratelimit-remaining before concluding it is a permissions
problem.
422 tells you what was wrong. The response body contains an errors array naming the offending
field — always read it rather than guessing.
Rate limits
Section titled “Rate limits”| Identity | Requests per hour |
|---|---|
| Unauthenticated | 60 |
| Authenticated user | 5,000 |
| GitHub App installation | 5,000 minimum, scaling with installation size |
GITHUB_TOKEN in Actions | 1,000 per repository per hour |
Enterprise Cloud organisations have higher allowances. Secondary limits also apply and are separate: no more than 100 concurrent requests, no more than 900 points per minute across REST endpoints, and content-creating requests capped at 80 per minute and 500 per hour.
That last one matters for any script creating Issues or comments in a loop — it will hit the secondary limit long before the primary one.
Every response carries the state:
curl -sI https://api.github.com/repos/OWNER/REPO \ -H "Authorization: Bearer $GITHUB_TOKEN" \ | grep -i '^x-ratelimit'x-ratelimit-limit: 5000x-ratelimit-remaining: 4983x-ratelimit-used: 17x-ratelimit-reset: 1787443200x-ratelimit-reset is a Unix timestamp. Checking your own budget costs nothing:
gh api rate_limit --jq '.resources.core'Behaving well
Section titled “Behaving well”Back off on 403 and 429. Respect retry-after when present; otherwise wait until
x-ratelimit-reset rather than retrying immediately.
Use conditional requests. Send If-None-Match with a previously returned ETag; a 304
response does not count against your limit. For anything polled, this is the single biggest saving
available.
curl -sS https://api.github.com/repos/OWNER/REPO \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H 'If-None-Match: "W/\"abc123\""' -i | head -1Do not poll. If you are asking every minute whether something changed, you want webhooks.
Request more per call. per_page=100 is the maximum and turns four requests into one.
Pagination
Section titled “Pagination”The most common correctness bug in GitHub automation: a script tested against twenty objects works, and silently processes only the first thirty of four hundred in production.
curl -sS "https://api.github.com/repos/OWNER/REPO/issues?per_page=100&page=2" \ -H "Authorization: Bearer $GITHUB_TOKEN"The Link header carries the navigation, and following it is more robust than incrementing a
page counter:
link: <https://api.github.com/repositories/1300192/issues?page=3>; rel="next", <https://api.github.com/repositories/1300192/issues?page=17>; rel="last"Loop until there is no rel="next". Do not stop when a page returns fewer than per_page items —
that is a heuristic, not a contract.
gh api --paginate handles all of this:
gh api "repos/OWNER/REPO/issues?per_page=100" --paginate --slurp --jq 'length'Worked examples
Section titled “Worked examples”# Readcurl -sS https://api.github.com/repos/OWNER/REPO \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ | jq '{name, stars: .stargazers_count, issues: .open_issues_count}'
# Createcurl -sS -X POST https://api.github.com/repos/OWNER/REPO/issues \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ -d '{"title":"Retry logic drops the final attempt","labels":["bug"]}'gh api
Section titled “gh api”gh api repos/OWNER/REPO --jq '{name, stars: .stargazers_count}'gh api --method POST repos/OWNER/REPO/issues -f title="Retry bug" -f "labels[]=bug"Python
Section titled “Python”import osimport requests
TOKEN = os.environ["GITHUB_TOKEN"]SESSION = requests.Session()SESSION.headers.update({ "Authorization": f"Bearer {TOKEN}", "Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", "User-Agent": "my-tool/1.0",})
def get_repo(owner: str, repo: str) -> dict: response = SESSION.get( f"https://api.github.com/repos/{owner}/{repo}", timeout=10, ) response.raise_for_status() return response.json()A Session reuses the connection and sets the headers once. timeout is not optional — without it a
hung connection blocks forever, which in a scheduled job means a stuck run rather than a failed one.
Python + GitHub API develops this into a small maintainable client.
Conditional requests in practice
Section titled “Conditional requests in practice”The single most effective rate-limit optimisation, and the one most often skipped.
Every response carries an ETag. Sending it back means GitHub answers 304 Not Modified when
nothing changed — and a 304 does not count against your rate limit.
# First requestetag=$(curl -sS -D - -o /dev/null https://api.github.com/repos/OWNER/REPO \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ | grep -i '^etag:' | cut -d' ' -f2- | tr -d '\r')
# Subsequent requestcurl -sS -o /dev/null -w '%{http_code}\n' https://api.github.com/repos/OWNER/REPO \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "If-None-Match: $etag"Output when nothing changed:
304For anything polled this is transformative. A dashboard refreshing fifty repositories every minute is 72,000 requests a day without conditional requests — well over any limit — and close to zero with them.
Last-Modified with If-Modified-Since works the same way on endpoints that provide it.
The caveat: store the ETag per URL, including its query string. ?state=open and ?state=closed
are different resources with different tags, and mixing them produces incorrect 304s.
Handling errors properly
Section titled “Handling errors properly”A 422 tells you exactly what was wrong. Reading it is the difference between a fix and a guess.
curl -sS -X POST https://api.github.com/repos/OWNER/REPO/issues \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -d '{"body":"No title supplied"}' | jq{ "message": "Validation Failed", "errors": [ { "resource": "Issue", "code": "missing_field", "field": "title" } ], "documentation_url": "https://docs.github.com/rest/issues/issues#create-an-issue"}errors[].field names the problem and documentation_url links the endpoint. A client that discards
the body and reports “HTTP 422” has thrown away everything useful.
Common code values worth handling distinctly: missing_field, invalid, already_exists and
unprocessable. already_exists in particular is often success for an idempotent operation —
creating a label that is already there.
Retry strategy
Section titled “Retry strategy”Not all failures are equal, and retrying the wrong ones wastes time while hiding real problems.
| Response | Retry? | How |
|---|---|---|
5xx | Yes | Exponential backoff |
429 | Yes | Wait for retry-after |
403 with x-ratelimit-remaining: 0 | Yes | Wait for x-ratelimit-reset |
403 otherwise | No | A permissions problem |
422 | No | The request is wrong |
404 | No | Missing, or not permitted |
401 | No | Fix the credential |
retry_after() { local headers="$1" local ra reset ra=$(grep -i '^retry-after:' <<<"$headers" | tr -d '\r' | awk '{print $2}') [ -n "$ra" ] && { echo "$ra"; return; } reset=$(grep -i '^x-ratelimit-reset:' <<<"$headers" | tr -d '\r' | awk '{print $2}') [ -n "$reset" ] && { echo $(( reset - $(date +%s) + 1 )); return; } echo 60}Preferring retry-after when present and falling back to x-ratelimit-reset is the correct order —
the first is GitHub telling you exactly how long to wait.
Endpoint shapes worth knowing
Section titled “Endpoint shapes worth knowing”A few patterns recur across the API and knowing them makes unfamiliar endpoints predictable.
Collections paginate. Anything returning a list supports per_page and page, and returns
Link.
Sub-resources nest under their parent. /repos/{owner}/{repo}/issues/{number}/comments — the
path mirrors the ownership.
Some sub-resources are collections you PUT to. Adding a collaborator is
PUT /repos/{owner}/{repo}/collaborators/{username} — the username is in the path, and the operation
is idempotent, which is why it is PUT rather than POST.
204 means success with no body. Common for DELETE and for PUT on membership-style endpoints. A
client checking for a JSON body will report a false failure.
Some endpoints have a different host. Release asset uploads go to the upload_url returned when
the release was created, not to api.github.com.
Common mistakes
Section titled “Common mistakes”Ignoring pagination. Silently incomplete results.
Forgetting pull requests appear in the Issues endpoint. Overstated counts.
Hardcoding a version copied from a tutorial. Ask the API instead.
Omitting User-Agent. Requests are rejected.
Treating 404 as definitely-not-there. It often means not-permitted.
Assuming 403 is permissions. Check the rate-limit headers.
Polling instead of using webhooks. Slower, costlier, less reliable.
No timeout. A hung request blocks indefinitely.
Media types
Section titled “Media types”The Accept header does more than select JSON. Several endpoints offer alternative representations,
and knowing they exist saves a great deal of parsing.
| Media type | Returns |
|---|---|
application/vnd.github+json | Standard JSON |
application/vnd.github.raw | File contents, unencoded |
application/vnd.github.html | Rendered HTML for Markdown fields |
application/vnd.github.diff | A unified diff, for commits and pull requests |
application/vnd.github.patch | A mailbox-format patch |
application/vnd.github.star+json | Stargazers with timestamps |
Reading a file without base64-decoding a JSON field:
curl -sS "https://api.github.com/repos/OWNER/REPO/contents/README.md?ref=main" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github.raw"Compare with the default, which returns metadata plus base64 content and needs decoding:
curl -sS "https://api.github.com/repos/OWNER/REPO/contents/README.md" \ -H "Authorization: Bearer $GITHUB_TOKEN" \ -H "Accept: application/vnd.github+json" \ | jq -r '.content' | base64 -dThe raw form is one step instead of three, and it does not fail on the size threshold above which the JSON form omits content entirely.
Writing files through the API
Section titled “Writing files through the API”Committing without a working tree is a genuinely useful capability — automation that updates a config file across many repositories, for instance.
# Read the current file to obtain its blob SHAcurrent=$(gh api "repos/OWNER/REPO/contents/config.yml?ref=main")sha=$(jq -r '.sha' <<<"$current")
# Write a new versiongh api --method PUT "repos/OWNER/REPO/contents/config.yml" \ -f message="Bump the retry limit to five" \ -f content="$(base64 -w0 < new-config.yml)" \ -f sha="$sha" \ -f branch=mainThe sha parameter is a concurrency guard, exactly like sha on the merge endpoint: it is the blob
SHA you read, and the write fails with 409 if the file changed in between. Omitting it on an update
is an error; omitting it when creating a file is correct, since there is nothing to conflict with.
Searching
Section titled “Searching”Search is a separate API with its own rules, and treating it like the rest is a common mistake.
gh api "search/repositories?q=$(printf '%s' 'language:go stars:>1000 pushed:>2026-01-01' | jq -sRr @uri)&per_page=10" \ --jq '.items[] | [.full_name, .stargazers_count] | @tsv'Three constraints matter:
A much stricter rate limit — a handful of requests per minute rather than thousands per hour, and counted separately from the core limit.
A hard cap on total results. Search will not enumerate an unlimited set, so it is unsuitable for exhaustive listing. Use it to find things; use list endpoints to enumerate them.
Different pagination behaviour, with total_count reported separately from what you can actually
page through.
The rule: search answers “which things match this?”, list endpoints answer “give me all of these”. Using search where a list endpoint would do is how scripts hit an unexpected limit at exactly the wrong moment.
Exercise
Section titled “Exercise”- Read the current API version from the response header and use it in your own request.
- Fetch a public repository with
curl, then the same withgh api, and compare the effort. - Inspect the rate-limit headers with
curl -sIbefore and after ten requests. - List a busy repository’s issues with and without pagination and compare the counts.
- Filter out pull requests and compare again — note the difference from
open_issues_count. - Trigger a 422 by posting an Issue with no title, and read the
errorsarray.
Steps 4 and 5 together are the pagination-and-semantics lesson in practice, and both are bugs people usually ship before they learn.
The Git Data API
Section titled “The Git Data API”Most of the REST API models GitHub’s product. A smaller set models Git itself — blobs, trees, commits and refs — and it is what you use to build a commit without a working tree.
# 1. Create a blob for each fileblob=$(gh api --method POST "repos/OWNER/REPO/git/blobs" \ -f content="$(base64 -w0 < new-file.txt)" -f encoding=base64 --jq '.sha')
# 2. Read the current commit and its treehead=$(gh api "repos/OWNER/REPO/git/ref/heads/main" --jq '.object.sha')base_tree=$(gh api "repos/OWNER/REPO/git/commits/$head" --jq '.tree.sha')
# 3. Build a new tree on top of ittree=$(gh api --method POST "repos/OWNER/REPO/git/trees" --input - --jq '.sha' <<JSON{ "base_tree": "$base_tree", "tree": [ { "path": "docs/new-file.txt", "mode": "100644", "type": "blob", "sha": "$blob" } ]}JSON)
# 4. Create the commitcommit=$(gh api --method POST "repos/OWNER/REPO/git/commits" \ -f message="Add the new file" -f tree="$tree" -f "parents[]=$head" --jq '.sha')
# 5. Move the refgh api --method PATCH "repos/OWNER/REPO/git/refs/heads/main" -f sha="$commit"That is five calls where the contents endpoint would be one — and it produces one commit for any number of files, where the contents endpoint produces one commit per file.
It is also a direct mapping of the object model onto HTTP, which makes it a genuinely useful way to check your understanding of Git: blobs hold content, trees hold structure, commits point at a tree and parents, and a branch is a ref you move.
Deprecations
Section titled “Deprecations”GitHub removes endpoints, and it announces removals through response headers rather than only in documentation.
gh api some/endpoint --include --silent 2>/dev/null \ | grep -iE '^(deprecation|sunset|link.*deprecation)'Deprecation— the endpoint is deprecated, with a date.Sunset— the date it will stop working.Linkwithrel="deprecation"— documentation explaining the replacement.
Checking these occasionally on anything you depend on converts a future outage into a scheduled piece
of work. A client that logs a warning when it sees a Sunset header gets you that for free.
The API version header interacts with this: pinning a version means breaking changes introduced in a later version do not affect you, but a removed endpoint is removed regardless. Versioning protects against changes in behaviour, not against sunset.
What you learned
Section titled “What you learned”- Four headers matter: authorisation, Accept, API version and User-Agent.
- The current API version is reported by the server; ask rather than copy.
- 404 often means not-permitted; 403 may mean rate-limited.
- Limits are 60 unauthenticated, 5,000 authenticated, 1,000 per repository for the Actions token, with separate and stricter secondary limits for content creation.
- Follow the
Linkheader rather than counting pages, and never infer the end from a short page. - The Issues endpoint includes pull requests; filter on the
pull_requestkey. - Conditional requests with
ETagreturn 304 and cost no rate limit.
A request checklist
Section titled “A request checklist”For any REST client you write, these are the things that separate one that works in testing from one that works in production:
- Four headers: authorisation,
Accept,X-GitHub-Api-Version,User-Agent. - A timeout on every request. Most HTTP libraries have no default.
- Pagination followed via
Link, not by incrementing a page counter, and never inferred from a short page. - Retry only 5xx and rate limits. Never 4xx — it will fail identically.
- Backoff that respects
retry-after, falling back tox-ratelimit-reset. - The error body preserved. A 422’s
errorsarray names the field; discarding it turns a precise message into a status code. - Conditional requests with
ETagfor anything polled — 304s are free. - The
pull_requestkey filtered whenever you list Issues. - No credentials in logs, including in debug output that dumps headers.
Items 3 and 8 are the two that produce silently wrong results rather than errors, which makes them the most expensive to discover. Everything else fails loudly.
The meta endpoint
Section titled “The meta endpoint”One endpoint has no equivalent elsewhere and is worth knowing about: /meta reports GitHub’s own
network and service information.
gh api meta --jq '{ hooks: (.hooks | length), api: (.api | length), actions: (.actions | length), ssh_keys: (.ssh_key_fingerprints | keys)}'{ "actions": 3576, "api": 22, "hooks": 6, "ssh_keys": ["SHA256_ECDSA", "SHA256_ED25519", "SHA256_RSA"]}Three practical uses.
Firewall allowlisting. hooks lists the IP ranges webhook deliveries originate from. If your
receiver sits behind a firewall, this is the authoritative source — and it changes, so a deployment
that hardcodes it will eventually stop receiving events. Fetching it periodically is the correct
approach.
Egress rules. api and actions list the ranges GitHub serves from, which is what you need when
an environment restricts outbound traffic.
Host key verification. ssh_key_fingerprints gives GitHub’s SSH host key fingerprints, so an
automated environment can verify the host key rather than accepting it blindly on first connection —
which is what StrictHostKeyChecking=accept-new does, and which is not verification.
gh api meta --jq '.ssh_key_fingerprints.SHA256_ED25519'ssh-keyscan github.com 2>/dev/null | ssh-keygen -lf - | grep ED25519Comparing those two is a genuine check that you are talking to GitHub, and it requires no credential —
/meta is one of the few endpoints that works unauthenticated.
Related lessons
Section titled “Related lessons”Check your understanding
4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.