How I Built a System to Automatically Sell Access to a Private GitHub Repo

How I Built a System to Automatically Sell Access to a Private GitHub Repo

# nextjs# typescript# github# webdev
How I Built a System to Automatically Sell Access to a Private GitHub RepoJason

TL;DR: I needed to sell access to a private GitHub repo. Zip downloads don't get updates, license...

TL;DR: I needed to sell access to a private GitHub repo. Zip downloads don't get updates, license keys are overkill, and multiple payment providers rejected my business. I ended up building a fully automated system that handles the whole flow: payment webhook fires, signature verified, GitHub username captured via OAuth, collaborator invite sent in seconds. It works great, but turns out Polar.sh already has this built in and does it better. I didn't end up using my version, but I learned a ton building it.


I built a boilerplate I wanted to sell. The product was done. The hard part was figuring out how to actually deliver it.

The Problem Nobody Talks About

You build a starter kit, a boilerplate, a template, something developers would pay for. Now what? How do you actually get it to them after they pay?

I spent way too long researching this. The options were all bad in different ways:

  • Zip file download. Works, but customers don't get updates. You ship a fix and they're stuck on the old version forever. No git history, no pull, no diff.
  • License key gating. Build a custom CLI that checks a license before cloning. Way too much infrastructure for what should be simple.
  • npm private package. Requires customers to configure registry auth. Friction kills conversions.
  • GitHub Sponsors with private repos. Subscription-only. Doesn't work for one-time purchases.
  • Manual delivery through a payment platform. You get a payment notification and manually add them. Fine until you get 3 sales at 2am.

What I actually wanted was dead simple: customer pays, customer gets access to the private repo, automatically, instantly.

The Payment Provider Problem

Before I even got to the technical stuff, I hit a wall with payment providers.

My registered business is a design and consulting firm. I tried signing up with Lemon Squeezy and a couple other platforms to sell digital products. They rejected me. I explained I was selling one-time digital products, boilerplate code, starter kits, not consulting services. Didn't matter. Still rejected. Some of them were pretty rude about it too.

At this point I wasn't sure what payment provider I'd even use. I just knew I needed something that could fire a webhook on payment so I could automate the rest myself.

How I Landed on the Solution

After a lot of searching I came across a project doing something similar, using payment webhooks to trigger GitHub collaborator invitations. Private repo, read-only access. Customer can clone, pull updates, see full git history. That was exactly what I wanted. They were charging $30 for it. I figured I could probably make it myself, and I needed to solve the problem anyway, so two birds.

I looked at how hard it would be to build my own version. Turns out the pieces are all there:

  • GitHub's API lets you add collaborators programmatically
  • Any payment provider that fires webhooks can trigger the flow
  • You can set permissions to pull (read-only) so customers can clone but can't push

The idea was to build a standalone system I could run on my own server. Any payment provider with webhooks would work. I'd handle the GitHub OAuth, the invitation, the email, everything myself.

So I built it.

The Architecture

User clicks "Buy"
       |
GitHub OAuth (capture username)
       |
Payment checkout
       |
Webhook fires -> verify signature -> invite to repo -> send email
       |
Customer has access in seconds
Enter fullscreen mode Exit fullscreen mode

A Next.js app with five pieces:

  • GitHub OAuth captures the buyer's GitHub username before checkout
  • Webhook handler receives payment confirmation with HMAC-SHA256 verification
  • GitHub API sends the repository invitation automatically
  • PostgreSQL tracks every customer (33 fields of data)
  • Resend sends the welcome email

The whole thing is designed to be payment provider agnostic. Plug in any provider that sends webhooks and it works.

The Parts That Were Harder Than Expected

You Need the GitHub Username Before Payment

Your payment provider doesn't know the buyer's GitHub username. You need to capture it before they hit checkout.

The flow: user clicks "Buy" -> GitHub OAuth popup -> authorize -> redirect to checkout with the username passed along.

// After OAuth callback, redirect to checkout with GitHub info
const checkoutUrl = new URL(CHECKOUT_URL);
checkoutUrl.searchParams.set('gh_username', githubUser.login);
checkoutUrl.searchParams.set('gh_user_id', String(githubUser.id));
Enter fullscreen mode Exit fullscreen mode

The payment provider includes these fields in the webhook payload, so when the payment completes, I know exactly who to invite.

Webhook Signature Verification

Anyone can POST to your webhook endpoint. Without signature verification, an attacker could grant themselves free access.

import { createHmac, timingSafeEqual } from 'crypto';

function verifySignature(payload: string, signature: string): boolean {
  const expected = createHmac('sha256', WEBHOOK_SECRET)
    .update(payload)
    .digest('hex');

  return timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}
Enter fullscreen mode Exit fullscreen mode

Two things that bit me:

  • Use the raw request body, not parsed JSON. Parsing and re-serializing changes whitespace, which breaks the signature.
  • Use timingSafeEqual, not ===. Regular string comparison leaks timing information that can be exploited.

Repeat Purchases Break Without UPSERT

A customer buys your boilerplate, then buys an upgrade or a second product. Without UPSERT, the second webhook fails with a unique constraint violation on their email:

INSERT INTO customers (email, name, amount_paid, ...)
VALUES (...)
ON CONFLICT (email) DO UPDATE SET
  amount_paid = EXCLUDED.amount_paid,
  order_id = EXCLUDED.order_id,
  updated_at = NOW()
Enter fullscreen mode Exit fullscreen mode

GitHub Has a 50 Pending Invitation Limit

If a customer doesn't accept their invitation, it stays pending. After 50 pending invitations, GitHub blocks new ones entirely. I track invitation status and built an admin panel to monitor this.

Handling Failures

The GitHub API goes down. Rate limits hit. Network blips happen. A customer paying for your boilerplate can't just get an error and be told to contact support.

I built a retry system with exponential backoff:

Attempt 1:  1 second
Attempt 2:  2 seconds
Attempt 3:  4 seconds
Attempt 4:  8 seconds
...
Attempt 10: 1 hour
Enter fullscreen mode Exit fullscreen mode

Errors get classified automatically:

  • Retryable (network timeouts, rate limits, 5xx errors) go to the retry queue with backoff
  • Permanent (user not found, repo not found, 401/403) go to a dead letter queue for manual review

After 10 failed attempts, the item moves to a dead letter queue and I get notified.

The OAuth Gotcha That Wasted Hours

The GitHub token exchange endpoint is https://github.com, NOT https://api.github.com:

# WRONG, returns HTML login page
POST https://api.github.com/login/oauth/access_token

# CORRECT, returns the token
POST https://github.com/login/oauth/access_token
Enter fullscreen mode Exit fullscreen mode

Every OAuth guide I found was correct, but my muscle memory from using the GitHub API kept making me use the API subdomain.

The Stack

Component Technology
Framework Next.js 16 (App Router)
Language TypeScript (strict mode)
Database PostgreSQL on Neon
GitHub API Octokit
Email Resend
Validation Zod
Testing Vitest, all passing

Built to run on any hosting provider. Runs on Vercel's free tier for low volume.

Why Private Repo + Collaborator Invite Wins

After trying all the alternatives, this approach has real advantages:

  • Customers get updates. Push a fix to main and every customer can git pull. No re-downloading zips.
  • Full git history. Customers can see why things were built the way they were.
  • Read-only by default. pull permission means they can clone but can't push to your repo.
  • No extra tooling. Customers just need git. No license keys, no custom CLIs, no registry auth.
  • Revocable. Chargeback? Remove the collaborator in one API call.

The main downside is the 50 pending invitation limit and the fact that it's tied to GitHub specifically. But for selling developer tools to developers, that's not really a limitation.

Plot Twist: I Didn't End Up Using It

While I was building all of this, I ended up going with Polar.sh for payments because they actually accepted my business (unlike Lemon Squeezy). And when I dug into their platform, I realized they already handle everything I was building. GitHub username capture, repository invitations, access management, all of it. Built right into their platform.

So the standalone tool I spent all this time building was redundant. Polar handles the exact flow I was trying to automate: customer pays, Polar captures their GitHub username, Polar sends the repo invitation, customer gets access. I just had to get approved and flip it on.

My tool works, all tests pass, the whole pipeline runs, but I never needed to deploy it. I learned a ton building it though. Webhook signature verification, OAuth flows, retry systems with exponential backoff, how GitHub's collaborator API actually works. That's the kind of stuff you don't pick up from tutorials.

But now I'm genuinely curious. I built this because someone was charging $30 for something similar and I figured I could do it myself. Is this something anyone would actually find useful? If you're selling a boilerplate or a starter kit and you don't want to be locked into Polar's built in solution, would a standalone tool like this be worth it to you? Would you want it open sourced?

Let me know in the comments. I'm trying to figure out if this is worth shipping (open source it / buy me a coffee) or if it just stays as a learning project. Almost feels pointless to sell? Either way was fun to build.


Built with Next.js, TypeScript, and a lot of time reading webhook docs.