Setup: from fork to first sale
Time: ~30 minutes, plus 10 if you add instant delivery in §6. Cost: $0/month. The arithmetic is in §7, not asserted. You need a GitHub account and an activated Stripe account (charges enabled).
1. Your storefront repo
- Fork (or "Use this template") this repo. Public is recommended: Pages is free on public repos and the open store is your credibility.
- Edit
store.config.json:name,kicker,headline,tagline,subline: your store's voice.url:https://<user>.github.io/<repo>(or your custom domain later).seller: who the merchant is. Use your real name or entity; it builds trust and it's the law in most places.repo: your storefront repo, asowner/name. This one is not cosmetic: it is how the build knows the store is yours rather than a copy of HonorBox's, and leaving it unset is what used to let a store ship with HonorBox's Buy buttons still live.sections: keep, edit, or delete the marketing sections. They're plain JSON; thecompareandfaqtypes cover most needs.
- Delete the shipped product files (
products/honorbox-pro.mdandproducts/crew.md) and write your own. One.mdper product, same frontmatter shape. Both ship with HonorBox's realpayment_link, so a store that keeps them sends its buyers to HonorBox's checkout and the money lands in HonorBox's Stripe account. The build refuses to produce that store as soon as any ofname,url, orrepois yours, and names every file and field to fix. node scripts/build.jslocally and opendist/index.htmlto preview.
2. Stripe
Fast path: STRIPE_SECRET_KEY=rk_... node scripts/init.js --name "My Tool" --price 2900 --repo you/product-access (a temporary restricted key; scopes in least-privilege.md) creates the Product, Price, and a correctly-configured Payment Link, and wires store.config.json + products/<id>.md for you. Skip to §3. The manual path:
- Dashboard → Products → Add product: name, price (one-time), currency.
Selling a subscription instead? Use a recurring price and then turn on subscription enforcement. Delivery works either way, but without enforcement a cancellation does nothing and access stays forever.
- Create a Payment Link for that price:
- Add a custom field: label "GitHub username (for delivery)", key
github_username, type text, required. - After payment → show a confirmation message like: "You're in. Your GitHub account will be invited to the private repo, usually within minutes and always within a few hours. Watch for the email from GitHub and accept the invite: it expires after 7 days. Trouble? Reply to your receipt."
- Add a custom field: label "GitHub username (for delivery)", key
Say the "accept it" part. Delivery is not finished when the invite is sent, it is finished when the buyer accepts, and a buyer who never realised there was a second step looks identical to a happy one in every record you keep. See how-it-works.md. - Leave "allow promotion codes" off unless you are actively running a coupon. init.js generates links with it off on purpose: a link that accepts typed codes plus any live 100%-off coupon is a free copy of your product to anyone who guesses the code. Turn it on for a campaign, turn it back off when the campaign ends. Section 8 shows how to test without it.
- The link gives you two different values, and they go in two different places:
- the URL goes in your product's
payment_linkfrontmatter; that is what the Buy button opens. - the id (starts with
plink_, visible in the link's URL in the dashboard or via the API) goes instore.config.json→fulfillment[].payment_link, with the target private repo inrepo(e.g.you/yourproduct-access).
- the URL goes in your product's
Stripe reports the id, not the URL, on the checkout session, so a URL in fulfillment[].payment_link matches nothing: the sale is skipped, the run still exits green, and the buyer is never invited. fulfill.js prints a CONFIG warning for that shape on every poll, but the grant is easier to get right the first time.
3. The product repo
Create a private repo containing what buyers get (code, files, releases). Buyers are invited with read (pull) permission. Updates = you push, they pull.
4. Pages deploy
Copy setup/workflows/deploy.yml to .github/workflows/deploy.yml in your fork (it lives in setup/ so the template pushes cleanly with minimal token scopes). Then: repo → Settings → Pages → Source: GitHub Actions. Push to main; the workflow builds and publishes.
static/ ships HonorBox's IndexNow key file. Replace it with your own key file or delete it: the deploy workflow reads the host and key from store.config.json and static/, and skips the ping when there is no key.
Prefer no CI? Build and publish dist/ to a gh-pages branch yourself. dist/ is in .gitignore, so it needs a force-add:
node scripts/build.js
git add -f dist && git commit -m "build"
git subtree push --prefix dist origin gh-pages
Pages serves either way.
5. Fulfillment (the ops repo)
Keep secrets and state out of your public repo:
- Create a private repo, e.g.
you/yourstore-ops. - Copy into it:
scripts/fulfill.js,scripts/renew-invites.js,scripts/lib/, yourstore.config.json, andsetup/workflows/fulfill.yml.example→.github/workflows/fulfill.yml. - Add Actions secrets:
STRIPE_SECRET_KEY: create a restricted key in Stripe (Developers → API keys → Create restricted key) with only Checkout Sessions: Read. Don't use your full secret key if you don't have to.
Nothing in that variable's name says whether the key behind it is live or test, so every run prints which one it reached before it calls Stripe:
stripe mode=live
Worth knowing because the reverse is the easy mistake. If your shell profile exports a live STRIPE_SECRET_KEY (a .env, a secrets file you source from .zshrc), then running node scripts/fulfill.js by hand delivers to real buyers, and without that line it looks exactly like a rehearsal. If you want the intent stated rather than inferred, pass --require-mode live (or test) and the run refuses to start against the wrong kind of key. - GH_FULFILL_TOKEN: a fine-grained PAT scoped to your private product repo(s) with Administration: Read & write (for collaborator invites). The ops-repo state commit uses the workflow's own GITHUB_TOKEN, not this PAT; add Contents: Read & write on the storefront repo only if you enable the public-ledger option below. Full scope map: least-privilege.md.
- (Optional, off by default) Actions variable
PUBLIC_STORE_REPO=you/yourstoreto publish the anonymized ledger to a public trust page on your storefront. Skip it to keep sales data private. - Run the workflow once manually (Actions → Fulfill orders → Run workflow) and check the log.
6. Instant delivery, or skip it on purpose
With what you have built so far, a buyer's invite arrives a median of ~15 minutes after payment, and occasionally hours when GitHub's scheduler drifts (the arithmetic is in §7). You have two honest options and neither is a compromise.
Skip this section if a wait of minutes suits what you sell. Nothing is missing and nothing is degraded: the poll is the whole product working as designed, it needs no account beyond Stripe and GitHub, and it cannot be misconfigured into silence. Set the expectation at checkout ("usually within minutes, always within a few hours") and beat it. This is the shipped default and it stays on whatever else you do.
Or add webhook mode and delivery lands in seconds. A signed Stripe webhook hits a small relay you deploy on a free serverless tier (Cloudflare Workers or Val Town, neither takes a card), which fires a GitHub repository_dispatch, and fulfillment runs immediately. The poll keeps running underneath as the safety net, so a relay that breaks degrades to exactly the behavior above rather than to no delivery at all. The engine is identical either way, so turning this on later changes nothing about how sales are processed.
It costs about 10 minutes, one free account, one webhook signing secret and one more fine-grained token. Full steps, the payload, and the threat model: instant-delivery.md.
Worth knowing before you choose: neither option changes GitHub's cap of 50 repository invitations per repo per 24 hours. Webhook mode makes the 50th delivery instant; it does not make the 51st possible, and nothing does. Moving the repo into an organization does not lift it either: org invitations are capped too, and it would let every buyer list every other buyer. Past the 50th, buyers queue and the queue drains on its own: how-it-works.md.
7. What this costs
$0/month, and here is the arithmetic rather than the assurance.
GitHub Pages + the storefront build. Your storefront repo is public, and Actions minutes in public repositories are not billed at all. GitHub's own wording is "There are no billable minutes when using GitHub Actions in public repositories." So the site build and deploy are free at any frequency.
The fulfillment poll. This is the only part with a meter on it. Your ops repo is private, and private-repo Actions bill against 2,000 free minutes/month on the GitHub Free plan. Two rules decide the bill:
- Each job is rounded up to a whole minute: "GitHub rounds the minutes and partial minutes each job uses up to the nearest whole minute."
- A fulfillment run takes ~15 seconds, so it is billed as 1 minute, whether it finds a sale or not.
So the monthly cost of polling is simply its run count, and the shipped */30 cron is sized to fit a 31-day month:
| Poll cron | Runs/month (31d) | Billable minutes | Inside 2,000? |
|---|---|---|---|
*/30 (shipped default) | 1,488 | 1,488 | yes, 512 spare |
*/20 | 2,232 | 2,232 | no, 232 over |
*/15 | 2,976 | 2,976 | no, 976 over (~$5.86/mo at $0.006/min) |
*/5 | 8,928 | 8,928 | no, 4.5x the tier |
The 512 spare minutes are real headroom, not rounding: they cover sale-triggered runs (fulfill-on-sale.yml costs 1 minute per sale, so 512 sales/month before the free tier binds) and any manual re-runs.
Invitation renewal costs nothing, and the rounding rule is why. GitHub expires an unaccepted invitation after seven days, so renew-invites.js re-issues one before that happens (§ how-it-works.md). It runs as a step inside the fulfillment job, not on a schedule of its own, and the whole-minute rounding above is exactly what makes that free:
| Where renewal runs | Added runs/month | Added billable minutes |
|---|---|---|
A step in fulfill.yml (shipped default) | 0 | 0 |
| Its own hourly cron | 744 | 744 (over the 512 spare) |
Its own */30 cron | 1,488 | 1,488 (~2x the whole tier with the poll) |
A fulfillment run takes ~15 seconds and is billed as a full minute either way, so the renewal step spends the seconds already paid for. Per poll it adds one GET /repos/{owner}/{repo}/invitations per product repo (one more per 100 invitations you have pending, which is a lot), plus a PUT only on the rare poll that actually renews somebody. Measured against a real repo it takes well under a second, nowhere near the 60 that would push the job into a second billable minute. A separate cron would have bought nothing and cost more than the headroom.
This also means renewal inherits the poll's cadence, which is the margin it needs. Renewal fires at 6 days against a 7-day expiry, so it has 24 hours and ~48 scheduled attempts to land even one run.
If you turn on the optional heartbeat (§ instant-delivery.md), loosen this cron to hourly: heartbeat nudges cost an ops-repo minute each, and hourly + hourly is the same 1,488 minutes as a lone */30.
What you actually wait for. With the */30 default and no webhook relay, a buyer's invite lands a median of ~15 minutes after payment (uniform arrival inside a 30-minute window) and ~30 minutes worst case if GitHub runs the cron on time. It often does not: GitHub's scheduler is best-effort and drops scheduled runs on quiet repos, so real worst case is a few hours. That is why the confirmation message promises "usually within minutes, always within a few hours" and why webhook mode (seconds, and free) exists for sellers who want the wait gone.
The one limit that is not about money. GitHub allows 50 invitations per repository per 24 hours, so one product repo can take on at most 50 new buyers a day. Buyers past that are queued and delivered automatically as the window frees, and the run tells you how many are waiting, so nothing is lost. If you are planning a launch that could clear 50 sales in a day, plan around that queue rather than against it.
There is no way to remove this ceiling. Moving the product repo into a GitHub organization does not lift it: organization invitations are capped too (50 a day, rising to 500 once the organization is over a month old or on a paid plan), and organization membership would let every buyer list every other buyer. GitHub's note that inviting organization members to an organization repository is uncapped applies to people who are already members, and a buyer who has just paid is not one. Details in how-it-works.md.
Stripe takes its per-transaction percentage and no monthly fee. That is a cost per sale, not per month, and it is the only money that leaves.
The one edit that can put you over: tightening the poll cron. Everything else here scales with sales, not with time.
8. Test the whole pipe before launch
Your generated payment link does not accept typed promotion codes (see step 2), so a test order goes through a coupon you apply yourself. Two ways, both $0 and neither needing a card.
A. Temporarily allow codes on the link (simplest, all in the Dashboard)
- In Stripe, create a coupon for 100% off,
max_redemptions1-2, with an expiry. Bound it; an unbounded 100% coupon is the whole risk here. - On the payment link, turn "allow promotion codes" on, and create a promotion code only you know. Do not use a guessable name like
FREETEST. - Buy your own product with it, entering a real GitHub username.
- Confirm the invite arrives and the ledger row appears. No refund needed; the order was $0.
- Turn "allow promotion codes" back off and deactivate the code. This step is not optional. A working 100%-off code left live on a public product page is a free copy of your product for anyone who finds it.
B. Pre-applied coupon on a Checkout Session (no flag to remember to unset)
Create the Session from the API with discounts[0][coupon]=<coupon_id> (the coupon id, not the promotion code) and open the URL it returns. The link's promo setting is irrelevant, so there is nothing to switch back off afterwards. This is the path this project uses for its own testing.
Either way you are exercising the real pipe: a $0 order completes with amount_total: 0, which fulfillment treats as paid and logs distinctly from a paying customer, so your test never masquerades as revenue.
9. Going live checklist
- [ ] Payment link opens and shows your product + custom field
- [ ]
store.config.jsonfulfillment uses theplink_id, not the URL - [ ] Fulfillment run is green and idempotent (run it twice; second run does nothing)
- [ ] Terms/refunds/privacy pages say something true
- [ ] Stripe receipts enabled (Settings → Emails → successful payments)
- [ ] Read docs/tax.md once, all the way through