GitHub API Automation with Claude Code: Safe Patterns and Examples
Build safe GitHub API automation with Claude Code: tokens, pagination, rate limits, webhooks, and Node examples.
The GitHub API lets you read and update issues, pull requests, releases, repository metadata, workflow state, and webhooks from code. Used well, it turns repetitive repository maintenance into a reviewable workflow: triage new issues, find stale pull requests, draft release notes, and produce a daily repository health report. Used carelessly, it can leak tokens, mutate too much data, or trust fake webhook events.
Claude Code is useful here because it can generate the boring but important parts quickly: a small API client, pagination helpers, rate-limit handling, README instructions, tests, and GitHub Actions schedules. The important point is to ask for safe automation, not just a script that happens to work once. The request should say where the token comes from, what permissions are allowed, what must remain read-only, and how destructive actions are guarded.
Keep the official docs open while building. Start with the GitHub REST API docs, confirm throttling behavior in the REST API rate limit docs, verify incoming events with the webhook delivery validation guide, and consider the GitHub GraphQL API when a report needs many related fields in one query. For adjacent ClaudeCodeLab workflow material, pair this with the Git workflow guide and advanced GitHub Actions guide.
The Safe Shape
Start with read-only automation. A script that lists issues or pull requests is easy to review and hard to damage production data with. Once the output is trusted, add dry-run write plans. Only after that should the script apply labels, close stale items, or publish release drafts.
flowchart LR
A["Give Claude Code goals and guardrails"] --> B["Read with minimal permissions"]
B --> C["Handle pagination and rate limits"]
C --> D["Show a dry-run diff"]
D --> E["Apply with explicit approval"]
E --> F["Run on a schedule or webhook"]
REST and GraphQL solve different problems. REST is usually the best first step because each endpoint maps to a visible resource: list pull requests, add a label, create a release, or read a workflow run. It is easy to debug with fetch, curl, or the browser docs. GraphQL becomes useful when a report needs fields that would otherwise require many REST calls, such as repository, pull request, author, reviews, labels, and milestone data in one result.
| Decision | REST API | GraphQL API |
|---|---|---|
| Best for | Single-resource actions such as issues, PRs, releases | Cross-resource reporting and dashboards |
| Learning curve | Lower, based on URLs and HTTP methods | Higher, requires query and schema design |
| Claude Code prompt | Ask for one endpoint at a time | Ask for schema-aware query design first |
| Main risk | Missing pagination or permissions | Over-fetching, complex queries, unclear cost |
Tokens and Permissions
Treat a GitHub token like a password. Do not paste it into source files, examples, error messages, screenshots, or debug logs. The examples in this article read GITHUB_TOKEN from the environment and never print it. Ask Claude Code to follow the same rule explicitly, because otherwise generated debugging code may log entire request objects.
Prefer fine-grained personal access tokens over classic tokens. Limit them to the repository and permissions required for the job. An issue triage bot may need Issues: Read and write; a stale PR reporter may only need Pull requests: Read-only; a release note generator can often start with Contents: Read and Metadata: Read. A classic token with the broad repo scope is convenient, but it is also easy to overuse.
For GitHub Actions, set workflow permissions deliberately. A daily report should not run with write access. A labeler should have the narrow write permissions it needs and no more. When using Claude Code, ask it to produce a short permission table before it writes YAML. That one step catches many excessive scopes.
This is the kind of prompt that keeps the implementation bounded:
Create a Node.js script that uses the GitHub REST API.
Requirements:
- Read the token from process.env.GITHUB_TOKEN.
- Never print the token or full authorization headers.
- Read owner and repo from environment variables.
- Start read-only. Do not update issues, pull requests, or releases.
- Use fetch and handle status codes, pagination, and rate-limit headers.
- Include a short README section with required GitHub permissions.
Runnable Read-Only Script
The next script lists open issues with Node.js 18 or newer. It is intentionally read-only and uses environment variables for the token, owner, and repository.
export GITHUB_TOKEN="github_pat_xxx"
export GITHUB_OWNER="octocat"
export GITHUB_REPO="Hello-World"
node scripts/list-open-issues.mjs
// scripts/list-open-issues.mjs
const { GITHUB_TOKEN, GITHUB_OWNER, GITHUB_REPO } = process.env;
if (!GITHUB_TOKEN || !GITHUB_OWNER || !GITHUB_REPO) {
throw new Error("Set GITHUB_TOKEN, GITHUB_OWNER, and GITHUB_REPO.");
}
const apiVersion = "2026-03-10";
async function github(path, options = {}) {
const response = await fetch(`https://api.github.com${path}`, {
...options,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${GITHUB_TOKEN}`,
"X-GitHub-Api-Version": apiVersion,
"User-Agent": "claudecodelab-safe-github-api-example",
...(options.headers ?? {}),
},
});
if (!response.ok) {
const body = await response.text();
throw new Error(`GitHub API ${response.status}: ${body.slice(0, 500)}`);
}
return response.json();
}
const issues = await github(
`/repos/${encodeURIComponent(GITHUB_OWNER)}/${encodeURIComponent(GITHUB_REPO)}/issues?state=open&per_page=10`,
);
const rows = issues
.filter((issue) => !issue.pull_request)
.map((issue) => ({
number: issue.number,
title: issue.title,
labels: issue.labels.map((label) => label.name).join(", "),
updated: issue.updated_at,
}));
console.table(rows);
Do not jump from this script straight to closing issues. The next safe step is a dry-run plan that prints proposed labels, comments, or assignees. Require APPLY=true or a similar explicit flag before any mutation. That small barrier prevents many accidental bulk edits.
Pagination and Rate Limits
List endpoints are paginated. per_page=100 is not the same as “all items”; it is only the largest page size for many REST endpoints. If your release note generator reads only the first page, it may miss older merged pull requests. If your stale PR reporter reads only the first page, the report quietly becomes wrong.
Rate limits need the same care. A naive loop that retries immediately on 403 or 429 can burn CI minutes and make throttling worse. Read retry-after and x-ratelimit-reset, wait only within a reasonable ceiling, and stop after a fixed number of attempts. For scheduled jobs, it is often better to fail clearly and retry on the next schedule than to loop forever.
This helper follows Link headers for pagination and waits at most two times when GitHub tells the client to slow down.
// scripts/github-pages.mjs
const token = process.env.GITHUB_TOKEN;
if (!token) throw new Error("Set GITHUB_TOKEN.");
const apiBase = "https://api.github.com";
const apiVersion = "2026-03-10";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function defaultHeaders() {
return {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": apiVersion,
"User-Agent": "claudecodelab-pagination-example",
};
}
function parseNextLink(linkHeader) {
if (!linkHeader) return null;
for (const part of linkHeader.split(",")) {
const match = part.match(/<([^>]+)>;\s*rel="([^"]+)"/);
if (match && match[2] === "next") return match[1];
}
return null;
}
async function githubRequest(url, options = {}, attempt = 0) {
const response = await fetch(url, {
...options,
headers: {
...defaultHeaders(),
...(options.headers ?? {}),
},
});
if ((response.status === 403 || response.status === 429) && attempt < 2) {
const retryAfterSeconds = Number(response.headers.get("retry-after") ?? "0");
const resetSeconds = Number(response.headers.get("x-ratelimit-reset") ?? "0");
const resetDelayMs = resetSeconds > 0 ? resetSeconds * 1000 - Date.now() : 0;
const waitMs = Math.max(retryAfterSeconds * 1000, resetDelayMs, 0);
if (waitMs > 0 && waitMs <= 10 * 60 * 1000) {
await sleep(waitMs + 1000);
return githubRequest(url, options, attempt + 1);
}
}
if (!response.ok) {
const body = await response.text();
throw new Error(`GitHub API ${response.status}: ${body.slice(0, 500)}`);
}
return {
data: await response.json(),
nextUrl: parseNextLink(response.headers.get("link")),
};
}
export async function paginate(path) {
const items = [];
let url = path.startsWith("http") ? path : `${apiBase}${path}`;
while (url) {
const page = await githubRequest(url);
if (!Array.isArray(page.data)) {
throw new Error("paginate() expected an array response.");
}
items.push(...page.data);
url = page.nextUrl;
}
return items;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const owner = process.env.GITHUB_OWNER;
const repo = process.env.GITHUB_REPO;
if (!owner || !repo) throw new Error("Set GITHUB_OWNER and GITHUB_REPO.");
const pulls = await paginate(
`/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/pulls?state=open&per_page=100`,
);
console.table(pulls.map((pr) => ({ number: pr.number, title: pr.title, updated: pr.updated_at })));
}
After Claude Code introduces a helper like this, ask it to search for direct calls to api.github.com and route them through the shared function. That keeps future automation from reintroducing one-page reads.
Webhook Verification and Idempotency
Webhooks are useful when automation should react to events instead of polling. A pull request can trigger a label suggestion, a new issue can enter a triage queue, and a release event can notify another system. The danger is that a webhook endpoint is public. Without signature verification, any caller can send JSON that looks like a GitHub event.
Verify x-hub-signature-256 before trusting the payload. Then make the handler idempotent, which means processing the same delivery twice should not duplicate side effects. Store x-github-delivery in a database or queue system before doing work. In the minimal example below, a Set shows the shape; production code should use durable storage.
npm install express
export GITHUB_WEBHOOK_SECRET="your-webhook-secret"
node webhook-server.mjs
// webhook-server.mjs
import crypto from "node:crypto";
import express from "express";
const secret = process.env.GITHUB_WEBHOOK_SECRET;
if (!secret) throw new Error("Set GITHUB_WEBHOOK_SECRET.");
const app = express();
const seenDeliveries = new Set();
function verifySignature(payloadBuffer, signatureHeader) {
if (!signatureHeader) return false;
const expected = `sha256=${crypto
.createHmac("sha256", secret)
.update(payloadBuffer)
.digest("hex")}`;
const actual = Buffer.from(signatureHeader, "utf8");
const expectedBuffer = Buffer.from(expected, "utf8");
return actual.length === expectedBuffer.length && crypto.timingSafeEqual(actual, expectedBuffer);
}
app.post("/github/webhook", express.raw({ type: "application/json" }), (req, res) => {
const signature = req.get("x-hub-signature-256");
if (!verifySignature(req.body, signature)) {
return res.status(401).send("invalid signature");
}
const deliveryId = req.get("x-github-delivery");
if (!deliveryId) return res.status(400).send("missing delivery id");
if (seenDeliveries.has(deliveryId)) {
return res.status(202).send("duplicate ignored");
}
seenDeliveries.add(deliveryId);
const event = req.get("x-github-event");
const payload = JSON.parse(req.body.toString("utf8"));
console.log(
JSON.stringify({
event,
deliveryId,
repository: payload.repository?.full_name,
action: payload.action,
}),
);
return res.status(202).send("accepted");
});
app.listen(process.env.PORT ?? 3000, () => {
console.log("Listening for GitHub webhooks.");
});
When expanding this handler with Claude Code, tell it not to parse or process the body before signature verification, not to perform destructive work directly in the HTTP request, and not to reprocess a known delivery ID. Pushing side effects into a queue makes retries safer.
Four Concrete Use Cases
An issue triage bot can inspect new issues, detect missing reproduction steps, and suggest labels such as bug, question, or needs-repro. Start by writing a daily report instead of posting comments automatically. Once the suggestions are reliable, allow a narrow write path for labels only.
A stale PR reporter can list pull requests that have not changed for 30 days, have pending review requests, or have failing checks. The safer version reports; it does not close pull requests. Closing or labeling should require a separate command with dry-run output and an item limit.
A release note generator can collect merged pull requests between two tags and group them by label. GraphQL is helpful when you need authors, reviewers, labels, and milestones together, but REST is enough for a first version. Keep the generated notes as a draft until a maintainer reviews the wording.
A daily repository health report can combine open issue counts, stale pull requests, failing workflows, Dependabot alerts, recent releases, and review backlog. For teams, the report should highlight the three items that deserve attention today rather than dumping every record. This pairs well with Claude Code workflow automation and the review workflow checklist.
Failure Cases to Avoid
The first failure is logging a classic token. Avoid console.log(process.env), full request dumps, and CI debug output that includes headers. Ask Claude Code to redact secrets and review the result with rg "GITHUB_TOKEN|Authorization|process.env".
The second failure is broad permissions. A read-only report does not need write access. A release draft tool should not have permission to rewrite repository settings. Use fine-grained tokens or GitHub Actions permissions, and add scopes only when the use case proves they are needed.
The third failure is missing pagination. If a script reads only page one, it may look successful while silently ignoring most of the repository. Shared helpers make this easier to prevent.
The fourth failure is a rate-limit loop. Retrying instantly on every failure is not resilience. It is noise. Use headers, backoff, and a maximum number of attempts.
The fifth failure is trusting unsigned webhooks. Always verify the signature and store the delivery ID before side effects. The sixth is destructive bulk editing: mass-closing issues, relabeling every PR, or rewriting releases without dry-run, count limits, audit logs, and rollback thinking.
Where Claude Code Fits
Claude Code should build the scaffold, not own the risk decision. Let it create the API client, tests, README, examples, GitHub Actions schedule, and review checklist. Keep token creation, production permissions, destructive changes, and final release publication with humans.
For team rollout, document this in CLAUDE.md: required environment variables, allowed endpoints, forbidden logging, dry-run rules, maximum mutation count, and verification commands. ClaudeCodeLab can help teams design these rules, review GitHub automation, and train developers to use Claude Code safely. Start with Claude Code training and consultation, or use the free cheatsheet and templates for individual practice.
Summary
GitHub API automation is valuable when it removes repetitive review work without hiding risk. Use REST for direct resource operations, GraphQL for larger reports, narrow token permissions, real pagination, rate-limit handling, verified webhooks, and idempotent processing. Move from read-only scripts to dry-run plans before any write action.
Masa’s hands-on result: the read-only script, pagination helper, and webhook signature verification server above were shaped so they remain copy-paste runnable and safe to review. In actual repository maintenance, starting with a daily report before adding labels created much better team trust than a bot that immediately closed or edited issues.
Free PDF: Claude Code Cheatsheet
Enter your email and download the one-page Claude Code cheatsheet for commands, review habits, and safe workflows.
We handle your data with care and never send spam.
Level up your Claude Code workflow
Start with the free PDF, use Gumroad guides when you need repeatable workflows, and book consultation when rollout or revenue paths need human judgment.
About the Author
Masa
Engineer focused on practical Claude Code workflows. Runs claudecode-lab.com, a 10-language technical media site.
Related Posts
Claude Code Obsidian to CLAUDE.md Workflow: Stop Re-explaining Context
Turn Obsidian working notes into concise CLAUDE.md operating notes that make Claude Code sessions easier to resume.
Claude Code Revenue CTA Routing: Send Articles to PDF, Gumroad, and Consultation
A Claude Code workflow for routing article readers to the free PDF, Gumroad products, or consultation by intent.
Claude Code Team Handoff Rules: Review Evidence, Permissions, Rollback, and Revenue Paths
A practical Claude Code handoff format for team review, proof, permission rules, rollback, free PDF, Gumroad, and consultation paths.
Related Products
The Complete Claude Code Setup & Configuration Guide
From install to team-ready workflow.
A practical guide to installation, CLAUDE.md, hooks, MCP servers, permissions, IDE setup, and CI/CD workflows.
50 Battle-Tested Claude Code Prompt Templates
Copy, paste, ship. 50 production-ready prompts.
Use proven prompts for code review, refactoring, testing, documentation, debugging, architecture, and incident response.