Skip to content

Move new purchases from Stripe to Polar - #278

Merged
jonyTF merged 3 commits into
mainfrom
polar-payments
Oct 1, 2026
Merged

jonyTF merged 3 commits into
mainfrom
polar-payments

Conversation

@jonyTF

@jonyTF jonyTF commented Oct 1, 2026

Copy link
Copy Markdown
Member

Summary

New purchases now go through Polar instead of Stripe. Existing Stripe subscribers keep renewing on Stripe, so its webhook and billing portal stay; only Stripe checkout and price fetching are removed. Moving those subscribers over is a follow-up, done with Polar's migration tool.

Backend

  • Adds a small Polar REST client (server/services/polar), pinned to API version 2026-10. Polar has no Go SDK.
  • Verifies Polar webhook signatures (Standard Webhooks, both the current and the older Polar secret format), with unit tests.
  • Adds /api/polar/* routes: price, create-checkout-session, fulfill-checkout, webhook, billing-portal.
  • create-checkout-session now requires sign-in and uses the signed-in user, not a userId from the request body.
  • Granting premium is safe to run twice: the post-checkout redirect and the order.paid webhook can both fire, but only one Slack "paid" message is sent.
Webhook Effect
order.paid grant premium (new purchases and renewals)
subscription.past_due remove premium, Slack "failed to pay"
subscription.active restore premium
subscription.revoked remove premium, Slack "cancelled"
  • Adds polarCustomerId to users and a shared User.HasPremium() helper. A user with either a Stripe or a Polar customer ID counts as premium.

Frontend

  • The paywall, sign-in, sign-up, and post-checkout redirect pages use the Polar routes.
  • Settings → Manage billing opens the Polar portal for Polar customers and the Stripe portal for legacy ones.
  • Lifetime plans are commented out of the paywall.
  • In development, the paywall now loads real prices from the server. It only falls back to the mock prices if that request fails, because Polar rejects the mock IDs at checkout.

Other

  • Regenerated the Swagger docs. They were well out of date, so server/docs/* has a lot of unrelated churn. swag init also needs --parseDependency now.
  • Documented the Polar setup and environment variables in DEPLOYMENT.md and server/.env.template.

Deploy checklist

Production Polar products and the webhook endpoint (https://timeful.app/api/polar/webhook, format Raw, API version 2026-10) already exist. Before or with the deploy, set:

  • POLAR_ACCESS_TOKEN: production token with checkouts:write, products:read, customer_sessions:write
  • POLAR_WEBHOOK_SECRET: from the production webhook endpoint
  • POLAR_SERVER=production
  • POLAR_MONTHLY_PRODUCT_ID, POLAR_YEARLY_PRODUCT_ID, POLAR_MONTHLY_STUDENT_PRODUCT_ID, POLAR_YEARLY_STUDENT_PRODUCT_ID

Test plan

Tested in the Polar sandbox:

  • Subscribe: premium granted, one Slack message
  • Cancel
  • Renewal (billing period moved forward): order.paid with subscription_cycle, user stays premium
  • Failed renewal (card 4000 0000 0000 0341): past_due, premium removed, then card switched back: order.paid + subscription.active, premium restored
  • go test ./services/polar/, frontend npm run build and npm run test:unit
  • Sign-up-then-checkout flow (Google sign-in and email code)
  • Student and yearly plans
  • Back out of checkout
  • Checkout works with the products set to private

🤖 Generated with Claude Code

jonyTF and others added 3 commits October 1, 2026 16:11
New checkouts, prices, and the billing portal now go through Polar.
Stripe's webhook and billing portal stay for legacy subscribers whose
subscriptions still renew on Stripe.

- Add a minimal Polar REST client with Standard Webhooks verification
  (standard and legacy Polar secret derivations)
- Add /polar routes: price, create-checkout-session (now auth-required),
  fulfill-checkout, webhook, billing-portal
- Fulfillment is idempotent so redirect + webhook don't double-notify
- Revoke premium on subscription.past_due/revoked, restore on
  subscription.active and order.paid
- Add polarCustomerId to users and a shared HasPremium helper
- Regenerate Swagger docs

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment out the lifetime card and stop fetching lifetime products, so
/polar/price only requires the monthly and yearly product IDs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The paywall used hardcoded mock price IDs in development, which Polar
rejects at checkout. Fetch prices from the server in every environment
and only fall back to the mocks in development if that request fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@jonyTF
jonyTF merged commit cc796d2 into main Oct 1, 2026
1 check passed
@jonyTF
jonyTF deleted the polar-payments branch October 1, 2026 22:47
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.

1 participant