Skip to content

Swap Polar checkout for Whop on buy CTAs - #38

Merged
kGeee merged 3 commits into
mainfrom
cursor/swap-polar-to-whop-checkout-68ba
Aug 27, 2026
Merged

kGeee merged 3 commits into
mainfrom
cursor/swap-polar-to-whop-checkout-68ba

Conversation

@kGeee

@kGeee kGeee commented Aug 27, 2026

Copy link
Copy Markdown
Owner

Summary

Whop checkout + license delivery on one route. A verified payment.succeeded for budgetr mints the same offline Ed25519 keys the Mac app already accepts and emails them via Resend. Polar order.* webhooks are unchanged for legacy orders.

Flow

  1. Buyer pays at https://whop.com/checkout/ch_3Yc4SnEzTyrKeua/
  2. Whop redirects to the macOS DMG (configured in Whop)
  3. Whop POSTs payment.succeededhttps://budgetr.dev/api/license/webhook
  4. Route verifies Standard Webhooks signature (WHOP_WEBHOOK_SECRET, ws_… verbatim)
  5. Parses buyer email + pay_… id, filters to budgetr product/plan when ids are present
  6. mintLicenseKey({ email, orderId: pay_…, edition: "personal", days: null })sendLicenseEmail
  7. Retries re-mint the same key (id derived from payment id)

Missing WHOP_WEBHOOK_SECRET503 no-op (same as Polar).

Kevin setup (not done by this PR)

Whop Developer → Webhooks

  • URL: https://budgetr.dev/api/license/webhook
  • Event: payment.succeeded

Vercel (marketing/checkout project)

  • WHOP_WEBHOOK_SECRET = ws_… signing secret from Whop
  • LICENSE_SIGNING_KEY = existing PEM
  • RESEND_API_KEY = existing Resend key

See web/docs/MARKETING_DEPLOY.md and web/env.example.

Code

File Change
web/app/api/license/webhook/route.ts Whop + Polar multiplex
web/lib/license/whop-payment.ts Payload parsing + product/plan filter
web/lib/license/deliver.ts Shared mint + email
web/lib/license/webhook.ts Standard Webhooks verifier (ws_ + whsec_)

Tests

30 passing in web/lib/license/ — includes Whop ws_ signature verification, payload parsing, and idempotent pay_lic_pay_ minting.

Open in Web Open in Cursor 

Replace the baked-in checkout link in lib/site.ts with the Whop hosted
checkout (post-purchase redirect to the macOS DMG). Update env example,
deploy docs, and related comments. Polar webhook code is unchanged — it
handles license minting, not the public buy button.

Co-authored-by: Kevin George <kGeee@users.noreply.github.com>
@vercel

vercel Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
budgetr Ready Ready Preview Aug 27, 2026 4:18am

cursoragent and others added 2 commits August 27, 2026 04:15
Checkout moved to Whop but license minting still runs server-side: the
existing /api/license/webhook route now handles Whop payment.succeeded
events (Standard Webhooks, ws_ secret) alongside legacy Polar order.*
events. Shared deliverLicense() extracts the mint-and-email path.

Configure WHOP_WEBHOOK_SECRET on the marketing deploy and point Whop at
/api/license/webhook with payment.succeeded enabled.

Co-authored-by: Kevin George <kGeee@users.noreply.github.com>
Extract Whop payload parsing (email, pay_ id, product/plan filter) into
whop-payment.ts with unit tests. Only mint on succeeded budgetr payments;
retries derive the same key from the Whop payment id. Document exact webhook
URL, ws_ secret, and Vercel env vars for Kevin to configure.

Co-authored-by: Kevin George <kGeee@users.noreply.github.com>

@kGeee kGeee left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: COMMENT (hold merge) — head 329762f

Checkout + payment.succeeded are both on this PR now. No blocking code concerns. Still a draft, and web CI was still in progress at review time — not an approve. Do not merge.

Brief vs diff

  1. Buy CTAs → Whop. CHECKOUT_URL is https://whop.com/checkout/ch_3Yc4SnEzTyrKeua/. Docs + env.example updated. Polar checkout URL gone from the constant.
  2. payment.succeeded → same Ed25519 mint + Resend email. Shared deliverLicense. Polar order.paid / created / updated still handled with POLAR_WEBHOOK_SECRET for legacy keys.
  3. Verifier: ws_… as UTF-8 (not base64); whsec_… unchanged. Test pins the Whop path. Raw-body HMAC before mint.

No secrets in the diff (env names only).

One verify-before-undraft

extractWhopPayment assumes data.user.email + data.id. Confirm against a real Whop payment.succeeded payload (or add a fixture). Wrong nesting → 422, buyer never gets a key — same footgun Polar had.

