Skip to main content
License delivery is the chain that converts a paid Stripe checkout (or Paddle / Lemon / Creem equivalent) into something the buyer can actually download. It’s idempotent end-to-end — webhook retries don’t double-issue — and the download endpoint is auth-gated so customers can’t share a key with a friend who isn’t logged in. This page walks the entire chain from webhook to tarball.

Prerequisites

  • A working payment provider (see /docs/payments/overview).
  • The license table created by pnpm db:push from src/db/license.schema.ts.
  • LICENSE_DOWNLOAD_URL env set (see step 6) — without it the endpoint falls back to a plaintext “contact support” message.

The full chain

  1. Customer pays. Stripe (or any provider) fires checkout.completed. The webhook handler normalizes the event and calls processNormalizedEvent in src/payment/handlers/core.ts.
  2. Dispatch to license. processNormalizedEvent inserts the payment row, then calls dispatchLicense(paymentId, p). That helper checks p.scene === siteConfig.product.id (i.e. the buyer paid for vibestrap itself, not a credit pack) and only then calls issueLicense.
  3. Issue the license. issueLicense({userId, productId, paymentId}) is idempotent on (userId, paymentId) — webhook retries are safe. The key is lic_<nanoid> (~22 chars), stored in license.key with a unique index.
  4. Customer sees their license. The buyer visits /settings/licenses to see the key, download count, and a re-download button.
  5. 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, calls consumeKey() (which increments downloadCount and stamps lastDownloadAt), then redirects 302 to LICENSE_DOWNLOAD_URL.
  6. Substitution. The string {key} in LICENSE_DOWNLOAD_URL is replaced with the encoded license key, so your upstream can identify the buyer.

Download URL options

Set LICENSE_DOWNLOAD_URL in .env.local to one of:
# GitHub private release tarball — buyer redirects to a signed asset URL
LICENSE_DOWNLOAD_URL=https://api.github.com/repos/you/private/tarball/v1.0.0?token={key}

# S3 / R2 signed URL — generate at deploy, rotate per release
LICENSE_DOWNLOAD_URL=https://r2.example.com/vibestrap-v1.0.0.zip?sig={key}

# Gumroad-style invite link — pass the key as the invite parameter
LICENSE_DOWNLOAD_URL=https://gumroad.com/d/your-product?invite={key}
The {key} placeholder is encodeURIComponent’d so it’s safe in any URL position.

Verify it works

  1. Run a test purchase in Stripe test mode.
  2. Tail your Postgres logs / pnpm db:studio and confirm a new row in license with key = lic_..., the right userId, and matching paymentId.
  3. Visit /settings/licenses as the buyer — the license should be listed.
  4. Click “Download”. You should land on whatever LICENSE_DOWNLOAD_URL points at (or a plaintext message if it’s unset).
  5. Refresh the settings page — downloadCount is now 1.
  6. Re-trigger the same Stripe webhook. Confirm no duplicate license is created (idempotency check).

Common pitfalls

  1. LICENSE_DOWNLOAD_URL unset. 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.
  2. productId doesn’t match siteConfig.product.id. dispatchLicense short-circuits when the scene isn’t siteConfig.product.id (or the legacy 'vibestrap-lifetime'). If you change the product slug in config, either update existing payments or add an alias in dispatchLicense.
  3. Refunds don’t auto-revoke licenses. Stripe sending charge.refunded doesn’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).
  4. 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).
  5. 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