Swap Polar checkout for Whop on buy CTAs - #38
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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
left a comment
There was a problem hiding this comment.
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
- Buy CTAs → Whop.
CHECKOUT_URLishttps://whop.com/checkout/ch_3Yc4SnEzTyrKeua/. Docs +env.exampleupdated. Polar checkout URL gone from the constant. payment.succeeded→ same Ed25519 mint + Resend email. ShareddeliverLicense. Polarorder.paid/created/updatedstill handled withPOLAR_WEBHOOK_SECRETfor legacy keys.- 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
webgreen. - Set
WHOP_WEBHOOK_SECRETon Vercel after the route is live. - Register webhook in Whop Developer:
payment.succeeded→https://www.budgetr.dev/api/license/webhook(no trailing slash). - Polar archive only after this ships and one live Whop purchase mints a key.
| * 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/"; |
There was a problem hiding this comment.
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.
| if (secret.startsWith("ws_")) { | ||
| return [Buffer.from(secret, "utf8")]; | ||
| } | ||
| const stripped = secret.startsWith("whsec_") ? secret.slice("whsec_".length) : secret; | ||
| const out: Buffer[] = []; |
There was a problem hiding this comment.
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.
| 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 { |
There was a problem hiding this comment.
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.succeeded → https://www.budgetr.dev/api/license/webhook (no trailing slash — apex/slash 308s broke Polar before). Polar secret can stay for old keys.
kGeee
left a comment
There was a problem hiding this comment.
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; Polarorder.*kept for legacy ws_…UTF-8 verifier; no secrets in the diff
Remaining (non-code)
- Still a draft — undraft before merge.
- Wait for
webgreen on this SHA. - Kevin:
WHOP_WEBHOOK_SECRETon Vercel + Whop Developer webhook (payment.succeeded→https://budgetr.dev/api/license/webhook, no trailing slash; prefer the canonical host that doesn’t 308). - Soft note: omit-ids ⇒ treat as budgetr. Fine while this endpoint is budgetr-only.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
Summary
Whop checkout + license delivery on one route. A verified
payment.succeededfor budgetr mints the same offline Ed25519 keys the Mac app already accepts and emails them via Resend. Polarorder.*webhooks are unchanged for legacy orders.Flow
https://whop.com/checkout/ch_3Yc4SnEzTyrKeua/payment.succeeded→https://budgetr.dev/api/license/webhookWHOP_WEBHOOK_SECRET,ws_…verbatim)pay_…id, filters to budgetr product/plan when ids are presentmintLicenseKey({ email, orderId: pay_…, edition: "personal", days: null })→sendLicenseEmailMissing
WHOP_WEBHOOK_SECRET→ 503 no-op (same as Polar).Kevin setup (not done by this PR)
Whop Developer → Webhooks
https://budgetr.dev/api/license/webhookpayment.succeededVercel (marketing/checkout project)
WHOP_WEBHOOK_SECRET=ws_…signing secret from WhopLICENSE_SIGNING_KEY= existing PEMRESEND_API_KEY= existing Resend keySee
web/docs/MARKETING_DEPLOY.mdandweb/env.example.Code
web/app/api/license/webhook/route.tsweb/lib/license/whop-payment.tsweb/lib/license/deliver.tsweb/lib/license/webhook.tsws_+whsec_)Tests
30 passing in
web/lib/license/— includes Whopws_signature verification, payload parsing, and idempotentpay_→lic_pay_minting.