All articles

Building a GitHub App: A Complete Developer Guide

A practical, code-first walkthrough that takes you from an empty repo to a deployed GitHub App handling webhooks and authenticated API calls.

D

DocPilot Team

AI documentation research and engineering from Onzira Labs.

6 min read

Building a GitHub App: A Complete Developer Guide

Most teams reach for a GitHub App before they reach for a personal access token. The reason is simple: an App acts on behalf of the installation, not the user, so permissions are scoped narrowly, actions are auditable, and rate limits jump from 5,000 to 15,000 requests per hour per installation once the App is installed on a repository or organization. If you are building any tool that reads code, opens PRs, comments on issues, or reacts to push events, the App model is the right default.

This guide walks through the full build: registering the App, configuring permissions and webhooks, handling installation flows, authenticating as an installation, and deploying somewhere real. Code samples are in Node.js using @octokit, but the principles translate cleanly to Python, Go, or any language that can make HTTPS calls.

Registering the App

Start by creating the App from the GitHub UI or programmatically. For most projects the UI is faster:

  1. Go to Settings → Developer settings → GitHub Apps → New GitHub App.
  2. Pick a name (must be globally unique across the App namespace).
  3. Set the homepage URL. If you do not have one yet, your repository URL is fine.
  4. Set the Webhook URL to a placeholder; you will swap it for a public tunnel during development.
  5. Generate a private key and download it immediately. GitHub shows it once.

You will leave most of the permission and event choices blank for now and configure them based on what your App actually needs. A practical heuristic: grant the minimum scope that lets your smallest meaningful feature work. You can widen permissions later, but narrowing an existing permission requires every installation owner to approve the change.

Choosing permissions

Permissions are split into Repository, Organization, and User categories. Each permission has read or write. For a documentation tool like DocPilot, the relevant set typically looks like:

PermissionAccessWhy
ContentsReadPull source files
MetadataReadRequired for every App
Pull requestsReadLink docs to open PRs
IssuesWriteComment on PRs/issues
WebhooksReadVerify delivery

Click Create GitHub App, then on the App settings page note three values you will need in environment variables:

  • APP_ID — shown on the general settings page.
  • CLIENT_ID and CLIENT_SECRET — used for OAuth user flows.
  • PRIVATE_KEY — the contents of the .pem file you downloaded.
  • WEBHOOK_SECRET — set this yourself under the Webhook section; it must match between GitHub and your server.

Project scaffolding

Install the Octokit libraries and set up a minimal Express server:

npm init -y
npm install express @octokit/webhooks-methods @octokit/auth-app

Load your secrets from environment variables rather than committing them:

import fs from 'node:fs';
import { App } from 'octokit';

const privateKey = fs.readFileSync(process.env.PRIVATE_KEY_PATH, 'utf8');

export const app = new App({
  appId: process.env.APP_ID,
  privateKey,
  webhooks: { secret: process.env.WEBHOOK_SECRET },
  oauth: { clientId: process.env.CLIENT_ID, clientSecret: process.env.CLIENT_SECRET },
});

The App class from octokit is the cleanest entry point: it bundles installation-token generation, webhook verification, and per-installation clients into a single object.

Local development with a public webhook URL

GitHub can only deliver webhooks to a public URL, so you need a tunnel during development. smee.io is the easiest option and requires no account for low-volume use:

npx smee --url https://smee.io/your-channel --target http://localhost:3000

Copy the smee URL into the Webhook URL field in your App settings, then restart the tunnel whenever you restart your laptop. For something more durable, ngrok, Cloudflare Tunnel, or a local reverse proxy on a VPS all work.

Handling the webhook

Mount a webhook endpoint and register listeners for the events you care about. Below is a working handler for push events that prints the files changed in the default branch:

import express from 'express';
import { app } from './app.js';

const server = express();

server.post('/webhook', async (req, res) => {
  try {
    await app.webhooks.verifyAndReceive({
      request: req,
      response: res,
    });
  } catch (err) {
    res.status(401).send('invalid signature');
  }
});

app.webhooks.on('push', async ({ payload, octokit }) => {
  const { repository, ref, commits } = payload;
  if (ref !== `refs/heads/${repository.default_branch}`) return;

  console.log(`Push to ${repository.full_name}@${ref}: ${commits.length} commits`);
  for (const commit of commits) {
    console.log(`  ${commit.id.slice(0, 7)} ${commit.message.split('\n')[0]}`);
  }
});

server.listen(3000, () => console.log('listening on 3000'));

verifyAndReceive does three jobs at once: it checks the X-Hub-Signature-256 HMAC against your WEBHOOK_SECRET, parses the payload, and dispatches it to the matching event handler. Without verification, an attacker can submit arbitrary events to your endpoint and trigger side effects.

Common events worth knowing

EventFires whenTypical use
pushCommits pushed to a branchSync docs, run CI
pull_requestPR opened, synced, closed, mergedComment, label, auto-merge
issuesIssue opened or editedTriage, link to projects
installationApp installed or uninstalledProvision tenant resources
installation_repositoriesRepos added or removed from an existing installationReconcile stored config

Calling the API as an installation

Most of your API work happens after the App has been installed on a target. Octokit handles token generation for you. Given an installationId from a webhook payload, you can request an authenticated client:

const octokit = await app.getInstallationClient(installationId);

const { data: prs } = await octokit.paginate(octokit.pullRequests.list, {
  owner: payload.repository.owner.login,
  repo: payload.repository.name,
  state: 'open',
});

The client automatically uses a fresh installation token (valid for one hour) and rotates it before expiry. Manual token generation is rarely necessary, but it looks like this:

import { createAppAuth } from '@octokit/auth-app';

const auth = createAppAuth({ appId, privateKey });
const { token } = await auth({ type: 'installation', installationId });

The installation flow

Users discover your App, click Install, and GitHub redirects them to your callback URL with a code query parameter. Exchange the code for an access token using the App's OAuth client credentials:

app.oauth.on('exchange', async ({ redirectUrl }) => {
  return { redirectUrl };
});

When the user visits /github/callback?code=...&installation_id=..., exchange the code and persist the resulting installation. A simple handler:

server.get('/github/callback', async (req, res) => {
  const { code, installation_id: installationId } = req.query;
  const result = await app.oauth.exchangeWebFlowCode({
    code,
    redirectUrl: `${process.env.PUBLIC_URL}/github/callback`,
  });

  await db.saveInstallation({
    installationId,
    userToken: result.authentication.token,
  });

  res.redirect('/installed');
});

Treat installation_id as the durable identifier. It survives uninstall and reinstall, but you must handle the installation.deleted event to clean up local state.

Deployment

For a single-tenant App that runs against your own repos, a single container on Fly, Railway, Render, or a small VPS is plenty. For a multi-tenant SaaS, the same image scales horizontally; the only stateful piece is your database.

A minimal Dockerfile:

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

Two operational notes from running GitHub Apps in production:

  • Webhooks are at-least-once. Make every handler idempotent by recording the X-GitHub-Delivery header in your database and short-circuiting duplicates.
  • GitHub sends a ping event immediately after you change the Webhook URL. Use it as a smoke test in your deploy script.

Security checklist

  • Verify every webhook signature.
  • Store the private key and webhook secret in your platform's secret manager, not in the repo.
  • Scope every permission to the minimum needed.
  • Treat user-supplied input from issue.title, pull_request.body, and similar fields as untrusted. Run any markdown rendering through a sanitizer before storing or echoing it.
  • Rotate the webhook secret periodically and after any suspected compromise; rotation requires updating your App settings and redeploying.

Where to go next

Once the skeleton is running, the natural extensions are: persisting installation metadata, building a settings UI for repository-level configuration, and adding background jobs for expensive operations like repository-wide search or LLM summarization. Each of these is straightforward when the foundations are solid.

If you are building something that needs to read source code, generate documentation, and keep it in sync with every commit, that is exactly the workflow DocPilot was built for. DocPilot connects to a GitHub repository, generates README, setup, architecture, and API reference documents from your code, and uses webhooks to refresh them on every push. The integration pattern is the same one outlined above: register a GitHub App, install it on your repos, and let the webhook do the rest. The DocPilot docs cover a complete working example you can adapt for your own service. If you are maintaining an open-source project, the free tier covers the basics, and it is the fastest way to see the App-plus-webhooks pattern working end to end without writing the boilerplate yourself.

Ship documentation that keeps up

Try DocPilot on your repository — free for one repo.

Connect your first repository in about a minute. No credit card. DocPilot writes and maintains the documentation from your code.

Read next