From 871298b6c33eca4f5b5172afc91ca4087752ab77 Mon Sep 17 00:00:00 2001 From: sebastianekstrom Date: Tue, 20 Oct 2026 15:37:27 +0200 Subject: [PATCH] Update skills --- README.md | 4 +- skills/polar-integration/SKILL.md | 414 +++++++++------------ skills/polar-migration/SKILL.md | 594 ++++-------------------------- skills/polar-testing/SKILL.md | 218 +++++------ skills/setup-polar/SKILL.md | 72 ++-- 5 files changed, 376 insertions(+), 926 deletions(-) diff --git a/README.md b/README.md index 03e97d9..1f61259 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Both servers use OAuth. Nothing connects until you sign in to Polar and authoriz | `setup-polar` | Guided setup from scratch: connects the MCP server, creates an organization and a first product, then hands off to `polar-integration`. | | `polar-integration` | Adds Checkout, Customer Portal and Webhooks endpoints with `@polar-sh/sdk` to any TS/JS framework, including Next.js, Express, Hono, Astro, SvelteKit, Remix, Nuxt and Cloudflare Workers. | | `polar-testing` | Tests an integration against the sandbox: test cards, local webhooks with the Polar CLI (`polar webhook listen`, `polar webhook trigger`), mocks and CI. | -| `polar-migration` | Plans and runs a migration from Stripe Billing, Paddle, Lemon Squeezy or Gumroad. | +| `polar-migration` | Guides a migration to Polar: Polar's assisted migration from Stripe Billing, or recreating the catalog and moving customers from Paddle, Lemon Squeezy and others. | ## Installation @@ -92,7 +92,7 @@ The skills also ask your agent to run commands and write code that talk to these - **Polar API** (`api.polar.sh`, `sandbox-api.polar.sh`): the integration code, tests and Polar CLI commands the skills set up call it with your own access token or CLI login. - **Polar CLI telemetry** (PostHog): `setup-polar` and `polar-testing` install the [Polar CLI](https://polar.sh/docs/integrate/cli/introduction), which sends one anonymous event per command, such as the command name, duration, CLI version and OS. It never sends access tokens, organization IDs, URLs or payloads. Opt out with `POLAR_CLI_TELEMETRY_OPTOUT=1`. - **npm registry**: to install `@polar-sh/cli` and `@polar-sh/sdk`. -- **Your current payment provider** (Stripe, Paddle or Lemon Squeezy): `polar-migration` writes scripts that export your products, customers and subscriptions with your own API keys so they can be imported into Polar. +- **Your current payment provider** (Stripe, Paddle or Lemon Squeezy): `polar-migration` may read your existing billing code and data with your own API keys to map customers and products to Polar. Stripe migrations themselves run in Polar's dashboard. ## Support diff --git a/skills/polar-integration/SKILL.md b/skills/polar-integration/SKILL.md index 3ebd647..1b83949 100644 --- a/skills/polar-integration/SKILL.md +++ b/skills/polar-integration/SKILL.md @@ -9,33 +9,44 @@ A standalone guide to wiring up Polar's three core HTTP endpoints — Checkout, ## Setup -Install the SDK: +Install the SDK. Version 1.0 and later needs Node.js 22 or newer: ```bash npm install @polar-sh/sdk # or pnpm / yarn / bun ``` -Don't install `@polar-sh/` packages (e.g. `@polar-sh/nextjs`, `@polar-sh/express`, `@polar-sh/hono`, etc.) — they are deprecated. `@polar-sh/sdk` is all you need for these recipes. +If the project already uses `@polar-sh/sdk` below 1.0 (signs: `new Polar(...)`, camelCase fields such as `successUrl`, imports from `@polar-sh/sdk/webhooks`), upgrade it first by following the [migration guide](https://github.com/polarsource/polar/blob/main/sdk/typescript/.agents/skills/polar-typescript-sdk/references/migration-from-v0.md). Don't mix the old and new conventions. + +Don't install the deprecated `@polar-sh/` adapters (`@polar-sh/express`, `@polar-sh/hono`, `@polar-sh/fastify`, `@polar-sh/elysia`, `@polar-sh/astro`, `@polar-sh/sveltekit`, `@polar-sh/remix`, `@polar-sh/deno`, `@polar-sh/supabase`). `@polar-sh/nextjs`, `@polar-sh/nuxt`, `@polar-sh/tanstack-start` and `@polar-sh/better-auth` are maintained; keep them if the project already uses them. Required environment variables (use whatever loader your framework provides — `process.env`, `Deno.env`, `import.meta.env`, etc.): -- `POLAR_ACCESS_TOKEN` — organization access token from the Polar dashboard. +- `POLAR_ACCESS_TOKEN` — organization access token from the Polar dashboard (Settings → Developers). - `POLAR_WEBHOOK_SECRET` — only needed for the webhook recipe. -- `POLAR_SERVER` — set to `sandbox` while developing, omit (or `production`) in prod. +- `POLAR_ENVIRONMENT` — `sandbox` while developing; omit it or set `production` in production. Sandbox and production are separate, so each needs its own token. -Construct one client and reuse it: +The SDK is versioned by API version: the import path pins it. Use `2026-10`, the current version, unless the project already imports another one. Construct one client in a server-only module and reuse it: ```ts -import { Polar } from "@polar-sh/sdk"; +import { createPolar, type Environment } from "@polar-sh/sdk/2026-10"; + +const accessToken = process.env.POLAR_ACCESS_TOKEN; +if (!accessToken) throw new Error("POLAR_ACCESS_TOKEN is required"); -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: (process.env.POLAR_SERVER as "sandbox" | "production") ?? "production", +export const polar = createPolar({ + accessToken, + environment: (process.env.POLAR_ENVIRONMENT as Environment | undefined) || "production", }); ``` -The three recipes below are independent — implement only the ones the user asks for. +The recipes below import this `polar` client. They are independent — implement only the ones the user asks for. + +### Conventions + +- Service and method names are camelCase (`polar.checkouts.create`, `polar.customerSessions.create`); every request and response field is `snake_case`, exactly as in the HTTP API. +- Path parameters are positional (`polar.checkouts.get(id)`), request bodies are a single object. +- Paginated lists come from `iter*` methods consumed with `for await`. --- @@ -43,105 +54,83 @@ The three recipes below are independent — implement only the ones the user ask **Goal:** an HTTP endpoint that creates a Polar checkout session and 302-redirects the browser to the hosted checkout page. -**Underlying API:** `POST /v1/checkouts/` (SDK: `polar.checkouts.create(...)`). Full schema: . +**Underlying API:** `POST /v1/checkouts/` (SDK: `polar.checkouts.create(...)`). Full schema: . ### `polar.checkouts.create()` field reference -The SDK uses `camelCase`; the underlying HTTP API uses `snake_case`. Pick whichever you see in the user's existing code. - -| SDK field (camelCase) | API field (snake_case) | Required | Notes | -|---|---|---|---| -| `products` | `products` | yes | Array of product UUIDs. The first is selected by default. | -| `customerId` | `customer_id` | no | Existing Polar customer ID. | -| `externalCustomerId` | `external_customer_id` | no | Your own user ID. If no Polar customer matches, one is created with this external ID. | -| `customerEmail` | `customer_email` | no | | -| `customerName` | `customer_name` | no | | -| `customerBillingName` | `customer_billing_name` | no | | -| `customerBillingAddress` | `customer_billing_address` | no | `{ country: "US", line1, line2, city, state, postal_code }`. `country` (ISO alpha-2) is required if address is set. | -| `customerTaxId` | `customer_tax_id` | no | | -| `customerIpAddress` | `customer_ip_address` | no | | -| `customerMetadata` | `customer_metadata` | no | Copied to the created customer. | -| `isBusinessCustomer` | `is_business_customer` | no | Default `false`. If `true`, full billing address + name required. | -| `metadata` | `metadata` | no | Copied to the resulting order/subscription. | -| `customFieldData` | `custom_field_data` | no | Map of custom field slug → value. | -| `discountId` | `discount_id` | no | | -| `allowDiscountCodes` | `allow_discount_codes` | no | Default `true`. | -| `requireBillingAddress` | `require_billing_address` | no | Default `false`. US customers always required regardless. | -| `seats` | `seats` | no | Predefined number of seats (seat-based pricing only). | -| `minSeats` / `maxSeats` | `min_seats` / `max_seats` | no | Seat-based pricing only. | -| `amount` | `amount` | no | Cents. Only used for `custom` (pay-what-you-want) prices. | -| `subscriptionId` | `subscription_id` | no | Upgrade an existing free subscription. | -| `successUrl` | `success_url` | no | See below. | -| `returnUrl` | `return_url` | no | Back-button URL on the checkout page. | -| `embedOrigin` | `embed_origin` | no | Set when embedding the checkout in an iframe. | -| `locale` | `locale` | no | IETF BCP 47 (`en`, `en-US`, `fr-CA`, ...). Defaults to `en-US`. | -| `currency` | `currency` | no | ISO 4217 (e.g. `usd`, `eur`). | -| `trialInterval` / `trialIntervalCount` | `trial_interval` / `trial_interval_count` | no | Override product trial. | -| `allowTrial` | `allow_trial` | no | Default `true`. Set `false` to disable an otherwise-configured trial. | -| `prices` | `prices` | no | Map of product ID → ad-hoc prices (override catalog prices). | - -**Response:** `{ id, url, client_secret, expires_at, status, ... }`. Redirect the user to `url`. `client_secret` is only needed if you are embedding the checkout via `@polar-sh/checkout`. +| Field | Required | Notes | +|---|---|---| +| `products` | yes | Array of product UUIDs. The first is selected by default. | +| `customer_id` | no | Existing Polar customer ID. | +| `external_customer_id` | no | Your own user ID. If no Polar customer matches, one is created with this external ID. | +| `customer_email` | no | | +| `customer_name` | no | | +| `customer_billing_name` | no | | +| `customer_billing_address` | no | `{ country: "US", line1, line2, city, state, postal_code }`. `country` (ISO alpha-2) is required if address is set. | +| `customer_tax_id` | no | | +| `customer_ip_address` | no | Used to pick the presentment currency. | +| `customer_metadata` | no | Copied to the created customer. | +| `is_business_customer` | no | Default `false`. If `true`, full billing address + name required. | +| `metadata` | no | Copied to the resulting order/subscription. | +| `custom_field_data` | no | Map of custom field slug → value. | +| `discount_id` | no | | +| `allow_discount_codes` | no | Default `true`. | +| `require_billing_address` | no | Default `false`. US customers always required regardless. | +| `seats` | no | Predefined number of seats (seat-based pricing only). | +| `min_seats` / `max_seats` | no | Seat-based pricing only. | +| `units` / `min_units` / `max_units` | no | Unit-based pricing only. | +| `amount` | no | Cents. Only used for `custom` (pay-what-you-want) prices. | +| `subscription_id` | no | Upgrade an existing free subscription. | +| `success_url` | no | See below. | +| `return_url` | no | Back-button URL on the checkout page. | +| `embed_origin` | no | Set when embedding the checkout in an iframe. | +| `locale` | no | IETF BCP 47 (`en`, `en-US`, `fr-CA`, ...). Defaults to `en`. | +| `currency` | no | ISO 4217 (e.g. `usd`, `eur`). | +| `trial_interval` / `trial_interval_count` | no | Override product trial. | +| `allow_trial` | no | Default `true`. Set `false` to disable an otherwise-configured trial. | +| `prices` | no | Map of product ID → ad-hoc prices (override catalog prices). | + +**Response:** `{ id, url, client_secret, expires_at, status, ... }`. Redirect the user to `url`. To embed the checkout in an iframe instead, pass `url` to `PolarEmbedCheckout.create` from `@polar-sh/checkout` and set `embed_origin`. `client_secret` is only for client-side checkout calls and custom checkout UIs. ### Notes on specific fields -- **`successUrl` and `{CHECKOUT_ID}`.** Polar performs a literal string substitution: any `{CHECKOUT_ID}` token in the URL is replaced with the actual session ID at redirect time. The official API docs example uses `?checkout_id={CHECKOUT_ID}`; you can use any param name you want. Useful when your success page needs to fetch the session via `polar.checkouts.get(checkoutId)`. -- **`theme` (UI hint, not an API field).** The hosted checkout UI accepts `?theme=light|dark` in the URL. To apply a theme, append it to `result.url` *after* creating the session — it's not a field on `polar.checkouts.create()`. +- **`success_url` and `{CHECKOUT_ID}`.** Polar performs a literal string substitution: any `{CHECKOUT_ID}` token in the URL is replaced with the actual session ID at redirect time. The official API docs example uses `?checkout_id={CHECKOUT_ID}`; you can use any param name you want. Useful when your success page needs to fetch the session via `polar.checkouts.get(checkoutId)`. Don't treat the redirect as proof of payment — grant access from a verified webhook or customer state. +- **`theme` (UI hint, not an API field).** The hosted checkout UI accepts `?theme=light|dark` in the URL. To apply a theme, append it to `checkout.url` *after* creating the session. +- **Customer IDs come from your auth.** When the user is logged in, set `customer_id` or `external_customer_id` from the server-side session, never from the query string: the ID links the order to that customer. ### Canonical implementation (Web Standards — Request/Response) -Works as-is in Deno, Bun, Cloudflare Workers, Supabase Edge Functions, SvelteKit, Remix, TanStack Start, Astro endpoints, and Next.js Route Handlers (NextRequest extends Request). - ```ts -import { Polar } from "@polar-sh/sdk"; - -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "production", -}); +import { polar } from "./polar"; -const SUCCESS_URL = "https://example.com/success?checkoutId={CHECKOUT_ID}"; +const SUCCESS_URL = "https://example.com/success?checkout_id={CHECKOUT_ID}"; const THEME: "light" | "dark" | undefined = undefined; -export async function GET(request: Request): Promise { +export async function handleCheckout(request: Request): Promise { const url = new URL(request.url); const products = url.searchParams.getAll("products"); if (products.length === 0) { - return Response.json( - { error: "Missing products in query params" }, - { status: 400 }, - ); + return Response.json({ error: "Missing products in query params" }, { status: 400 }); } - const json = (key: string) => - url.searchParams.has(key) - ? JSON.parse(url.searchParams.get(key) ?? "{}") - : undefined; - const str = (key: string) => url.searchParams.get(key) ?? undefined; + const ipAddress = + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + request.headers.get("cf-connecting-ip") ?? + undefined; try { - const result = await polar.checkouts.create({ + const checkout = await polar.checkouts.create({ products, - successUrl: SUCCESS_URL, - customerId: str("customerId"), - externalCustomerId: str("customerExternalId"), - customerEmail: str("customerEmail"), - customerName: str("customerName"), - customerBillingAddress: json("customerBillingAddress"), - customerTaxId: str("customerTaxId"), - customerIpAddress: str("customerIpAddress"), - customerMetadata: json("customerMetadata"), - metadata: json("metadata"), - discountId: str("discountId"), - allowDiscountCodes: url.searchParams.has("allowDiscountCodes") - ? url.searchParams.get("allowDiscountCodes") === "true" - : undefined, - seats: url.searchParams.has("seats") - ? Number.parseInt(url.searchParams.get("seats") ?? "1", 10) - : undefined, + success_url: SUCCESS_URL, + external_customer_id: await getUserId(request), // your auth helper, or undefined for guests + customer_email: url.searchParams.get("customer_email") ?? undefined, + customer_ip_address: ipAddress, + discount_id: url.searchParams.get("discount_id") ?? undefined, }); - const redirectUrl = new URL(result.url); + const redirectUrl = new URL(checkout.url); if (THEME) redirectUrl.searchParams.set("theme", THEME); return Response.redirect(redirectUrl.toString(), 302); @@ -152,61 +141,40 @@ export async function GET(request: Request): Promise { } ``` -This handler accepts a GET request with `?products=` and forwards optional customer-prefill params straight to `polar.checkouts.create()`. Drop fields the caller won't ever provide; add fields from the table above (`metadata`, `embedOrigin`, `locale`, ...) as needed for your use case. If your frontend is server-rendered and already knows the user, hardcode `externalCustomerId` from your auth context instead of reading it from the query. +This handler accepts a GET request with `?products=`. Drop fields you don't need and add fields from the table above (`metadata`, `embed_origin`, `locale`, ...) as needed. ### Framework variations -The body of the handler is identical — only the signature, URL access, and response constructors change. - -**Next.js (App Router):** put the canonical handler in `app/api/checkout/route.ts`. `NextRequest`/`NextResponse` are optional; `request: Request` works. - -**Express:** - -```ts -import type { Request, Response } from "express"; - -app.get("/checkout", async (req: Request, res: Response) => { - const url = new URL(`${req.protocol}://${req.get("host")}${req.originalUrl}`); - // ... same body using `url.searchParams` ... - // res.redirect(redirectUrl.toString()); - // res.status(400).json({ error: "..." }); -}); -``` - -**Fastify:** - -```ts -fastify.get("/checkout", async (request, reply) => { - const url = new URL(`${request.protocol}://${request.hostname}${request.url}`); - // ... same body ... - // reply.redirect(redirectUrl.toString()); -}); -``` - -**Hono:** - -```ts -app.get("/checkout", async (c) => { - const url = new URL(c.req.url); - // ... same body ... - return c.redirect(redirectUrl.toString()); -}); -``` - -**Elysia:** - -```ts -app.get("/checkout", ({ request, redirect }) => { - const url = new URL(request.url); - // ... same body, `return redirect(redirectUrl.toString())` ... -}); -``` - -**Nuxt (server route):** `server/api/checkout.get.ts` using `defineEventHandler`, `getQuery`, `sendRedirect(event, url, 302)`. - -**SvelteKit:** `src/routes/api/checkout/+server.ts` exporting `GET({ request, url })` — the canonical handler works directly. - -**Astro:** `src/pages/api/checkout.ts` exporting `export const GET: APIRoute = async ({ request }) => { ... }`. +The body of the handler is identical — only the entry point changes: + +- **Next.js (App Router):** `app/api/checkout/route.ts`: `export const GET = handleCheckout;` +- **SvelteKit:** `src/routes/api/checkout/+server.ts`: `export const GET = ({ request }) => handleCheckout(request);` +- **Astro:** `src/pages/api/checkout.ts`: `export const GET: APIRoute = ({ request }) => handleCheckout(request);` +- **Remix / React Router:** `export const loader = ({ request }) => handleCheckout(request);` +- **TanStack Start:** `server: { handlers: { GET: ({ request }) => handleCheckout(request) } }` on the route. +- **Hono:** `app.get("/checkout", (c) => handleCheckout(c.req.raw));` +- **Elysia:** `app.get("/checkout", ({ request }) => handleCheckout(request));` +- **Deno / Supabase Edge Functions:** `Deno.serve(handleCheckout);` +- **Bun:** `Bun.serve({ fetch: handleCheckout });` +- **Cloudflare Workers:** `export default { fetch: handleCheckout };` (read secrets from the `env` argument instead of `process.env`). +- **Nuxt (server route):** `server/api/checkout.get.ts` with `defineEventHandler((event) => handleCheckout(toWebRequest(event)))`. +- **Express:** + + ```ts + app.get("/checkout", async (req, res) => { + const url = new URL(`${req.protocol}://${req.get("host")}${req.originalUrl}`); + // ... same body using `url.searchParams`, then res.redirect(redirectUrl.toString()) ... + }); + ``` + +- **Fastify:** + + ```ts + fastify.get("/checkout", async (request, reply) => { + const url = new URL(`${request.protocol}://${request.hostname}${request.url}`); + // ... same body, then reply.redirect(redirectUrl.toString()) ... + }); + ``` --- @@ -214,52 +182,41 @@ app.get("/checkout", ({ request, redirect }) => { **Goal:** an authenticated endpoint that creates a Polar customer session and 302-redirects to the hosted customer portal (subscriptions, invoices, payment methods). -**Underlying API:** `POST /v1/customer-sessions/` (SDK: `polar.customerSessions.create(...)`). Full schema: . +**Underlying API:** `POST /v1/customer-sessions/` (SDK: `polar.customerSessions.create(...)`). Full schema: . ### `polar.customerSessions.create()` field reference -The request body is a union — pass **either** `customerId` **or** `externalCustomerId`, not both. +The request body is a union — pass **either** `customer_id` **or** `external_customer_id`, not both. -| SDK field | API field | Required | Notes | -|---|---|---|---| -| `customerId` | `customer_id` | one of these two | Existing Polar customer UUID. | -| `externalCustomerId` | `external_customer_id` | one of these two | Your own user ID. | -| `returnUrl` | `return_url` | no | Back-button URL on the portal page. | -| `memberId` | `member_id` | no | Only for orgs with `member_model_enabled`. Polar customer member UUID. Defaults to the owner member for individual customers. | -| `externalMemberId` | `external_member_id` | no | Your member ID, alternative to `memberId`. | +| Field | Required | Notes | +|---|---|---| +| `customer_id` | one of these two | Existing Polar customer UUID. | +| `external_customer_id` | one of these two | Your own user ID. | +| `return_url` | no | Back-button URL on the portal page. | +| `member_id` | no | Only for orgs with `member_model_enabled`. Polar customer member UUID. Defaults to the owner member for individual customers. | +| `external_member_id` | no | Your member ID, alternative to `member_id`. | **Response:** `{ id, customer_portal_url, token, expires_at, customer, customer_id, return_url, ... }`. Redirect the user to `customer_portal_url`. `token` and `customer` are useful if you want to render portal data inside your own UI instead of redirecting. ### Canonical implementation ```ts -import { Polar } from "@polar-sh/sdk"; - -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "production", -}); +import { polar } from "./polar"; const RETURN_URL = "https://example.com/account"; -// Replace with your auth: read a cookie/JWT/session and return your user id. -async function getExternalCustomerId(request: Request): Promise { - const session = await getSession(request); // your auth helper - return session?.userId ?? null; -} - -export async function GET(request: Request): Promise { - const externalCustomerId = await getExternalCustomerId(request); +export async function handlePortal(request: Request): Promise { + const externalCustomerId = await getUserId(request); // your auth helper if (!externalCustomerId) { return Response.json({ error: "Unauthorized" }, { status: 401 }); } try { - const { customerPortalUrl } = await polar.customerSessions.create({ - externalCustomerId, - returnUrl: RETURN_URL, + const session = await polar.customerSessions.create({ + external_customer_id: externalCustomerId, + return_url: RETURN_URL, }); - return Response.redirect(customerPortalUrl, 302); + return Response.redirect(session.customer_portal_url, 302); } catch (error) { console.error(error); return new Response(null, { status: 500 }); @@ -267,11 +224,11 @@ export async function GET(request: Request): Promise { } ``` -If you have a Polar customer ID instead of your own user ID, swap `externalCustomerId` for `customerId` in the `polar.customerSessions.create({ ... })` call. `externalCustomerId` is usually what you want — your auth system already knows the user's ID. +If you have a Polar customer ID instead of your own user ID, swap `external_customer_id` for `customer_id`. `external_customer_id` is usually what you want — your auth system already knows the user's ID. Never let the caller choose another customer's ID. ### Framework variations -Same shape as Checkout — only the request/response idioms change. Read auth state however your framework expects (Next.js: `cookies()` from `next/headers`, Express: `req.user`, Hono: `c.get('user')`, etc.) and pass the result into `polar.customerSessions.create`. +Use the same entry points as Recipe 1. Read auth state however your framework expects (Next.js: `await cookies()` from `next/headers`, Express: `req.user`, Hono: `c.get("user")`, etc.). --- @@ -279,51 +236,46 @@ Same shape as Checkout — only the request/response idioms change. Read auth st **Goal:** receive Polar webhook events, verify the signature, and dispatch to per-event handlers. -**Contract:** POST endpoint at a stable URL configured in the Polar dashboard. Verifies the `webhook-id`, `webhook-timestamp`, `webhook-signature` headers using `validateEvent` from `@polar-sh/sdk/webhooks`. Returns 200 on success, 403 on signature mismatch. +**Contract:** POST endpoint at a stable URL configured in the Polar dashboard. Verifies the `webhook-id`, `webhook-timestamp` and `webhook-signature` headers with `webhooks.validateEvent` from the same versioned import as the client. Keep that version equal to the webhook endpoint's API version in the dashboard, so payloads match the SDK's types. ### Canonical implementation ```ts -import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"; +import { webhooks } from "@polar-sh/sdk/2026-10"; -const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!; +const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET; +if (!WEBHOOK_SECRET) throw new Error("POLAR_WEBHOOK_SECRET is required"); -export async function POST(request: Request): Promise { +export async function handleWebhook(request: Request): Promise { const body = await request.text(); // must be the raw body, not parsed JSON + const headers = { + "webhook-id": request.headers.get("webhook-id") ?? "", + "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "", + "webhook-signature": request.headers.get("webhook-signature") ?? "", + }; - let event: ReturnType; + let event: webhooks.WebhookPayload; try { - event = validateEvent( - body, - { - "webhook-id": request.headers.get("webhook-id") ?? "", - "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "", - "webhook-signature": request.headers.get("webhook-signature") ?? "", - }, - WEBHOOK_SECRET, - ); + event = await webhooks.validateEvent(body, headers, WEBHOOK_SECRET!); } catch (error) { - if (error instanceof WebhookVerificationError) { + if (error instanceof webhooks.PolarWebhookVerificationError) { return Response.json({ received: false }, { status: 403 }); } + if (error instanceof webhooks.PolarWebhookUnknownTypeError) { + // Signed, but an event type this SDK version doesn't know. Acknowledge it so Polar doesn't retry. + return Response.json({ received: true }, { status: 202 }); + } + if (error instanceof webhooks.PolarWebhookError) { + return Response.json({ received: false }, { status: 400 }); + } throw error; } switch (event.type) { - case "checkout.created": - case "checkout.updated": - // event.data is the Checkout - break; - - case "order.created": - case "order.updated": case "order.paid": - case "order.refunded": // event.data is the Order — fulfill, send receipt, etc. break; - case "subscription.created": - case "subscription.updated": case "subscription.active": case "subscription.canceled": case "subscription.uncanceled": @@ -331,45 +283,28 @@ export async function POST(request: Request): Promise { // event.data is the Subscription — flip entitlements in your DB break; - case "refund.created": - case "refund.updated": - break; - - case "product.created": - case "product.updated": - break; - - case "benefit.created": - case "benefit.updated": - break; - case "benefit_grant.created": case "benefit_grant.updated": case "benefit_grant.revoked": // grant or revoke the user's access to a benefit break; - case "customer.created": - case "customer.updated": - case "customer.deleted": case "customer.state_changed": - break; - - case "organization.updated": + // event.data is the full customer state — reconcile access from granted_benefits break; } - return Response.json({ received: true }); + return Response.json({ received: true }, { status: 202 }); } ``` -`event` is fully typed — TypeScript will narrow `event.data` inside each `case`. +`event` is fully typed — TypeScript narrows `event.data` inside each `case`. Other events include `checkout.created`, `checkout.updated`, `order.created`, `order.updated`, `order.refunded`, `subscription.created`, `subscription.updated`, `refund.created`, `refund.updated`, `product.created`, `product.updated`, `benefit.created`, `benefit.updated`, `customer.created`, `customer.updated`, `customer.deleted` and `organization.updated`. Subscribe the endpoint only to the events you handle. ### Critical details -1. **Use the raw request body** for `validateEvent`. If your framework parsed JSON for you (Express's `express.json()`, Fastify's default body parser), disable it on this route or read the raw body manually. Signature verification fails on re-serialized JSON. -2. **Respond fast.** Polar retries on non-2xx and on timeouts. If a handler is slow (sending email, syncing inventory), enqueue it (queue/cron/background job) and return 200 immediately. -3. **Idempotency.** Polar may redeliver. Deduplicate on the `webhook-id` header — it's unique per delivery and reused on retries (Standard Webhooks spec). Don't dedupe on `event.data.id`: that's the resource ID and is shared across distinct events about the same resource (e.g. `order.created` and `order.paid`). +1. **Use the raw request body** for `validateEvent`. If your framework parsed JSON for you (Express's `express.json()`, Fastify's default body parser), the signature check fails on the re-serialized JSON. +2. **Respond within 2 seconds.** Polar times out after 10 seconds, retries failed deliveries up to 10 times, and disables the endpoint after 10 consecutive failures. Persist or enqueue the event, return 2xx, and do slow work (email, provisioning) in a background job. +3. **Idempotency.** Polar may redeliver. Deduplicate on the `webhook-id` header — it's unique per delivery and reused on retries. Don't dedupe on `event.data.id`: that's the resource ID and is shared across distinct events about the same resource (e.g. `order.created` and `order.paid`). 4. **Keep the route public.** Polar doesn't follow redirects, so a route behind auth middleware that redirects to a login page fails every delivery. The signature is the authentication. ### Test it locally @@ -385,31 +320,28 @@ Locally, `POLAR_WEBHOOK_SECRET` is the secret from `polar webhook listen --print ### Framework variations -**Next.js Route Handler:** drop the canonical handler into `app/api/polar/webhook/route.ts`. App Router does not auto-parse the body, so `request.text()` is correct. - -**Express:** mount with the raw body parser on this route only: - -```ts -app.post("/polar/webhook", express.raw({ type: "application/json" }), async (req, res) => { - const body = (req.body as Buffer).toString("utf8"); - // ... validateEvent / switch ... - res.json({ received: true }); -}); -``` - -**Fastify:** add a content-type parser that preserves the raw body, or use `request.rawBody` with `@fastify/raw-body`. - -**Hono:** `await c.req.text()` returns the raw body, and `c.req.header("webhook-id")` reads headers. - -**Elysia:** `await request.text()` on the standard `Request`. - -**SvelteKit:** in `+server.ts`, `await event.request.text()`. - -**Astro:** in `APIRoute`, `await request.text()`. - -**Nuxt:** `await readRawBody(event)` from h3. - ---- - -- Don't install `@polar-sh/` packages (e.g. `@polar-sh/express`) — they are deprecated. `@polar-sh/sdk` is all you need for these recipes. The only exceptions are `@polar-sh/nextjs` for Next.js App Router and `@polar-sh/better-auth` for Better Auth. +Use the same entry points as Recipe 1 with `POST` (Remix: `action`), plus: + +- **Next.js Route Handler:** `app/api/polar/webhook/route.ts`: `export const POST = handleWebhook;`. App Router doesn't parse the body, so `request.text()` is correct. +- **Express:** register the webhook route with `express.raw` **before** `app.use(express.json())` or any other body parser; once an earlier parser has consumed the stream, the signed bytes are gone. Pass the `Buffer` straight to `validateEvent`: + + ```ts + app.post("/polar/webhook", express.raw({ type: "application/json" }), async (req, res, next) => { + try { + const event = await webhooks.validateEvent(req.body, { + "webhook-id": req.header("webhook-id") ?? "", + "webhook-timestamp": req.header("webhook-timestamp") ?? "", + "webhook-signature": req.header("webhook-signature") ?? "", + }, WEBHOOK_SECRET!); + // ... handle event, catch the error classes as in the canonical handler ... + res.sendStatus(202); + } catch (error) { + next(error); + } + }); + app.use(express.json()); + ``` +- **Fastify:** register `fastify-raw-body` and set `config: { rawBody: true }` on the route, then pass `request.rawBody`. +- **Hono:** `app.post("/polar/webhook", (c) => handleWebhook(c.req.raw));` +- **Nuxt:** `await readRawBody(event)` from h3 for the body, `getHeader(event, "webhook-id")` for headers. diff --git a/skills/polar-migration/SKILL.md b/skills/polar-migration/SKILL.md index 53c646c..2e27006 100644 --- a/skills/polar-migration/SKILL.md +++ b/skills/polar-migration/SKILL.md @@ -1,575 +1,127 @@ --- name: polar-migration description: | - Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy, Gumroad). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Migrating customer data and subscriptions; (3) Running parallel systems during transition; (4) Mapping products and pricing between platforms; (5) Handling payment method migration; (6) Communicating migration to customers; (7) Testing migration in sandbox before production. + Guide for migrating to Polar from other payment platforms (Stripe Billing, Paddle, Lemon Squeezy). Use this skill when: (1) Planning a migration from another payment provider to Polar; (2) Moving customers, subscriptions and saved cards with Polar's Stripe migration; (3) Running both billing systems during the transition; (4) Mapping products and pricing between platforms; (5) Updating webhook and access handling for migrated subscriptions; (6) Communicating the migration to customers; (7) Testing the migration in sandbox first. --- # Polar Migration Guide -Migrate to Polar from other payment platforms while minimizing disruption to your customers and revenue. +[Polar's migration guide](https://polar.sh/docs/migrate) is the source of truth; read it before recommending anything. This skill summarizes what the application has to do. -## Migration Overview +## Pick the path -### What Can Be Migrated +- **From Stripe:** use Polar's assisted migration under **Settings → Migrations**. Polar imports products, prices and customers, Stripe copies saved cards straight to Polar's payment processor, and Polar switches each subscription over with the same paid period. Most customers don't need to do anything. Migrations are rolling out gradually; if the tab is missing, contact [Polar support](https://polar.sh/docs/support). +- **From Paddle, Lemon Squeezy or anything else:** there's no automated migration. Contact Polar support first, then follow "Other providers" below. Customers move by checking out again on Polar. -| Data Type | Migration Method | -|-----------|-----------------| -| Customer emails/names | API import | -| Product catalog | Manual recreation | -| Active subscriptions | New subscription creation | -| Payment methods | Customer re-entry required* | -| Historical orders | Not migrated (keep in old system) | -| License keys | New keys generated | +## Rules for every migration -*Payment method tokens cannot be transferred between processors due to PCI compliance. Customers will need to re-enter payment details. +- Never stop, cancel or pause the old provider's billing, or activate a migrated Polar subscription, outside Polar's switch. Don't write your own cutover scripts: they double-bill. +- Never handle raw card data, and never ask for Stripe or Polar secrets in chat. +- Use the application's immutable customer ID as the Polar customer `external_id` (passed as `external_customer_id` at checkout). Not email, and not the old provider's ID unless that's already the app's canonical ID. An `external_id` can't be changed once set. +- Each subscription must have exactly one system responsible for its next renewal. Record which one, rather than inferring it from which IDs are set. +- Past payments stay in the old provider. Don't create Polar orders for transactions Polar didn't process, and keep the old account for refunds, disputes and accounting. -### Migration Strategies +## From Stripe -**1. Hard Cutover** -- Cancel all subscriptions in old system -- Create new subscriptions in Polar -- Best for: Small customer base, simple products +The dashboard handles connecting Stripe, importing the catalog, copying cards and switching subscriptions. The application handles the first and last stages and the events in between. -**2. Gradual Migration** -- Run both systems in parallel -- Migrate customers as they renew -- Best for: Large customer base, minimizing risk +### 1. Move new sales to Polar -**3. New Customers Only** -- Keep existing customers on old system -- New customers use Polar -- Best for: Testing Polar before full commitment +Integrate Polar checkout and webhooks first (`polar-integration`), passing `external_customer_id` on every checkout. New customers then subscribe on Polar while existing subscriptions keep renewing on Stripe. Keep the Stripe IDs in your database until nothing renews there. -## Pre-Migration Checklist +During coexistence, access checks must accept a valid entitlement from either provider, and cancellations or plan changes go to whichever system bills that subscription. -### 1. Set Up Polar +### 2. Map imported customers -```bash -# Test in sandbox first -# https://sandbox.polar.sh +The import creates or reuses Polar customers, but Polar can't know your application's customer IDs. Match each imported customer once on an exact, unique email, then set your ID on customers whose `external_id` is still empty. Send missing, duplicate or changed emails for review instead of guessing. -# Install SDK -npm install @polar-sh/sdk -``` - -### 2. Map Your Products - -Create equivalent products in Polar: - -```typescript -import { Polar } from "@polar-sh/sdk"; - -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "sandbox", // Test first! -}); - -// Create products matching your old catalog -const basicPlan = await polar.products.create({ - name: "Basic Plan", - organizationId: "org_xxx", - description: "Basic features", - prices: [ - { - type: "recurring", - recurringInterval: "month", - amountType: "fixed", - priceAmount: 900, // $9.00 - priceCurrency: "usd", - }, - { - type: "recurring", - recurringInterval: "year", - amountType: "fixed", - priceAmount: 9000, // $90.00 - priceCurrency: "usd", - }, - ], -}); -``` - -### 3. Map Benefits/Entitlements - -```typescript -// Create benefits in Polar -const licenseBenefit = await polar.benefits.create({ - type: "license_keys", - description: "Software License", - organizationId: "org_xxx", - properties: { - prefix: "PRO", - activations: { limit: 3 }, - }, -}); +```ts +import { polar } from "./polar"; // client from polar-integration -// Attach the benefit to the product in the Polar dashboard -// (Organization → Products → [product] → Benefits). -``` - -### 4. Set Up Webhooks - -```typescript -import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks"; - -export async function POST(request: Request): Promise { - const body = await request.text(); - - let event: ReturnType; - try { - event = validateEvent( - body, - { - "webhook-id": request.headers.get("webhook-id") ?? "", - "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "", - "webhook-signature": request.headers.get("webhook-signature") ?? "", - }, - process.env.POLAR_WEBHOOK_SECRET!, - ); - } catch (error) { - if (error instanceof WebhookVerificationError) { - return Response.json({ received: false }, { status: 403 }); - } - throw error; - } - - switch (event.type) { - case "order.paid": - // Grant access in your system - await grantAccess(event.data.customer.externalId, event.data.productId); - break; - - case "subscription.canceled": - // Schedule access removal - await scheduleAccessRemoval(event.data.customer.externalId, event.data.endsAt); - break; - } - - return Response.json({ received: true }); +const customer = await polar.customers.get(polarCustomerId); +if (customer.external_id === null) { + await polar.customers.update(polarCustomerId, { external_id: appCustomerId }); } ``` -See the `polar-integration` skill for the full webhook recipe (event types, framework variations, raw-body requirement, idempotency). - -## Migration from Stripe Billing - -### Export Customer Data - -```typescript -import Stripe from "stripe"; +Imported products are new Polar products. Map each internal plan to its new Polar product and price IDs explicitly, never by name. Benefits aren't imported: attach them to the imported products before the switch if access depends on them. -const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); +### 3. Prepare for the switch -// Export customers with active subscriptions -async function exportStripeCustomers() { - const customers: ExportedCustomer[] = []; +Polar stops each switched subscription on Stripe by canceling it immediately, without proration or a final invoice. Before switching: - for await (const subscription of stripe.subscriptions.list({ status: "active" })) { - const customer = await stripe.customers.retrieve(subscription.customer as string); +- Turn off Stripe's canceled-subscription email (Settings → Billing → Subscriptions and emails) and pause any Stripe automation triggered by a cancellation. +- Make the Stripe `customer.subscription.deleted` handler ignore switched subscriptions, so it doesn't revoke access or send a cancellation email: - if (customer.deleted) continue; - - customers.push({ - stripeCustomerId: customer.id, - email: customer.email!, - name: customer.name || undefined, - subscriptionId: subscription.id, - productId: subscription.items.data[0].price.product as string, - priceId: subscription.items.data[0].price.id, - currentPeriodEnd: new Date(subscription.current_period_end * 1000), - cancelAtPeriodEnd: subscription.cancel_at_period_end, - }); + ```ts + if (subscription.cancellation_details?.comment?.startsWith("Migrated to Polar")) { + return; // billing moved to Polar; reconcile through subscription.migrated instead } + ``` - return customers; -} -``` - -### Create Customers in Polar - -```typescript -// Map Stripe products to Polar products -const productMap: Record = { - "prod_stripe_basic": "prod_polar_basic", - "prod_stripe_pro": "prod_polar_pro", -}; - -async function createPolarCustomers(customers: ExportedCustomer[]) { - for (const customer of customers) { - // Create customer in Polar with external ID for linking - const polarCustomer = await polar.customers.create({ - organizationId: "org_xxx", - email: customer.email, - name: customer.name, - externalId: customer.stripeCustomerId, // Link to Stripe ID - }); - - console.log(`Created Polar customer: ${polarCustomer.id} for ${customer.email}`); - } -} -``` - -### Migrate Subscriptions (Gradual) - -```typescript -async function migrateSubscription(customer: ExportedCustomer) { - const polarProductId = productMap[customer.productId]; - - // Create checkout for customer to enter new payment method - const checkout = await polar.checkouts.create({ - products: [polarProductId], - customerEmail: customer.email, - externalCustomerId: customer.stripeCustomerId, - successUrl: `https://yoursite.com/migration-complete?customer=${customer.stripeCustomerId}`, - // Allow discount for migration - discountId: "migration_discount_xxx", - }); - - // Send migration email to customer - await sendMigrationEmail(customer.email, { - checkoutUrl: checkout.url, - currentPeriodEnd: customer.currentPeriodEnd, - }); - - return checkout; -} -``` - -### Handle Migration Completion - -```typescript -// Inside the validateEvent switch in your webhook handler: -case "order.paid": { - const order = event.data; - const stripeCustomerId = order.customer.externalId; - - if (stripeCustomerId?.startsWith("cus_")) { - // Cancel Stripe subscription at period end - const stripeSubscriptions = await stripe.subscriptions.list({ - customer: stripeCustomerId, - status: "active", - }); - - for (const sub of stripeSubscriptions.data) { - await stripe.subscriptions.update(sub.id, { - cancel_at_period_end: true, - }); - } - - // Mark customer as migrated in your database - await db.customer.update({ - where: { stripeId: stripeCustomerId }, - data: { - migratedToPolar: true, - polarCustomerId: order.customer.id, - }, - }); - } - break; -} -``` - -## Migration from Paddle - -### Export Paddle Data - -```typescript -// Paddle API to export subscribers -async function exportPaddleSubscribers() { - const response = await fetch("https://vendors.paddle.com/api/2.0/subscription/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - vendor_id: process.env.PADDLE_VENDOR_ID, - vendor_auth_code: process.env.PADDLE_AUTH_CODE, - state: "active", - }), - }); - - const data = await response.json(); - return data.response; -} -``` - -### Map Paddle to Polar - -```typescript -const paddleToPolar = { - products: { - "paddle_12345": "polar_basic", - "paddle_12346": "polar_pro", - }, -}; - -async function migratePaddleCustomer(paddleUser: PaddleSubscriber) { - // Create checkout with Polar - const checkout = await polar.checkouts.create({ - products: [paddleToPolar.products[paddleUser.plan_id]], - customerEmail: paddleUser.user_email, - externalCustomerId: `paddle_${paddleUser.user_id}`, - successUrl: "https://yoursite.com/migrated", - }); - - return checkout; -} -``` - -## Migration from Lemon Squeezy - -```typescript -// Lemon Squeezy API export -async function exportLemonSqueezyCustomers() { - const response = await fetch("https://api.lemonsqueezy.com/v1/subscriptions", { - headers: { - Authorization: `Bearer ${process.env.LEMON_SQUEEZY_API_KEY}`, - }, - }); - - const { data } = await response.json(); - return data.filter((sub: any) => sub.attributes.status === "active"); -} -``` - -## Customer Communication - -### Migration Email Template - -```typescript -const migrationEmailTemplate = { - subject: "Action Required: Update Your Payment Method", - body: ` -Hi {{customer_name}}, - -We're upgrading our payment system to provide you with a better experience. - -**What you need to do:** -Click the link below to update your payment method. This will only take a minute. - -[Update Payment Method]({{checkout_url}}) +- Handle Polar's `subscription.migrated` event as the cutover signal. It carries `provider` and `provider_subscription_id`. Treat it as an idempotent upsert: store the Polar subscription ID against the Stripe subscription and record Polar as the system that bills next. Don't grant or revoke access from it, and don't send a welcome email: -**What's changing:** -- More payment options -- Better invoice management -- Improved customer portal - -**What's NOT changing:** -- Your subscription price -- Your features and access -- Your billing date - -**Timeline:** -Please complete this by {{deadline}} to ensure uninterrupted service. - -Your current subscription will continue until {{current_period_end}}, then -seamlessly transition to our new system. - -Questions? Reply to this email. - -Thanks, -{{company_name}} -`, -}; - -async function sendMigrationEmail(email: string, data: MigrationData) { - // Use your email service (SendGrid, Postmark, etc.) - await emailService.send({ - to: email, - subject: migrationEmailTemplate.subject, - body: renderTemplate(migrationEmailTemplate.body, { - customer_name: data.name || "there", - checkout_url: data.checkoutUrl, - deadline: formatDate(data.deadline), - current_period_end: formatDate(data.currentPeriodEnd), - company_name: "Your Company", - }), - }); -} -``` - -### Reminder Sequence - -```typescript -const reminderSchedule = [ - { daysBeforeDeadline: 14, template: "initial" }, - { daysBeforeDeadline: 7, template: "reminder" }, - { daysBeforeDeadline: 3, template: "urgent" }, - { daysBeforeDeadline: 1, template: "final" }, -]; - -async function scheduleReminders(customer: Customer, deadline: Date) { - for (const reminder of reminderSchedule) { - const sendAt = subDays(deadline, reminder.daysBeforeDeadline); - - await scheduleEmail({ - to: customer.email, - template: reminder.template, - sendAt, - data: { checkoutUrl: customer.migrationCheckoutUrl }, + ```ts + case "subscription.migrated": + await recordMigratedSubscription({ + stripeSubscriptionId: event.provider_subscription_id, + polarSubscriptionId: event.data.id, }); - } -} -``` + break; + ``` -## Parallel Running +Expect `subscription.updated`, `subscription.migrated` and `customer.state_changed` per switched subscription, in any order. Migration sends no `subscription.created` or `subscription.active`, and the import sends no `product.created` or `customer.created`. -### Router Pattern +### 4. Switch, reconcile and clean up -```typescript -// Determine which system handles a customer -async function getPaymentSystem(userId: string): Promise<"stripe" | "polar"> { - const user = await db.user.findUnique({ where: { id: userId } }); +Run the switch from the dashboard. It can't be undone, and subscriptions renewing within 24 hours stay on Stripe until the next attempt. Afterwards, check that every moved subscription bills only in Polar and maps to one customer and entitlement. Retire Stripe checkout and subscription code only once no subscriptions renew there. - if (user?.polarCustomerId) { - return "polar"; - } - return "stripe"; -} +Limits to know up front: Stripe Connect platform accounts and Stripe accounts in India can't use the automated flow. Subscriptions sold through Stripe Managed Payments, and those with multiple items, a quantity above 1, or a past-due, unpaid or paused status, need attention or stay on Stripe. Only cards are copied, real card copies need live Stripe accounts, and the Stripe key mode must match the Polar environment. -// Route subscription checks -async function hasActiveSubscription(userId: string): Promise { - const system = await getPaymentSystem(userId); +For a full assessment, Polar's guide includes a [coding assistant prompt](https://polar.sh/docs/migrate#coding-assistant-prompt) that produces a migration plan and reconciliation contract before any code changes. - if (system === "polar") { - const state = await polar.customers.getState({ - customerId: user.polarCustomerId, - }); - return state.activeSubscriptions.length > 0; - } else { - const subscriptions = await stripe.subscriptions.list({ - customer: user.stripeCustomerId, - status: "active", - }); - return subscriptions.data.length > 0; - } -} -``` +## Other providers -### Unified Webhook Handler +### Recreate the catalog -```typescript -// Handle webhooks from both systems during migration -app.post("/webhooks/stripe", stripeWebhookHandler); -app.post("/webhooks/polar", polarWebhookHandler); +The billing interval belongs to the product, and a product has one fixed price per currency. Monthly and yearly billing are two products: -// Normalize events to your internal format -interface SubscriptionEvent { - type: "created" | "canceled" | "renewed"; - customerId: string; - productId: string; - source: "stripe" | "polar"; -} - -function normalizeStripeEvent(event: Stripe.Event): SubscriptionEvent | null { - // Convert Stripe event to internal format -} - -function normalizePolarEvent(event: PolarWebhookEvent): SubscriptionEvent | null { - // Convert Polar event to internal format -} +```ts +await polar.products.create({ + name: "Pro (Monthly)", + recurring_interval: "month", + prices: [{ amount_type: "fixed", price_amount: 2900, price_currency: "usd" }], +}); +await polar.products.create({ + name: "Pro (Yearly)", + recurring_interval: "year", + prices: [{ amount_type: "fixed", price_amount: 29000, price_currency: "usd" }], +}); ``` -## Post-Migration Cleanup - -### Verify Migration - -```typescript -async function verifyMigration() { - // Get all customers marked as migrated - const migratedCustomers = await db.customer.findMany({ - where: { migratedToPolar: true }, - }); - - const issues: string[] = []; - - for (const customer of migratedCustomers) { - // Verify Polar subscription exists - const polarState = await polar.customers.getState({ - customerId: customer.polarCustomerId, - }); - - if (polarState.activeSubscriptions.length === 0) { - issues.push(`${customer.email}: No active Polar subscription`); - } - - // Verify Stripe subscription is canceled - const stripeSubscriptions = await stripe.subscriptions.list({ - customer: customer.stripeCustomerId, - status: "active", - }); +Recreate benefits (license keys, file downloads, Discord roles and others) and attach them with `polar.products.updateBenefits(productId, { benefits: [benefitId] })`. - if (stripeSubscriptions.data.length > 0) { - issues.push(`${customer.email}: Stripe subscription still active`); - } - } +### Move customers - return issues; -} -``` - -### Cancel Old System - -```typescript -// After all customers migrated, cancel remaining Stripe subscriptions -async function cancelRemainingStripeSubscriptions() { - for await (const subscription of stripe.subscriptions.list({ status: "active" })) { - // Check if customer was migrated - const customer = await db.customer.findFirst({ - where: { stripeId: subscription.customer as string }, - }); +Create customers ahead of time if you want them in Polar before they check out: - if (customer?.migratedToPolar) { - await stripe.subscriptions.cancel(subscription.id); - console.log(`Canceled Stripe subscription ${subscription.id}`); - } - } -} +```ts +await polar.customers.create({ external_id: appCustomerId, email, name }); ``` -## Migration Checklist - -### Before Migration - -- [ ] Products created in Polar (sandbox) -- [ ] Benefits/entitlements mapped -- [ ] Webhooks configured -- [ ] Email templates ready -- [ ] Customer support informed -- [ ] Rollback plan documented - -### During Migration - -- [ ] Test with small batch first -- [ ] Monitor error rates -- [ ] Track conversion rate -- [ ] Respond to customer issues quickly -- [ ] Send reminder emails on schedule +Paid subscriptions can't be created through the API, and saved cards can't move between processors outside Stripe's copy. Each customer checks out on Polar, where they enter their card. Send them a checkout link created with `external_customer_id`. -### After Migration +To avoid charging twice for the same period, schedule the old subscription to cancel at the end of its period, and give the Polar checkout a trial that covers the days left, using `trial_interval: "day"` and `trial_interval_count`. Check this approach with Polar support for your case. -- [ ] Verify all subscriptions migrated -- [ ] Cancel old platform subscriptions -- [ ] Update documentation -- [ ] Remove old platform code -- [ ] Archive old platform data -- [ ] Celebrate! 🎉 +### License keys -## Troubleshooting +License keys can't be imported. Keep validating existing keys against the old provider during the transition; new purchases get Polar keys. -**Customer didn't receive migration email** -- Check spam folder -- Verify email address in export -- Resend with different subject line +## Communicating with customers -**Customer completed checkout but Stripe not canceled** -- Check webhook logs -- Verify external_id mapping -- Manually cancel if needed +- **Stripe migration:** no action is needed from customers. Polar adds a notice about the move to its first billing emails, such as the first renewal receipt. If you want customers to hear about it from you first, send a short heads-up before switching. +- **Other providers:** customers need to check out again. Tell them plainly that they'll enter their card on Polar, when they'll be charged, and that the old subscription ends at the end of its paid period. -**Double billing** -- Refund on old platform -- Apologize to customer -- Improve timing logic +## Testing -**Customer wants to stay on old platform** -- Respect their choice (if possible) -- Offer incentive to migrate -- Set final deadline +Run the migration in Polar's sandbox with a Stripe test-mode account first. Sandbox verifies the application side (customer mapping, `subscription.migrated` handling, access checks), but can't copy real cards. See `polar-testing` for sandbox setup and local webhook testing. diff --git a/skills/polar-testing/SKILL.md b/skills/polar-testing/SKILL.md index 911c9d6..60155f9 100644 --- a/skills/polar-testing/SKILL.md +++ b/skills/polar-testing/SKILL.md @@ -30,70 +30,41 @@ The sandbox is completely isolated from production. You need separate: ### SDK Configuration +Set up the client as in `polar-integration` ("Setup") and pass `environment: "sandbox"`: + ```typescript -// TypeScript -import { Polar } from "@polar-sh/sdk"; +import { createPolar } from "@polar-sh/sdk/2026-10"; -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "sandbox", // Switch to "production" for live +const polar = createPolar({ + accessToken: process.env.POLAR_ACCESS_TOKEN!, + environment: "sandbox", }); ``` ```python -# Python -from polar_sdk import Polar +import os -polar = Polar( - access_token=os.environ["POLAR_ACCESS_TOKEN"], - server="sandbox", -) -``` +from polar.v2026_10 import Polar -```go -// Go -s := polargo.New( - polargo.WithServer("sandbox"), - polargo.WithSecurity(os.Getenv("POLAR_ACCESS_TOKEN")), -) +polar = Polar(os.environ["POLAR_ACCESS_TOKEN"], environment="sandbox") ``` ### Sandbox Limitations -- Subscriptions auto-cancel after 90 days -- No real money is processed -- Data is isolated from production +- No real money is processed, and data is isolated from production +- Customer-facing emails (receipts, renewal reminders) are only delivered to members of your organization; `you+test@example.com` aliases are accepted +- The API allows 100 requests per minute, against 500 in production, which matters for large integration suites ## Test Cards -Polar uses Stripe for payment processing. Use these test card numbers: - -### Successful Payments - -| Card Number | Brand | CVC | Expiry | -|-------------|-------|-----|--------| -| 4242 4242 4242 4242 | Visa | Any 3 digits | Any future date | -| 5555 5555 5555 4444 | Mastercard | Any 3 digits | Any future date | -| 3782 822463 10005 | Amex | Any 4 digits | Any future date | -| 6011 1111 1111 1117 | Discover | Any 3 digits | Any future date | - -### Declined Payments - -| Card Number | Decline Reason | -|-------------|----------------| -| 4000 0000 0000 0002 | Generic decline | -| 4000 0000 0000 9995 | Insufficient funds | -| 4000 0000 0000 9987 | Lost card | -| 4000 0000 0000 9979 | Stolen card | -| 4000 0000 0000 0069 | Expired card | -| 4000 0000 0000 0127 | Incorrect CVC | - -### 3D Secure Testing +Polar uses Stripe for payment processing, so any of [Stripe's test cards](https://docs.stripe.com/testing#cards) work. Use a future expiry date and any CVC: | Card Number | Behavior | |-------------|----------| -| 4000 0027 6000 3184 | Requires 3DS authentication | -| 4000 0000 0000 3220 | Requires 3DS authentication | +| 4242 4242 4242 4242 | Successful payment | +| 4000 0000 0000 0002 | Generic decline | +| 4000 0000 0000 9995 | Insufficient funds | +| 4000 0027 6000 3184 | Requires 3D Secure authentication | ## Local Webhook Testing @@ -118,13 +89,15 @@ export POLAR_ACCESS_TOKEN=polar_oat_... # scopes: webhooks:read, webhooks:writ export POLAR_ENVIRONMENT=sandbox ``` +If `POLAR_ACCESS_TOKEN` is already exported in the shell, for example from the app's `.env`, the CLI uses it instead of the login. + ### Forward events to your app ```bash polar webhook listen 3000/api/polar/webhook ``` -`listen` takes a port, a `port/path`, or a full URL. It runs until stopped, so start it in its own terminal or as a background process and keep it running while you test. It forwards every webhook event for the organization, including the ones real sandbox activity produces, such as completing a test checkout. +`listen` takes a port, a `port/path`, or a full URL. It runs until stopped, so start it in its own terminal or as a background process and keep it running while you test. It forwards every webhook event for the organization, including the ones real sandbox activity produces, such as completing a test checkout. If the account has several organizations, pick one with `--org ` or `polar auth org`. ### Set the webhook secret @@ -158,52 +131,46 @@ polar webhook trigger order.paid --seed 7 # the same IDs on every ### Test Checkout Flow +Use an organization access token with the `products:write` and `checkouts:write` scopes. Organization tokens already imply the organization, so don't pass `organization_id`. + ```typescript import { describe, it, expect, beforeAll } from "vitest"; -import { Polar } from "@polar-sh/sdk"; +import { createPolar } from "@polar-sh/sdk/2026-10"; -describe("Polar Checkout", () => { - const polar = new Polar({ - accessToken: process.env.POLAR_SANDBOX_TOKEN!, - server: "sandbox", - }); +const polar = createPolar({ + accessToken: process.env.POLAR_ACCESS_TOKEN!, + environment: "sandbox", +}); +describe("Polar Checkout", () => { let testProductId: string; beforeAll(async () => { - // Create test product const product = await polar.products.create({ name: "Test Product", - organizationId: process.env.POLAR_ORG_ID!, - prices: [{ - type: "one_time", - amountType: "fixed", - priceAmount: 1000, - priceCurrency: "usd", - }], + prices: [{ amount_type: "fixed", price_amount: 1000, price_currency: "usd" }], }); testProductId = product.id; }); - it("should create checkout session", async () => { + it("creates a checkout session", async () => { const checkout = await polar.checkouts.create({ products: [testProductId], - successUrl: "http://localhost:3000/success", - customerEmail: "test@example.com", + success_url: "http://localhost:3000/success", + customer_email: "test@example.com", }); expect(checkout.status).toBe("open"); - expect(checkout.url).toBeDefined(); expect(checkout.url).toContain("sandbox"); }); - it("should retrieve checkout", async () => { + it("retrieves a checkout", async () => { const checkout = await polar.checkouts.create({ products: [testProductId], - successUrl: "http://localhost:3000/success", + success_url: "http://localhost:3000/success", }); - const retrieved = await polar.checkouts.get({ id: checkout.id }); + const retrieved = await polar.checkouts.get(checkout.id); expect(retrieved.id).toBe(checkout.id); }); }); @@ -211,15 +178,15 @@ describe("Polar Checkout", () => { ### Test Webhook Handler -Generate fixtures from real payloads instead of writing them by hand. `--json` prints the payload without sending it, so `listen` doesn't need to be running: +Generate fixtures from real payloads instead of writing them by hand. `--dry-run` prints the payload without sending it, so `listen` doesn't need to be running: ```bash mkdir -p test/fixtures -polar webhook trigger order.paid --json --seed 1 > test/fixtures/order.paid.json -polar webhook trigger subscription.created --json --seed 1 > test/fixtures/subscription.created.json +polar webhook trigger order.paid --dry-run --json --seed 1 > test/fixtures/order.paid.json +polar webhook trigger subscription.created --dry-run --json --seed 1 > test/fixtures/subscription.created.json ``` -Sign them the way Polar does ([Standard Webhooks](https://www.standardwebhooks.com/)): an HMAC-SHA256 of `id.timestamp.body`, base64-encoded and keyed with the secret string. This test calls the webhook route from `polar-integration` Recipe 3 directly: +Sign them the way `polar webhook listen` does ([Standard Webhooks](https://www.standardwebhooks.com/)): an HMAC-SHA256 of `id.timestamp.body`, base64-encoded and keyed with the secret string. This test calls the webhook route from `polar-integration` Recipe 3 directly: ```typescript import { createHmac } from "node:crypto"; @@ -256,7 +223,7 @@ describe("Polar webhook handler", () => { it("accepts a signed order.paid event", async () => { const body = await readFile("test/fixtures/order.paid.json", "utf8"); const response = await POST(webhookRequest(body)); - expect(response.status).toBe(200); + expect(response.status).toBe(202); }); it("rejects an invalid signature", async () => { @@ -269,27 +236,28 @@ describe("Polar webhook handler", () => { ### Test License Key Validation +Validation needs a token with `license_keys:write`. A valid key returns `status: "granted"`; an unknown key throws `ResourceNotFound`: + ```typescript -describe("License Keys", () => { - it("should validate license key", async () => { - // First create a customer with a license key benefit - // Then validate the key - const result = await polar.licenseKeys.validate({ - key: "TEST-XXXX-XXXX-XXXX", - organizationId: process.env.POLAR_ORG_ID!, - }); +import { describe, it, expect } from "vitest"; +import { createPolar, errors } from "@polar-sh/sdk/2026-10"; - expect(result.valid).toBe(true); - expect(result.customer).toBeDefined(); - }); +const polar = createPolar({ + accessToken: process.env.POLAR_ACCESS_TOKEN!, + environment: "sandbox", +}); +const organization_id = process.env.POLAR_ORG_ID!; - it("should reject invalid license key", async () => { - const result = await polar.licenseKeys.validate({ - key: "INVALID-KEY", - organizationId: process.env.POLAR_ORG_ID!, - }); +describe("License keys", () => { + it("validates a granted key", async () => { + const result = await polar.licenseKeys.validate({ key: process.env.TEST_LICENSE_KEY!, organization_id }); + expect(result.status).toBe("granted"); + }); - expect(result.valid).toBe(false); + it("rejects an unknown key", async () => { + await expect( + polar.licenseKeys.validate({ key: "INVALID-KEY", organization_id }), + ).rejects.toBeInstanceOf(errors.ResourceNotFound); }); }); ``` @@ -298,42 +266,40 @@ describe("License Keys", () => { ### Mock SDK +Mock `createPolar` and keep the real `webhooks` and `errors` exports. Responses use the same `snake_case` fields as the API: + ```typescript import { vi } from "vitest"; -// Mock the entire SDK -vi.mock("@polar-sh/sdk", () => ({ - Polar: vi.fn().mockImplementation(() => ({ +vi.mock("@polar-sh/sdk/2026-10", async (importOriginal) => ({ + ...(await importOriginal()), + createPolar: () => ({ checkouts: { create: vi.fn().mockResolvedValue({ id: "checkout_mock", url: "https://sandbox.polar.sh/checkout/mock", status: "open", }), - get: vi.fn().mockResolvedValue({ - id: "checkout_mock", - status: "succeeded", - }), + get: vi.fn().mockResolvedValue({ id: "checkout_mock", status: "succeeded" }), }, customers: { - getState: vi.fn().mockResolvedValue({ - activeSubscriptions: [ - { id: "sub_mock", status: "active", productId: "prod_mock" }, - ], - grantedBenefits: [], + getStateExternal: vi.fn().mockResolvedValue({ + active_subscriptions: [{ id: "sub_mock", status: "active", product_id: "prod_mock" }], + granted_benefits: [], + active_meters: [], }), }, subscriptions: { - list: vi.fn().mockResolvedValue([]), - cancel: vi.fn().mockResolvedValue({}), + revoke: vi.fn(), + update: vi.fn(), }, - })), + }), })); ``` ### Mock Webhook Payloads -Load the fixtures generated with `polar webhook trigger --json` (see Test Webhook Handler). Hand-written payloads drift from the real shape, and `validateEvent` rejects them. +Load the fixtures generated with `polar webhook trigger --dry-run --json` (see Test Webhook Handler). Hand-written payloads drift from the real shape, and `validateEvent` only checks the signature and event type, so the drift goes unnoticed. ```typescript import orderPaid from "./fixtures/order.paid.json"; @@ -355,16 +321,14 @@ jobs: env: POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_SANDBOX_TOKEN }} - POLAR_WEBHOOK_SECRET: ${{ secrets.POLAR_SANDBOX_WEBHOOK_SECRET }} POLAR_ORG_ID: ${{ secrets.POLAR_SANDBOX_ORG_ID }} - POLAR_SERVER: sandbox steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: "20" + node-version: "22" cache: "npm" - run: npm ci @@ -376,14 +340,16 @@ jobs: run: npm run test:integration ``` +If CI also runs the Polar CLI, add `POLAR_ENVIRONMENT: sandbox`. + ### Environment Setup Create sandbox credentials specifically for CI: 1. Create a dedicated sandbox organization for CI -2. Generate a CI-specific access token +2. Create an organization access token for it with only the scopes the tests need (`polar auth token --scope products:write --scope checkouts:write` opens the token page with them preselected) 3. Commit the webhook fixtures; handler tests sign them with a test secret and need no webhook endpoint -4. Store credentials in GitHub Secrets +4. Store the token and organization ID in GitHub Secrets ## Debugging Tips @@ -409,24 +375,28 @@ For a deployed endpoint: **Checkout not completing** - Use test cards, not real cards - Check browser console for errors -- Verify successUrl is correct +- Verify `success_url` is correct **API returns 401** -- Verify you're using sandbox token with sandbox API -- Check token hasn't expired -- Ensure token has required scopes +- Verify you're using a sandbox token with the sandbox API +- Check the token hasn't expired or been revoked -### Enable Debug Logging +**API returns 403** +- The token is missing a scope the endpoint needs -```typescript -const polar = new Polar({ - accessToken: process.env.POLAR_ACCESS_TOKEN, - server: "sandbox", - // Enable debug mode if available -}); +### Inspect API Errors + +The SDK throws typed errors with the HTTP status and response body: -// Log all requests -polar.checkouts.create({...}).then(console.log).catch(console.error); +```typescript +import { PolarClientError } from "@polar-sh/sdk"; + +try { + await polar.checkouts.create({ products: [productId], success_url: "http://localhost:3000/success" }); +} catch (error) { + if (error instanceof PolarClientError) console.error(error.statusCode, error.error); + throw error; +} ``` ## Test Checklist diff --git a/skills/setup-polar/SKILL.md b/skills/setup-polar/SKILL.md index 7dacee0..f1061e5 100644 --- a/skills/setup-polar/SKILL.md +++ b/skills/setup-polar/SKILL.md @@ -14,7 +14,7 @@ Follow these steps in order: ### Step 1: Check MCP Availability -First, check if the Polar MCP server is available. Try listing products or organizations using MCP tools. +First, check if the Polar MCP server is available by listing organizations: call `execute` with `GET /v1/organizations/`, or `organizations_list` where the server exposes one tool per operation. **If MCP tools work:** Skip to Step 3. @@ -46,9 +46,7 @@ claude mcp add --transport http polar-sandbox https://mcp.polar.sh/mcp/polar-san } ``` -After installation, the user must: -1. Restart their editor/CLI -2. Authenticate when prompted (redirects to polar.sh) +For other clients, follow the [Polar MCP guide](https://polar.sh/docs/integrate/mcp). After installing, the user restarts their editor or CLI and signs in when prompted (in Claude Code, run `/mcp` and authenticate). The sandbox server signs in at sandbox.polar.sh and the production server at polar.sh. Once authenticated, verify MCP works by listing organizations. @@ -57,11 +55,8 @@ Once authenticated, verify MCP works by listing organizations. Ask the user: 1. **Framework**: What framework are you using? - - Next.js (App Router) - - Next.js (Pages Router) - - Express.js - - FastAPI (Python) - - Other + - Next.js (App Router), Express, Hono, Astro, SvelteKit, Remix, TanStack Start, Nuxt, Fastify, Cloudflare Workers, or another TypeScript/JavaScript framework + - Python: there's no integration skill; follow the [Python SDK docs](https://polar.sh/docs/integrate/sdk/python) 2. **Environment**: Start with sandbox or production? - Sandbox (recommended for new setups) @@ -72,22 +67,28 @@ Ask the user: - One-time purchase - Both -### Step 4: Create Organization (if needed) +From here on, use the `polar-sandbox` server's tools for sandbox and the `polar` server's tools for production. If only `polar` is available (as in ChatGPT), create sandbox products in the sandbox dashboard or with the Polar CLI instead. -Use MCP to check if user has an organization. If not, guide them to create one at: +### Step 4: Choose the Organization + +List the organizations with MCP. If there are several, ask which one to use. Note its `id`, `slug` and `default_presentment_currency`. Signing in to the MCP server already asks a user without an organization to create one; without MCP, they can create one at: - Sandbox: https://sandbox.polar.sh/start - Production: https://polar.sh/start ### Step 5: Create First Product -Use MCP to create a test product. Example for subscription: +Create the product with MCP (`POST /v1/products/`). The price must be in the organization's `default_presentment_currency`, and the billing interval is fixed per product. Example for a monthly subscription: +```json +{ + "organization_id": "", + "name": "Pro Plan", + "recurring_interval": "month", + "prices": [{ "amount_type": "fixed", "price_amount": 2900, "price_currency": "" }] +} ``` -Create a product with: -- Name: "Pro Plan" -- Monthly price: $29 -- Organization: [user's org] -``` + +Omit `recurring_interval` for a one-time purchase. "Both", or monthly and yearly billing, means separate products. Show the user the server, organization, name, price, currency and interval, and create the product once they confirm. Store the returned product ID for checkout integration. @@ -98,40 +99,35 @@ Hand off to the `polar-integration` skill to generate the Checkout, Customer Por Before invoking it, make sure the user has these environment variables ready (use whatever loader their framework provides): ```bash -POLAR_ACCESS_TOKEN= # from polar.sh/settings (or sandbox) -POLAR_WEBHOOK_SECRET= # created in Step 7 -POLAR_SERVER=sandbox # omit or set to "production" when going live +POLAR_ACCESS_TOKEN= # organization access token: Settings → Developers → New Token, in the sandbox or production dashboard +POLAR_WEBHOOK_SECRET= # created in Step 7 +POLAR_ENVIRONMENT=sandbox # omit or set to "production" when going live ``` Pass along the product ID from Step 5 so checkout links can be tested in Step 8. ### Step 7: Set Up Webhooks -Use the Polar CLI to forward webhooks to the local dev server. It needs no tunnel and no dashboard endpoint. The `polar-testing` skill ("Local Webhook Testing") has the details. +Start the dev server, then follow `polar-testing` ("Local Webhook Testing") to forward webhooks to it with the Polar CLI. It needs no tunnel and no dashboard endpoint: -1. Install the CLI (`npm install -g @polar-sh/cli`) and ask the user to run `polar auth login --sandbox`, which opens a browser. -2. Save the local signing secret without printing it, then restart the dev server: - ```bash - secret=$(polar webhook listen --print-secret) && echo "POLAR_WEBHOOK_SECRET=$secret" >> .env - ``` -3. Start `polar webhook listen /` in the background, using the path from the route generated in Step 6, and keep it running. -4. Run `polar webhook trigger order.paid`. It prints the handler's response and exits with status 1 when the handler fails. Fix the handler and repeat until it exits with 0. +1. Install the CLI and ask the user to run `polar auth login --sandbox`, which opens a browser. +2. Save the local signing secret to `.env` with `polar webhook listen --print-secret`, then restart the dev server. +3. Start `polar webhook listen /` in the background, using the path from the route generated in Step 6. Pass `--org ` if the account has several organizations. +4. Run `polar webhook trigger order.paid`. Success is exit 0 with a `Response 2xx` line. On a failure it prints the handler's response and exits with status 1; fix the handler and repeat. If it only says the event was forwarded by your `polar webhook listen` terminal, check the `listen` output. -When deploying, create a webhook endpoint for the public URL with MCP or in the dashboard (Settings → Webhooks), select `order.paid`, `subscription.created` and `subscription.canceled`, and use that endpoint's secret as `POLAR_WEBHOOK_SECRET` in the deployed environment. +When deploying, create a webhook endpoint for the public URL in the dashboard (Settings → Webhooks) or with MCP (`url`, `format: "raw"` and the `events`: `order.paid`, `subscription.created` and `subscription.canceled`). Use that endpoint's secret as `POLAR_WEBHOOK_SECRET` in the deployed environment. The MCP response contains the secret, so tell the user where to copy it rather than repeating it. ### Step 8: Test the Integration -1. Start the dev server. -2. Visit the checkout URL with the product ID from Step 5 (path and query shape come from the route generated in Step 6). -3. Complete checkout using test card: `4242 4242 4242 4242`. -4. Verify that `polar webhook listen` shows `order.paid` with a 2xx status from the handler. -5. Check the success page displays correctly. +1. Visit the checkout URL with the product ID from Step 5 (path and query shape come from the route generated in Step 6). +2. Complete checkout using test card: `4242 4242 4242 4242`. +3. Verify that `polar webhook listen` shows `order.paid` with a 2xx status from the handler. +4. Check the success page displays correctly. ### Step 9: Next Steps Once basic setup works, suggest: -1. **Add customer portal** - Let users manage subscriptions (Recipe 2 in `polar-integration`). -2. **Link to your auth** - Pass `externalCustomerId` in checkout. -3. **Add benefits** - License keys, file downloads, Discord roles. -4. **Go production** - Switch `POLAR_SERVER` from `sandbox` to `production`. +1. **Link to your auth** - Pass `external_customer_id` from the logged-in user when creating checkouts. +2. **Add benefits** - License keys, file downloads, Discord roles. +3. **Go production** - Production is separate from sandbox: set up the organization at polar.sh, re-create the product (it gets a new ID), create a production access token and webhook endpoint, and set `POLAR_ENVIRONMENT=production`. Then submit the organization for review in the dashboard; checkouts are blocked until it can accept payments.