Remaining (ops, Kevin)

  • Still a draft; wait for web green.
  • Set WHOP_WEBHOOK_SECRET on Vercel after the route is live.
  • Register webhook in Whop Developer: payment.succeededhttps://www.budgetr.dev/api/license/webhook (no trailing slash).
  • Polar archive only after this ships and one live Whop purchase mints a key.

Comment thread web/lib/site.ts
* self-hosters and for rotating the link without a code change.
*/
const CHECKOUT_URL = "https://polar.sh/checkout/polar_c_gKxB7i65pQyxZnJCMXElfVK0HIq2ua6b4Yb1q2ryyoI";
const CHECKOUT_URL = "https://whop.com/checkout/ch_3Yc4SnEzTyrKeua/";

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checkout swap is correct. Baked-in CHECKOUT_URL is now https://whop.com/checkout/ch_3Yc4SnEzTyrKeua/ (matches Kowalski’s catalog). Env still overrides via NEXT_PUBLIC_CHECKOUT_URL for self-hosters / rotation. No Polar checkout URL left in this constant.

Comment on lines +27 to 31
if (secret.startsWith("ws_")) {
return [Buffer.from(secret, "utf8")];
}
const stripped = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret;
const out: Buffer[] = [];

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ws_… → UTF-8 only (no base64 decode) is the right split from Polar whsec_…. Timing-safe compare and no timestamp-age rejection match the Polar hardening (idempotent order-derived keys). Alias verifyPolarWebhook = verifyStandardWebhook keeps existing tests green. New test pins the Whop secret path. Good.

Comment on lines 42 to +49
export async function POST(req: Request) {
const raw = await req.text();
const headers = webhookHeaders(req);

let event: { type?: string; data?: Record<string, unknown> };
try {
event = JSON.parse(raw);
} catch {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right structure: Whop payment.succeeded and Polar order.* share deliverLicense (same Ed25519 mint + Resend). Polar stays for legacy keys. Secret is chosen by event type, then Standard Webhooks verifies the raw body before minting — good.

Verify against a real Whop sandbox event before undraft. This assumes data.user.email and data.id. If Whop nests email elsewhere, this 422s and the buyer never gets a key (same class of bug as Polar’s customer.email vs customer_email). Pin the shape from one live payment.succeeded payload and add a fixture.

Ops (not code): after merge, Kevin still sets WHOP_WEBHOOK_SECRET on Vercel and registers payment.succeededhttps://www.budgetr.dev/api/license/webhook (no trailing slash — apex/slash 308s broke Polar before). Polar secret can stay for old keys.

@kGeee kGeee left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict: COMMENT (hold merge) — head 574b22c

Re-review after the payload harden. No blocking code concerns. Still a draft. Do not merge. Prior review at 329762f is superseded.

What 574b22c fixed

My email-shape nit is addressed: whop-payment.ts falls back through user / member / customer / billing_email / top-level email, filters to budgetr product/plan when ids are present, rejects failed/canceled/draft, and unit-tests the pay_lic_pay_ idempotent mint. Docs list the exact Whop IDs + Vercel env checklist.

Still pass

  • Checkout → https://whop.com/checkout/ch_3Yc4SnEzTyrKeua/
  • Same Ed25519 deliverLicense; Polar order.* kept for legacy
  • ws_… UTF-8 verifier; no secrets in the diff

Remaining (non-code)

  1. Still a draft — undraft before merge.
  2. Wait for web green on this SHA.
  3. Kevin: WHOP_WEBHOOK_SECRET on Vercel + Whop Developer webhook (payment.succeededhttps://budgetr.dev/api/license/webhook, no trailing slash; prefer the canonical host that doesn’t 308).
  4. Soft note: omit-ids ⇒ treat as budgetr. Fine while this endpoint is budgetr-only.

Comment on lines +34 to +40
export function isBudgetrWhopPurchase(data: Record<string, unknown>): boolean {
const planId = nestedId(data.plan);
const productId = nestedId(data.product);
if (planId && planId !== WHOP_BUDGETR_PLAN_ID) return false;
if (productId && productId !== WHOP_BUDGETR_PRODUCT_ID) return false;
return true;
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This closes the email-shape nit from 329762f. Fallbacks cover user / member / customer / billing_email / top-level email — same defensive pattern Polar needed. Product/plan filter matches Kowalski’s catalog (prod_KsEESYFxS0cQW / plan_DZoy04FGD4McW). Unit tests + pay_lic_pay_ idempotency are the right fixtures.

One deliberate tradeoff: when plan/product ids are absent, isBudgetrWhopPurchase returns true. Fine if this webhook URL is budgetr-only; if the same Whop company later sells something else and omits ids, you’d mint. Acceptable for now — call it out if the company gets a second product.

@kGeee
kGeee marked this pull request as ready for review August 27, 2026 04:27
@kGeee
kGeee merged commit 5279576 into main Aug 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants