How to Sell Access to a Private GitHub Repo Without a SaaS in the Middle
Launch write-up for RepoAccess: an open-source Cloudflare Worker that grants GitHub team invites on payment and revokes them on refunds and chargebacks.
Polar’s free tier takes 5% plus 50 cents of every sale. Sell a $149 boilerplate a hundred times and the meter reads $795.
To be fair, that fee buys a whole platform: checkout, tax, disputes. But if you already have a payment provider, the only piece you are missing is delivery - a webhook and a GitHub team invite per buyer. That gap is what I built, and open-sourced.
What selling access to a private GitHub repo actually takes
The job sounds trivial and is not. On payment, invite the buyer’s GitHub account to the team that carries the repo. RepoAccess does this without OAuth: the buyer types their GitHub username and accepts an email invite, so there is no app to authorize at the most expensive moment of the funnel.
The flip side is that a username can be mistyped, so the buyer needs a recovery path that is not a support thread. On refund or chargeback, take the access back - on the provider’s schedule, not yours. And do all of it idempotently, because payment providers retry webhooks.
TL;DR
- RepoAccess turns payment webhooks into GitHub team invites; refunds and chargebacks revoke access automatically
- It runs as one Cloudflare Worker on the free tier: no server, no SaaS subscription, no per-sale cut
- Buyers never authorize an OAuth app; they type a GitHub username and accept an email invite
- The AGPL core ships a complete Stripe adapter; other providers plug into a documented contract
My own products ship as private repo access rather than downloads, and this delivery layer was the piece I could not buy without adopting someone’s platform. So I built it as a single Cloudflare Worker, put my own products behind it, and open-sourced the core. This article walks the architecture: what happens when someone pays, why it is one worker and not a service, and where the hard parts turned out to be.
The stack, so the code below reads without guessing: TypeScript, Hono 4 as the router, Cloudflare Workers as the runtime, Cloudflare Workflows for the durable grant and revoke steps, and Workers KV for claim tokens and grant records. No database, no queue service, no framework beyond that. The core is AGPL-3.0, on npm as repoaccess-core.
What happens when someone pays
Every provider integration ends at the same front door: the provider POSTs to /wh/<adapter> on your worker. The route verifies before it parses - for Stripe that is a timing-safe HMAC check over the byte-exact raw body, because re-serializing JSON before verification is how signature bugs are born. Only then does the adapter translate the provider’s payload into the one shape the engine speaks:
export interface NormalizedEvent {
event_type: 'payment_success' | 'refund' | 'chargeback'
product_id: string
/** Stable correlation key - identical across an order and its later refund/chargeback. */
transaction_id: string
buyer_email: string | null
github_username: string | null
/** Refund events only: true=full, false=partial, null=n/a. */
is_full_refund: boolean | null
}
Three event types are the whole vocabulary. Everything else a provider sends is noise the adapter filters out, and transaction_id is the correlation key: the refund that arrives three weeks after the order carries the same id, which is how the revoke finds its grant.
The event then goes into a Cloudflare Workflow, and the instance id does more work than it looks like:
const event = adapter.parse(raw)
if (!event) return c.text('unprocessable entity', 400)
// Deterministic Workflow id = the idempotency key. This IS the dedupe
// mechanism - no KV bookkeeping. A duplicate id is silently skipped.
const id = await workflowInstanceId(
adapter.name,
event.event_type,
event.transaction_id,
event.is_full_refund,
)
await c.env.ACCESS_WORKFLOW.createBatch([
{ id, params: { adapter: adapter.name, event, origin: 'webhook' } },
])
return c.text('ok', 200)
Providers retry webhooks - sometimes seconds later, sometimes after your worker already answered 200. The common fix is a table of seen event ids.
This engine keeps no such table: the instance id is derived from the event itself, so a retry builds the same id and the Workflows runtime skips the duplicate. Idempotency by construction, not by bookkeeping.
And is_full_refund sits in the id for a reason: a refund paid in two stages must become two instances, not one silently deduplicated one. More on that in the hard-parts section.
Inside the Workflow, the durable work runs as retryable steps: map product_id to its GitHub teams, call GitHub, invite the username. A refund or chargeback runs the opposite verb through the same pipeline, and revoke is reconciliation against live GitHub state rather than a stored flag: a membership that is already gone reads as gone, and the DELETE is a no-op. That property is what makes every retry in this engine harmless.
When a payment arrives without a usable GitHub username - nothing typed, a malformed handle, or a name that does not exist on GitHub - the grant does not fail into a support thread. The worker mints a single-use claim token tied to the transaction, and the buyer finishes on a claim page served by the same worker: type the username, confirm it, get the invite.
The project calls this the typo path, and it is a safety net, not a step of any provider’s flow. The one case no system can catch is a mistype that names somebody else’s real account - nothing downstream can tell that apart from a correct purchase - so every access.granted event carries both the transaction and the handle, and an “I paid and got nothing” ticket resolves in one look.
Why one Cloudflare Worker and not a platform
Go back to the arithmetic in the opening. A platform’s percentage is a fair price for checkout, tax handling and dispute liability - if you need those. A seller who already has Stripe, or Paddle, or any provider with webhooks, is already paying their processor for exactly that.
What is left over is delivery, and delivery is small. A sale costs the worker one webhook request, a handful of KV operations and one GitHub API call.
The tightest free-tier limit it touches is KV’s 1,000 writes a day, and a sale is a few writes. So the free tier prices hundreds of sales a day at zero, with no server idling between them.
The second reason is identity. There is no “Login with GitHub” anywhere in the flow: no OAuth app to register, no callback route, no session store, no extra secret that can expire silently. The worker holds no buyer identity at all - it turns a payment event into a team invitation, and that is the whole relationship.
The third reason is trust, and it points at open source rather than away from it. To manage team memberships, the worker needs a GitHub token with real power over your organization. I would not hand a token like that to a closed binary from a stranger, and I do not expect you to - which is why the core is AGPL-3.0 and every line between the webhook and the GitHub call is in the repo.
The hard 20%: signatures, refunds, idempotency
The happy path of this product is a weekend project, and I will not pretend otherwise. What took months of live runs to trust is everything around the happy path - the cases that decide whether a stranger gets free access, or a paying buyer loses theirs.
Verify the bytes you received, not the JSON you parsed
HMAC verification runs over the exact bytes the provider sent. Parse the body and re-serialize it, and the signature breaks on key order, whitespace, or a unicode escape - so the worker keeps the raw text and verifies before anything touches JSON.parse.
Stripe’s signature header can carry several v1 signatures at once - that is how secret rolls work - so the verifier matches any of them, inside a 300-second replay window, the default of Stripe’s own libraries. And the comparison itself must not leak timing:
/**
* Constant-time hex compare. The length check is acceptable: a digest's
* length is fixed by its algorithm, so a mismatch only ever means an
* invalid signature, not a secret-dependent branch.
*/
export function timingSafeEqualHex(a: string, b: string): boolean {
if (a.length !== b.length) return false
let diff = 0
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
return diff === 0
}
None of this is exotic. All of it is exactly what a happy-path integration skips, and skipping it here means a forged webhook can grant access to your repo.
The refund that arrives in stages
The best bug in this engine’s history was found before it ever met a real buyer. Providers can refund a payment in stages, and the engine has a full_refund_only policy: a partial refund runs and correctly skips the revoke.
The Workflow id for a refund used to be {adapter}-refund-{txn}. So when a later event completed the refund, it carried the same transaction, built the same id - and the same dedupe that protects against retries silently swallowed it. A fully refunded buyer kept their access, which is the one case that policy exists to handle.
The fix lives in the id itself. A refund’s instance id now carries its scope, so a retry still deduplicates - same answer, same id - while a completion flips partial to full, mints a new id, and the revoke runs:
const suffix = eventType === 'refund' ? `-${refundScopeOf(isFullRefund)}` : ''
const readable = `${adapter}-${eventType}-${transactionId}${suffix}`
And isFullRefund became a required parameter rather than an optional one, which is the actual safety property: an enqueue site that forgets the scope does not quietly produce an old-shaped id. It fails to compile.
The lesson generalizes past refunds. When your idempotency key is derived from the event, the key has to carry every answer the event carries - dedupe on less, and two different answers collapse into one.
An agent deploys it, and never sees your secrets
The repo ships a setup wizard, and its design inverts the pattern most “AI-ready” setup docs use. Every reliable setup tool - wrangler login, gh auth login, create-next-app - works one way: the tool drives and holds the state, and the human responds. A prose guide an agent improvises from is the opposite, and prose is an untested surface.
So the wizard is a program. The agent runs one command, npm run wizard:drive, renders the record it prints verbatim, and feeds the answer back. It never chooses the next step, never composes a shell command, and never diagnoses off-path.
A record is a question with a fixed set of options, a named free-text field read off a dashboard, or a manual step the human confirms with the single word done. And done is verified, not trusted: where the driver can check real state - the org, the team, the token, the deployed worker’s URL - it does, and a failed check routes back to the screen that owns the wrong input, with the known failure modes attached as data. The run ends with a live end-to-end test purchase, so “it works” is an observation, not a hope.
The part I care most about is what the agent cannot see. You paste secret values into .dev.vars yourself; the deploy hands that file to wrangler, which reads it directly, so the values never pass through the agent. The permission rules that deny the agent’s reads of the secrets files by name are committed in the repo, and a test plants fake secrets and proves the refusal - you can read the enforcement before you run anything.
Plan for roughly an hour end to end, dashboards included. The wizard is agent-agnostic, and it does not need a frontier model: the driver owns the sequence and the wording, so the agent only renders and relays. My own live runs used Claude Code on Haiku, its cheapest tier, and OpenCode on a free bundled model such as Big Pickle.
What the AGPL core includes, and what Pro adds
The split is by need, not by crippling. The core on npm is the complete engine: the webhook router, the Stripe adapter, the Workflow grant and revoke, the claim page, the typo path, and the wizard. If you sell through Stripe and are happy to self-host, the core is the whole product, and it stays that way.
The core also exports every primitive needed to embed the service inside your own Cloudflare funnel over RPC, with no public webhook route at all. Pro ships that as a ready-made, supported service class; the core gives you the parts to assemble it yourself.
Pro exists for two sellers: the one whose provider is not Stripe, and the one who does not want to own webhook maintenance. It adds the other five adapters - Paddle, Lemon Squeezy, Gumroad, Razorpay, and Telegram Stars - including two Merchant-of-Record options, Paddle and Lemon Squeezy, for sellers who want tax and compliance carried by the provider. It also ships a themed storefront and buyer pages, and it is maintained: providers change webhook payloads, event names and signature schemes over time, and tracking that is the job you are actually paying to not have.
The price is a one-time payment that includes twelve months of updates and support. If you never renew, the version you have keeps working forever - your clone and your deployed worker are yours, and nothing phones home to check.
The decision cue, as plainly as I can put it: Stripe plus self-host, use the core. Any other provider, a Merchant of Record, or a funnel you would rather have somebody else keep current - that is what Pro is for.
What this trades away
No OAuth means the username is typed, and typed input can be wrong. The bound matters more than the fear: every invitation costs a completed purchase, so nobody can farm access, and what remains is one buyer’s mistake - one account, one purchase, revocable. The typo path catches the detectable half; a mistype that names somebody’s real account is answerable through the event log, not detectable, and I state that plainly rather than promising detection I do not have.
Self-hosting is the other half of the deal. The worker runs on your Cloudflare account against your GitHub org and your provider dashboard, so when something is misconfigured, the wizard’s checks and the event stream tell you - but there is no vendor status page to point at. Owning the margin and owning the pager are the same decision.
And the free tier has a ceiling. KV’s 1,000 writes a day translates to hundreds of sales a day, which is far above a code product’s steady state but not above a launch-day spike. The Workers Paid plan at $5 a month raises the write allowance to a million a month, which stops being a meaningful constraint.
Where to start
If you sell through Stripe, clone repoaccess-core, run the wizard with whatever coding agent you already use, and budget about an hour. The README carries the same architecture this article walked, with the enforcement details committed next to the code.
If your provider is Paddle, Lemon Squeezy, Gumroad, Razorpay or Telegram Stars, or you want the maintenance carried for you, the paid tier lives on the RepoAccess product page.
One last fact, because it is the strongest claim I can make about trusting this thing: every copy of Pro is delivered by RepoAccess itself. You pay, a worker invites your GitHub account to the private repo, and a refund would take the invite back. The delivery layer sells itself the same way it sells everything else.
Frequently Asked Questions
How do you sell access to a private GitHub repo without a SaaS platform in the middle?
Run the delivery layer yourself. RepoAccess is an open-source (AGPL-3.0) Cloudflare Worker deployed on your own account: your payment provider POSTs a webhook to the worker, the worker verifies the signature over the raw body, maps the product to a GitHub team and invites the buyer's username. A refund or chargeback revokes the access through the same pipeline. Checkout stays with the provider you already use; the Stripe adapter ships in the core, and there is no per-sale cut on top of your processor's own fees.
How does RepoAccess revoke GitHub access after a refund or chargeback?
Every event carries a stable transaction id, so the refund that arrives weeks after the order correlates to its grant. The revoke runs as a durable Cloudflare Workflow step and works by reconciliation against live GitHub state rather than a stored flag: the buyer is removed from the team, and a membership that is already gone reads as a no-op. That reconciliation property is what makes provider retries harmless.
Why does RepoAccess not use Login with GitHub (OAuth) for buyers?
Because it removes an entire class of moving parts and a funnel cost. With no OAuth there is no app to register, no callback route, no session store, and no extra secret that can expire silently - the worker stores no buyer identity at all. Buyers type a GitHub username and accept an email invite, so nothing interrupts them right after payment, which is the most expensive moment to add friction.
What happens when a buyer mistypes their GitHub username at checkout?
If the handle is unusable - empty, malformed, or naming an account that does not exist on GitHub - the worker mints a single-use claim token tied to the transaction and the buyer finishes on a claim page: type the username, confirm it, get the invite. A mistype that names somebody else's real account is not detectable by any system, so instead of promising detection, every access.granted event carries both the transaction and the handle, and a support ticket resolves in one look.
How does RepoAccess deduplicate webhook retries without a database?
The Cloudflare Workflow instance id is derived from the event itself - adapter, event type, transaction id, and for refunds the refund scope - so a provider retry builds the same id and the Workflows runtime silently skips the duplicate. There is no table of seen event ids to maintain. The refund scope suffix matters: a refund paid in stages flips partial to full, mints a new id, and the revoke still runs instead of being swallowed by the dedupe.
How many sales a day can RepoAccess handle on the Cloudflare free tier?
Hundreds. The tightest free-tier limit the worker touches is Workers KV at 1,000 writes a day, and a sale costs a few writes; requests are capped far higher at 100,000 a day. Past that, the Workers Paid plan at $5 a month raises the KV allowance to a million writes a month, at which point throughput stops being a meaningful constraint for a code product.
What is the difference between the free RepoAccess core and RepoAccess Pro?
The AGPL core on npm is the complete engine for a Stripe seller: webhook router, Stripe adapter, Workflow grant and revoke, claim page, setup wizard, and the primitives to embed the service in your own worker over RPC. Pro adds the other five adapters - Paddle, Lemon Squeezy, Gumroad, Razorpay and Telegram Stars, including two Merchant-of-Record options - plus a themed storefront and ongoing maintenance and support, as a one-time payment with twelve months of updates.
Can a coding agent deploy RepoAccess by itself?
Yes, and by design rather than by luck. The setup wizard is a state machine the agent drives with one command: it renders each record verbatim, relays answers back, and every done is verified against real state - org, team, token, deployed URL - with recovery routed to the screen that owns a wrong input. Secrets never pass through the agent; wrangler reads the secrets file itself, and the permission rules denying the agent that read are committed in the repo. Budget roughly an hour, on a cheap model - live runs used Claude Code on Haiku and OpenCode on a free bundled model.