Prerequisites
- A working payment provider (see /docs/payments/overview).
- The
licensetable created bypnpm db:pushfromsrc/db/license.schema.ts. LICENSE_DOWNLOAD_URLenv set (see step 6) — without it the endpoint falls back to a plaintext “contact support” message.
The full chain
- Customer pays. Stripe (or any provider) fires
checkout.completed. The webhook handler normalizes the event and callsprocessNormalizedEventinsrc/payment/handlers/core.ts. - Dispatch to license.
processNormalizedEventinserts thepaymentrow, then callsdispatchLicense(paymentId, p). That helper checksp.scene === siteConfig.product.id(i.e. the buyer paid for vibestrap itself, not a credit pack) and only then callsissueLicense. - Issue the license.
issueLicense({userId, productId, paymentId})is idempotent on(userId, paymentId)— webhook retries are safe. The key islic_<nanoid>(~22 chars), stored inlicense.keywith a unique index. - Customer sees their license. The buyer visits
/settings/licensesto see the key, download count, and a re-download button. - Buyer hits download. The button links to
/api/license/download?key=lic_xxx. The handler verifies the session, checks the key belongs to this user, callsconsumeKey()(which incrementsdownloadCountand stampslastDownloadAt), then redirects 302 toLICENSE_DOWNLOAD_URL. - Substitution. The string
{key}inLICENSE_DOWNLOAD_URLis replaced with the encoded license key, so your upstream can identify the buyer.
Download URL options
SetLICENSE_DOWNLOAD_URL in .env.local to one of:
{key} placeholder is encodeURIComponent’d so it’s safe in any
URL position.
Verify it works
- Run a test purchase in Stripe test mode.
- Tail your Postgres logs /
pnpm db:studioand confirm a new row inlicensewithkey = lic_..., the rightuserId, and matchingpaymentId. - Visit
/settings/licensesas the buyer — the license should be listed. - Click “Download”. You should land on whatever
LICENSE_DOWNLOAD_URLpoints at (or a plaintext message if it’s unset). - Refresh the settings page —
downloadCountis now 1. - Re-trigger the same Stripe webhook. Confirm no duplicate license is created (idempotency check).
Common pitfalls
LICENSE_DOWNLOAD_URLunset. The endpoint falls back to a plaintext message asking the buyer to contact support. Useful for dev, embarrassing in prod. Set it before you take real money.productIddoesn’t matchsiteConfig.product.id.dispatchLicenseshort-circuits when the scene isn’tsiteConfig.product.id(or the legacy'vibestrap-lifetime'). If you change the product slug in config, either update existing payments or add an alias indispatchLicense.- Refunds don’t auto-revoke licenses. Stripe sending
charge.refundeddoesn’t delete the license row. Handle it in your business logic — soft- delete the license, expire the key, or trust your customers (most indie hackers do). - Forgot the
{key}placeholder. If your URL doesn’t contain{key}, the upstream system has no way to authenticate the buyer. Always include it unless you’re using a single shared download link (in which case why issue keys at all). - Sharing a key works if both users are logged in. The endpoint checks
row.userId === session.user.id, so a shared key only works for the original buyer. But account sharing defeats this — accept it as a known limitation or layer in IP / device fingerprinting.
Official docs
- Payment provider abstraction: /docs/payments/overview
- Stripe webhooks: stripe.com/docs/webhooks
- GitHub release downloads: docs.github.com/en/rest/releases
- Cloudflare R2 signed URLs: developers.cloudflare.com/r2/api/s3/presigned-urls
- AWS S3 presigned URLs: docs.aws.amazon.com/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html