Skip to content

GitHub REST API: Requests, Versioning, Pagination and Rate Limits

Lesson 1 of 10Intermediate13 min readGitHub Engineering · GitHub APIVerified: api.github.com returning X-GitHub-Api-Version 2022-11-28, August 2026

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.

Everything hangs off https://api.github.com.

GET /repos/OWNER/REPO retrieve a repository
GET /repos/OWNER/REPO/issues list its issues
POST /repos/OWNER/REPO/issues create one
GET /repos/OWNER/REPO/issues/ISSUE_NUMBER retrieve one
PATCH /repos/OWNER/REPO/issues/ISSUE_NUMBER update it
DELETE /repos/OWNER/REPO/issues/comments/ID delete a comment

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

Terminal window
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.

Do not copy a version string from a tutorial, including this one. Ask the API, which reports the version it selected:

Terminal window
curl -sI https://api.github.com/ | grep -i x-github-api-version-selected

Output at the time of writing:

x-github-api-version-selected: 2022-11-28

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

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.
Terminal window
export GITHUB_TOKEN="YOUR_TOKEN_HERE"
CodeMeaningUsual cause
200OK
201CreatedA successful POST
204No contentSuccessful DELETE or an empty result
301MovedRepository renamed or transferred
304Not modifiedConditional request; costs no rate limit
401UnauthorisedMissing, malformed or revoked token
403ForbiddenInsufficient permission — or a rate limit
404Not foundMissing, or private and invisible to your token
409Conflicte.g. an empty repository
422UnprocessableValid JSON, invalid content
429Too many requestsRate 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.

IdentityRequests per hour
Unauthenticated60
Authenticated user5,000
GitHub App installation5,000 minimum, scaling with installation size
GITHUB_TOKEN in Actions1,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:

Terminal window
curl -sI https://api.github.com/repos/OWNER/REPO \
-H "Authorization: Bearer $GITHUB_TOKEN" \
| grep -i '^x-ratelimit'
x-ratelimit-limit: 5000
x-ratelimit-remaining: 4983
x-ratelimit-used: 17
x-ratelimit-reset: 1787443200

x-ratelimit-reset is a Unix timestamp. Checking your own budget costs nothing:

Terminal window
gh api rate_limit --jq '.resources.core'

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.

Terminal window
curl -sS https://api.github.com/repos/OWNER/REPO \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H 'If-None-Match: "W/\"abc123\""' -i | head -1

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

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.

Terminal window
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:

Terminal window
gh api "repos/OWNER/REPO/issues?per_page=100" --paginate --slurp --jq 'length'
Terminal window
# Read
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" \
| jq '{name, stars: .stargazers_count, issues: .open_issues_count}'
# Create
curl -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"]}'
Terminal window
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"
import os
import 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.

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.

Terminal window
# First request
etag=$(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 request
curl -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:

304

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

A 422 tells you exactly what was wrong. Reading it is the difference between a fix and a guess.

Terminal window
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.

Not all failures are equal, and retrying the wrong ones wastes time while hiding real problems.

ResponseRetry?How
5xxYesExponential backoff
429YesWait for retry-after
403 with x-ratelimit-remaining: 0YesWait for x-ratelimit-reset
403 otherwiseNoA permissions problem
422NoThe request is wrong
404NoMissing, or not permitted
401NoFix the credential
Terminal window
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.

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.

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.

The Accept header does more than select JSON. Several endpoints offer alternative representations, and knowing they exist saves a great deal of parsing.

Media typeReturns
application/vnd.github+jsonStandard JSON
application/vnd.github.rawFile contents, unencoded
application/vnd.github.htmlRendered HTML for Markdown fields
application/vnd.github.diffA unified diff, for commits and pull requests
application/vnd.github.patchA mailbox-format patch
application/vnd.github.star+jsonStargazers with timestamps

Reading a file without base64-decoding a JSON field:

Terminal window
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:

Terminal window
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 -d

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

Committing without a working tree is a genuinely useful capability — automation that updates a config file across many repositories, for instance.

Terminal window
# Read the current file to obtain its blob SHA
current=$(gh api "repos/OWNER/REPO/contents/config.yml?ref=main")
sha=$(jq -r '.sha' <<<"$current")
# Write a new version
gh 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=main

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

Search is a separate API with its own rules, and treating it like the rest is a common mistake.

Terminal window
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.

  1. Read the current API version from the response header and use it in your own request.
  2. Fetch a public repository with curl, then the same with gh api, and compare the effort.
  3. Inspect the rate-limit headers with curl -sI before and after ten requests.
  4. List a busy repository’s issues with and without pagination and compare the counts.
  5. Filter out pull requests and compare again — note the difference from open_issues_count.
  6. Trigger a 422 by posting an Issue with no title, and read the errors array.

Steps 4 and 5 together are the pagination-and-semantics lesson in practice, and both are bugs people usually ship before they learn.

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.

Terminal window
# 1. Create a blob for each file
blob=$(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 tree
head=$(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 it
tree=$(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 commit
commit=$(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 ref
gh 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.

GitHub removes endpoints, and it announces removals through response headers rather than only in documentation.

Terminal window
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.
  • Link with rel="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.

  • 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 Link header rather than counting pages, and never infer the end from a short page.
  • The Issues endpoint includes pull requests; filter on the pull_request key.
  • Conditional requests with ETag return 304 and cost no rate limit.

For any REST client you write, these are the things that separate one that works in testing from one that works in production:

  1. Four headers: authorisation, Accept, X-GitHub-Api-Version, User-Agent.
  2. A timeout on every request. Most HTTP libraries have no default.
  3. Pagination followed via Link, not by incrementing a page counter, and never inferred from a short page.
  4. Retry only 5xx and rate limits. Never 4xx — it will fail identically.
  5. Backoff that respects retry-after, falling back to x-ratelimit-reset.
  6. The error body preserved. A 422’s errors array names the field; discarding it turns a precise message into a status code.
  7. Conditional requests with ETag for anything polled — 304s are free.
  8. The pull_request key filtered whenever you list Issues.
  9. 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.

One endpoint has no equivalent elsewhere and is worth knowing about: /meta reports GitHub’s own network and service information.

Terminal window
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.

Terminal window
gh api meta --jq '.ssh_key_fingerprints.SHA256_ED25519'
ssh-keyscan github.com 2>/dev/null | ssh-keygen -lf - | grep ED25519

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

Check your understanding

4 questions — each one asks you to predict what Git or GitHub will do, not to recall a flag.

An authenticated request to a repository you cannot access returns which status code?
Show answer

404 — GitHub returns 404 for private resources you are not permitted to see, so that the existence of a repository is not leaked. "Not found" usually means "not permitted".

You receive a 403 from a request that worked a minute ago. What should you check first?
Show answer

The rate-limit headers — 403 may mean you are rate-limited, not unauthorised — 403 covers both permission failures and rate limiting. `X-RateLimit-Remaining` and the `Retry-After` header distinguish them.

What is the correct way to fetch every page of a list endpoint?
Show answer

Follow the `Link` header's `rel="next"` until it is absent — A short page is not proof of the end, and not every endpoint reports a total. The `Link` header is the authoritative signal that more pages exist.

You count open issues via the `/issues` endpoint and the number is higher than the Issues tab shows. Why?
Show answer

The Issues endpoint also returns pull requests; filter on the `pull_request` key — Every pull request is also an issue in GitHub's data model. Items that are pull requests carry a `pull_request` key, which is how you exclude them.

Professional ToolkitThe GitHub App vs personal access token decision guide, with a tested installation-token recipe, is in the Professional Toolkit.