The GitHub Invite IS the Product: A Stripe Webhook Where Some Failures Throw and Some Never Do
How we deliver a paid product as a GitHub repo invite, and why our Stripe webhook throws on rate limits but never on a typo in the username.
BoiledPlate is sold as access to a private GitHub repository. There is no download link, no license key, no zip file in an email. When you buy, our Stripe webhook invites your GitHub account to the releases repo as a read-only collaborator, and that invite is the entire product. If the invite never arrives, you paid for nothing.
That one sentence forced us into a webhook design that treats delivery failures differently from every other failure in the handler. Most webhook advice says "ack fast, do the work async, never let a downstream call fail the response." We deliberately broke that rule for one call, and only one call.
Two failures that look the same and must not be
When deliverPurchaseAccess() runs inside our checkout.session.completed handler, the GitHub API can fail in two ways that both surface as an error from the same PUT /repos/{repo}/collaborators/{username} request:
- GitHub is rate limiting us, the network blipped, or GitHub returned a 5xx.
- The username the buyer typed into Stripe Checkout does not exist, so GitHub returns 404.
The naive handler treats both the same, either swallowing both (buyer paid, got nothing, nobody noticed) or throwing on both (Stripe retries a 404 forever, the buyer still gets nothing, and now your webhook endpoint has a permanently failing event).
The correct behavior is opposite for each case:
- Transient failure: throw. A retry genuinely can succeed. Stripe retries failed webhook deliveries with backoff for up to three days, which is a free, battle-tested retry queue we would otherwise have to build.
- Unknown username: never throw. No number of retries turns a typo into a real GitHub account. Retrying is worse than useless, because while Stripe hammers the event, the purchase row sits in limbo.
Our invite function in server/utils/github.ts encodes exactly this split:
export async function inviteToReleasesRepo(
username: string,
): Promise<'invited' | 'invalid'> {
try {
await $fetch.raw(
`https://api.github.com/repos/${githubReleasesRepo}/collaborators/${username}`,
{ method: 'PUT', body: { permission: 'pull' }, headers: githubHeaders() },
)
return 'invited'
}
catch (err) {
if (isNotFound(err)) return 'invalid'
throw err
}
}
A 404 becomes the value 'invalid'. Everything else propagates. The wrapper around it, deliverPurchaseAccess(), stamps the outcome onto the purchase row as invite_status before deciding whether to rethrow:
catch (err) {
// Transient: surface as `failed` for the dashboard, then rethrow so
// the webhook releases its event claim for a Stripe retry.
await setDelivery('failed')
throw err
}
That ordering matters. The status is written first, so even mid-retry the buyer's dashboard shows something honest ("delivery failed, retrying") instead of nothing.
The throw has to release the idempotency claim
Throwing only helps if the retry can actually re-run the work. Like every handler we ship, the webhook claims the Stripe event id in a stripe_events table before processing, so duplicate deliveries are acked without reprocessing (we wrote about that pattern in webhooks as the source of truth). A claimed-but-failed event would make the retry a no-op, so the catch block deletes the claim before rethrowing:
catch (err) {
// Release the claim so Stripe's retry re-processes this event.
await supabase.from('stripe_events').delete().eq('id', stripeEvent.id)
throw err
}
Claim, process, release-on-failure. Without the release, the throw is theater.
Why the order inside the handler is fixed
The paid-checkout sequence runs in a fixed order: record the purchase, record the affiliate commission, deliver the invite, send the confirmation email. The email is last on purpose, and it is the one step that is allowed to fail silently.
If delivery throws and Stripe retries the event, everything before the throw runs again. recordPurchase and recordCommission are idempotent upserts, so re-running them is harmless. But an email send is not naturally idempotent, and putting it before delivery would mean a buyer whose invite hit a GitHub rate limit gets one confirmation email per retry attempt. Email after delivery means a retried event can never double-send, because the email only runs on the attempt where delivery finally succeeded.
This is the same handler where, for the email itself, we follow the conventional advice: wrap it in try/catch, log, never fail the webhook over it. A receipt email is best-effort. The invite is not. The difference is not the technology, it is what the buyer actually paid for.
What happens to the typo
So a buyer fat-fingers their username in the Stripe Checkout custom field (we collect it right on the hosted checkout page via custom_fields, max length 39, GitHub's limit). The webhook records the purchase, marks invite_status = 'invalid', sends the confirmation email, and acks the event. Stripe is done with it forever.
The fix moves to where it belongs: the buyer. Their dashboard shows a delivery card that reads the invalid status and offers a corrected-username form, backed by an auth-gated server route with a cooldown that calls the same deliverPurchaseAccess() function. Same code path, same idempotent status stamping, no special cases.
There is a third non-retryable outcome worth naming: the GitHub token or repo name is not configured at all. Early on it was tempting to lump that in with transient failures, but Stripe retries cannot fix your environment variables. So missing config logs an error and leaves the row 'pending', with the username already stored, ready to be re-delivered once the config lands.
Refunds get the same treatment in reverse
Once the invite is the product, a refund has to take the product back. Our charge.refunded handler stamps the purchase as refunded and then removes the buyer from the releases repo, including cancelling a still-pending invitation, which is the common case when a chargeback follows the purchase quickly. We initially shipped refund handling that only revoked the site entitlement and forgot the repo access entirely. The buyer would have kept the actual product after the refund. That fix landed as its own commit three days after the delivery feature, which tells you how easy this is to miss when the entitlement check and the delivered artifact live in different systems.
The takeaway
If your webhook delivers the thing the customer paid for, classify every failure of that delivery into exactly one of two buckets before you write the handler: "a retry can fix this" or "a retry can never fix this." Throw on the first bucket and let Stripe's retry queue do its job, after releasing your idempotency claim. Persist a status and ack on the second, then give the customer a path to fix their own input. The bug we were guarding against is not a crash. It is a charge that succeeded and a product that silently never shipped.
Read more
A free Nuxt + Supabase SaaS boilerplate (auth, Stripe, email, MIT)
BoiledPlate Lite is a free, MIT-licensed SaaS starter kit: Supabase auth, Stripe billing, transactional email, a blog, and i18n on Nuxt 4. Clone it and ship.
How to ship SaaS fast: the stack is easy, the plumbing is the project
The framework picks itself. Webhook idempotency, RLS, refunds, EU consent: the plumbing is what eats your launch. The anti-plumbing SaaS stack.
What it actually takes to wire up a Nuxt, Supabase and Stripe SaaS
Picking a Nuxt + Supabase + Stripe + Resend stack is the easy decision. Here is the wiring each piece really demands, and where a SaaS starter saves you weeks.

BoiledPlate