Skill instructions
polar-integration18.1 KB
View saved version →
---
name: polar-integration
description: Add Polar billing to a TypeScript/JavaScript app using the @polar-sh/sdk package. Use this skill whenever the user wants to add a Checkout endpoint, a Customer Portal endpoint, or a Webhooks endpoint for Polar to any framework — Next.js, Express, Hono, Astro, SvelteKit, Remix, TanStack Start, Nuxt, Fastify, Elysia, Deno, Supabase Edge Functions, Cloudflare Workers, Bun, etc.
---
# Polar SDK integration
A standalone guide to wiring up Polar's three core HTTP endpoints — Checkout, Customer Portal, and Webhooks — using `@polar-sh/sdk` directly. The recipes are framework-agnostic Web Standards (`Request`/`Response`); each section also lists the small idiomatic adjustments per framework.
## Setup
Install the SDK. Version 1.0 and later needs Node.js 22 or newer:
```bash
npm install @polar-sh/sdk
# or pnpm / yarn / bun
```
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/<framework>` 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. Create it with the Polar CLI (`polar auth token --scope checkouts:write --scope customer_sessions:write`, which opens a browser and prints the token, so ask the user to run it and save the output to the env file) or in the Polar dashboard (Settings → Access Tokens).
- `POLAR_WEBHOOK_SECRET` — only needed for the webhook recipe.
- `POLAR_ENVIRONMENT` — `sandbox` while developing; omit it or set `production` in production. Sandbox and production are separate, so each needs its own token.
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 { 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");
export const polar = createPolar({
accessToken,
environment: (process.env.POLAR_ENVIRONMENT as Environment | undefined) || "production",
});
```
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`.
---
## Recipe 1 — Checkout endpoint
**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: <https://polar.sh/docs/api-reference/current/checkouts/create-checkout-session>.
### `polar.checkouts.create()` field reference
| 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
- **`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)
```ts
import { polar } from "./polar";
const SUCCESS_URL = "https://example.com/success?checkout_id={CHECKOUT_ID}";
const THEME: "light" | "dark" | undefined = undefined;
export async function handleCheckout(request: Request): Promise<Response> {
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 });
}
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 checkout = await polar.checkouts.create({
products,
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(checkout.url);
if (THEME) redirectUrl.searchParams.set("theme", THEME);
return Response.redirect(redirectUrl.toString(), 302);
} catch (error) {
console.error(error);
return new Response(null, { status: 500 });
}
}
```
This handler accepts a GET request with `?products=<product_id>`. 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 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()) ...
});
```
---
## Recipe 2 — Customer Portal endpoint
**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: <https://polar.sh/docs/api-reference/current/customer-sessions/create-customer-session>.
### `polar.customerSessions.create()` field reference
The request body is a union — pass **either** `customer_id` **or** `external_customer_id`, not both.
| 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";
const RETURN_URL = "https://example.com/account";
export async function handlePortal(request: Request): Promise<Response> {
const externalCustomerId = await getUserId(request); // your auth helper
if (!externalCustomerId) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const session = await polar.customerSessions.create({
external_customer_id: externalCustomerId,
return_url: RETURN_URL,
});
return Response.redirect(session.customer_portal_url, 302);
} catch (error) {
console.error(error);
return new Response(null, { status: 500 });
}
}
```
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
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.).
---
## Recipe 3 — Webhooks endpoint
**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` 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 { webhooks } from "@polar-sh/sdk/2026-10";
const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) throw new Error("POLAR_WEBHOOK_SECRET is required");
export async function handleWebhook(request: Request): Promise<Response> {
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: webhooks.WebhookPayload;
try {
event = await webhooks.validateEvent(body, headers, WEBHOOK_SECRET!);
} catch (error) {
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 "order.paid":
// event.data is the Order — fulfill, send receipt, etc.
break;
case "subscription.active":
case "subscription.canceled":
case "subscription.uncanceled":
case "subscription.revoked":
// event.data is the Subscription — flip entitlements in your DB
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.state_changed":
// event.data is the full customer state — reconcile access from granted_benefits
break;
}
return Response.json({ received: true }, { status: 202 });
}
```
`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), 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
With the dev server running, forward events with the Polar CLI and send one (setup in `polar-testing`, "Local Webhook Testing"):
```bash
polar webhook listen 3000/api/polar/webhook # keep it running in the background
polar webhook trigger order.paid # exits 1 and prints the response when the handler fails
```
Locally, `POLAR_WEBHOOK_SECRET` is the secret from `polar webhook listen --print-secret`.
### Framework variations
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.
polar-migration8.45 KB
View saved version →
---
name: polar-migration
description: |
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
[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.
## Pick the path
- **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.
## Rules for every migration
- 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.
## From Stripe
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.
### 1. Move new sales to Polar
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.
During coexistence, access checks must accept a valid entitlement from either provider, and cancellations or plan changes go to whichever system bills that subscription.
### 2. Map imported customers
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.
```ts
import { polar } from "./polar"; // client from polar-integration
const customer = await polar.customers.get(polarCustomerId);
if (customer.external_id === null) {
await polar.customers.update(polarCustomerId, { external_id: appCustomerId });
}
```
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.
### 3. Prepare for the switch
Polar stops each switched subscription on Stripe by canceling it immediately, without proration or a final invoice. Before switching:
- 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:
```ts
if (subscription.cancellation_details?.comment?.startsWith("Migrated to Polar")) {
return; // billing moved to Polar; reconcile through subscription.migrated instead
}
```
- 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:
```ts
case "subscription.migrated":
await recordMigratedSubscription({
stripeSubscriptionId: event.provider_subscription_id,
polarSubscriptionId: event.data.id,
});
break;
```
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`.
### 4. Switch, reconcile and clean up
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.
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.
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.
## Other providers
### Recreate the catalog
The billing interval belongs to the product, and a product has one fixed price per currency. Monthly and yearly billing are two products:
```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" }],
});
```
Recreate benefits (license keys, file downloads, Discord roles and others) and attach them with `polar.products.updateBenefits(productId, { benefits: [benefitId] })`.
### Move customers
Create customers ahead of time if you want them in Polar before they check out:
```ts
await polar.customers.create({ external_id: appCustomerId, email, name });
```
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`.
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.
### License keys
License keys can't be imported. Keep validating existing keys against the old provider during the transition; new purchases get Polar keys.
## Communicating with customers
- **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.
## Testing
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.
polar-testing13.9 KB
View saved version →
---
name: polar-testing
description: |
Guide for testing Polar payment integrations using the sandbox environment. Use this skill when: (1) Setting up the Polar sandbox for development; (2) Testing checkout flows without real payments; (3) Using Stripe test cards with Polar; (4) Writing integration tests for payment flows; (5) Testing webhooks locally with the Polar CLI (`polar webhook listen`, `polar webhook trigger`) and generating webhook fixtures; (6) Mocking Polar in unit tests; (7) Setting up CI/CD pipelines with Polar sandbox; (8) Debugging payment issues in sandbox.
---
# Polar Testing Guide
Test Polar integrations safely using the sandbox environment - a fully isolated server where you can experiment without affecting production data or processing real payments.
## Sandbox Environment
### Access
- **Dashboard**: https://sandbox.polar.sh
- **API**: https://sandbox-api.polar.sh/v1
The sandbox is completely isolated from production. You need separate:
- User account
- Organization
- Access tokens
- Webhook endpoints
### Setup Steps
1. Go to https://sandbox.polar.sh/start
2. Create a new account (or use "Go to sandbox" from org switcher)
3. Create a test organization
4. Generate an access token in Settings → Access Tokens, or with `polar auth token` once the CLI is signed in to the sandbox (see "Install and sign in" below)
### SDK Configuration
Set up the client as in `polar-integration` ("Setup") and pass `environment: "sandbox"`:
```typescript
import { createPolar } from "@polar-sh/sdk/2026-10";
const polar = createPolar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
environment: "sandbox",
});
```
```python
import os
from polar.v2026_10 import Polar
polar = Polar(os.environ["POLAR_ACCESS_TOKEN"], environment="sandbox")
```
### Sandbox Limitations
- 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, 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 |
|-------------|----------|
| 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
Use the Polar CLI. It forwards your organization's webhook events straight to your local server: no tunnel, no public URL, and no webhook endpoint in the dashboard.
### Install and sign in
```bash
npm install -g @polar-sh/cli
polar auth login --sandbox
```
For other install methods, see the [CLI docs](https://polar.sh/docs/integrate/cli/introduction).
`polar auth login` opens a browser, so ask the user to run it. Check the result with `polar auth whoami --json`: `environments` lists the signed-in environments and `organization` is the active one.
Where no browser is available (CI, remote machines, cloud agents), an organization access token replaces `auth login`:
```bash
export POLAR_ACCESS_TOKEN=polar_oat_... # scopes: webhooks:read, webhooks:write, organizations:read
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. If the account has several organizations, pick one with `--org <id>` or `polar auth org`.
### Set the webhook secret
`listen` signs forwarded events with a local secret, not a dashboard endpoint's secret. Write it to `.env` without printing it:
```bash
secret=$(polar webhook listen --print-secret) && echo "POLAR_WEBHOOK_SECRET=$secret" >> .env
```
If `.env` already has a `POLAR_WEBHOOK_SECRET` line, replace it instead of appending, then restart the dev server. Deployed environments use the secret of their dashboard webhook endpoint.
### Send test events
```bash
polar webhook trigger order.paid
```
`trigger` sends a sample event through the running `listen`. When your handler doesn't accept it (a non-2xx response, a connection error, or a redirect), it prints the handler's response body and exits with status 1. Read that output, fix the handler, and run the same command again until it exits with 0.
A redirect usually means auth middleware is intercepting the webhook route. Exclude the route from it: Polar never follows redirects when delivering webhooks.
```bash
polar webhook trigger --list --json # every event you can send
polar webhook trigger order.paid --override data.customer.email=jane@example.com
polar webhook trigger order.paid --seed 7 # the same IDs on every run
```
`--override` takes `path=value` and can be repeated. The API rejects paths that don't exist in the payload.
## Integration Testing
### 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 { createPolar } from "@polar-sh/sdk/2026-10";
const polar = createPolar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
environment: "sandbox",
});
describe("Polar Checkout", () => {
let testProductId: string;
beforeAll(async () => {
const product = await polar.products.create({
name: "Test Product",
prices: [{ amount_type: "fixed", price_amount: 1000, price_currency: "usd" }],
});
testProductId = product.id;
});
it("creates a checkout session", async () => {
const checkout = await polar.checkouts.create({
products: [testProductId],
success_url: "http://localhost:3000/success",
customer_email: "test@example.com",
});
expect(checkout.status).toBe("open");
expect(checkout.url).toContain("sandbox");
});
it("retrieves a checkout", async () => {
const checkout = await polar.checkouts.create({
products: [testProductId],
success_url: "http://localhost:3000/success",
});
const retrieved = await polar.checkouts.get(checkout.id);
expect(retrieved.id).toBe(checkout.id);
});
});
```
### Test Webhook Handler
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 --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 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";
import { readFile } from "node:fs/promises";
import { beforeAll, describe, expect, it, vi } from "vitest";
const secret = "test_webhook_secret";
let POST: (request: Request) => Promise<Response>;
beforeAll(async () => {
vi.stubEnv("POLAR_WEBHOOK_SECRET", secret);
({ POST } = await import("../app/api/polar/webhook/route"));
});
const sign = (id: string, timestamp: number, body: string) =>
`v1,${createHmac("sha256", secret).update(`${id}.${timestamp}.${body}`).digest("base64")}`;
const webhookRequest = (body: string, signature?: string) => {
const id = "msg_test";
const timestamp = Math.floor(Date.now() / 1000);
return new Request("http://localhost/api/polar/webhook", {
method: "POST",
headers: {
"content-type": "application/json",
"webhook-id": id,
"webhook-timestamp": String(timestamp),
"webhook-signature": signature ?? sign(id, timestamp, body),
},
body,
});
};
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(202);
});
it("rejects an invalid signature", async () => {
const body = await readFile("test/fixtures/order.paid.json", "utf8");
const response = await POST(webhookRequest(body, "v1,invalid"));
expect(response.status).toBe(403);
});
});
```
### Test License Key Validation
Validation needs a token with `license_keys:write`. A valid key returns `status: "granted"`; an unknown key throws `ResourceNotFound`:
```typescript
import { describe, it, expect } from "vitest";
import { createPolar, errors } from "@polar-sh/sdk/2026-10";
const polar = createPolar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
environment: "sandbox",
});
const organization_id = 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");
});
it("rejects an unknown key", async () => {
await expect(
polar.licenseKeys.validate({ key: "INVALID-KEY", organization_id }),
).rejects.toBeInstanceOf(errors.ResourceNotFound);
});
});
```
## Mocking Polar in Unit Tests
### 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";
vi.mock("@polar-sh/sdk/2026-10", async (importOriginal) => ({
...(await importOriginal<typeof import("@polar-sh/sdk/2026-10")>()),
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" }),
},
customers: {
getStateExternal: vi.fn().mockResolvedValue({
active_subscriptions: [{ id: "sub_mock", status: "active", product_id: "prod_mock" }],
granted_benefits: [],
active_meters: [],
}),
},
subscriptions: {
revoke: vi.fn(),
update: vi.fn(),
},
}),
}));
```
### Mock Webhook Payloads
Load the fixtures generated with `polar webhook trigger <event> --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";
import subscriptionCreated from "./fixtures/subscription.created.json";
```
## CI/CD Integration
### GitHub Actions
```yaml
name: Test Polar Integration
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
env:
POLAR_ACCESS_TOKEN: ${{ secrets.POLAR_SANDBOX_TOKEN }}
POLAR_ORG_ID: ${{ secrets.POLAR_SANDBOX_ORG_ID }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "npm"
- run: npm ci
- name: Run unit tests
run: npm run test:unit
- name: Run integration tests
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. 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 the token and organization ID in GitHub Secrets
## Debugging Tips
### Check Webhook Delivery
Locally, `polar webhook listen` logs every forwarded event with your handler's status, and `polar webhook trigger` prints the handler's response body when it fails.
For a deployed endpoint:
1. Go to sandbox.polar.sh → Settings → Webhooks
2. Click on your endpoint
3. View delivery history and payloads
4. Check response codes and errors
### Common Issues
**Webhook signature mismatch**
- Ensure you're using the sandbox webhook secret
- Locally, use the secret from `polar webhook listen --print-secret` and restart the dev server after changing `.env`
- Check that the raw body is being passed (not parsed JSON)
- Verify timestamp is within tolerance (5 minutes)
**Checkout not completing**
- Use test cards, not real cards
- Check browser console for errors
- Verify `success_url` is correct
**API returns 401**
- Verify you're using a sandbox token with the sandbox API
- Check the token hasn't expired or been revoked
**API returns 403**
- The token is missing a scope the endpoint needs
### Inspect API Errors
The SDK throws typed errors with the HTTP status and response body:
```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
Before going to production:
- [ ] Checkout flow completes successfully
- [ ] Webhooks received and processed
- [ ] Subscription lifecycle works (create, cancel, revoke)
- [ ] Benefits granted and revoked correctly
- [ ] License key validation works
- [ ] Error handling for declined payments
- [ ] Customer portal accessible
- [ ] Refund flow tested
setup-polar7.88 KB
View saved version →
---
name: setup-polar
description: |
Interactive onboarding wizard to set up Polar payment integration from scratch. Use this skill when: (1) User wants to "set up Polar" or "integrate Polar" in their project; (2) User is starting fresh with Polar and needs guided setup; (3) User asks "how do I get started with Polar"; (4) User wants to add payments/subscriptions/checkout to their app using Polar; (5) User needs help creating their first Polar product and checkout. This skill walks through MCP installation, authentication, product creation, and generates framework-specific integration code.
---
# Polar Setup Wizard
Interactive setup guide to get Polar payments working in your project.
## Setup Flow
Follow these steps in order:
### Step 1: Check MCP Availability
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.
**If MCP not available:** Continue to Step 2.
### Step 2: Install Polar MCP
**Polar plugin installed:** the plugin already configures the Polar server (`polar`, plus `polar-sandbox` where available). Ask the user to sign in to it, preferring `polar-sandbox`: in Claude Code with `/mcp`, in Codex with `codex mcp login <server>`, in Cursor or Antigravity from their MCP settings, in Gemini CLI with `/mcp auth <server>`, and in ChatGPT when prompted to connect. Then continue with the verification below.
Otherwise, guide the user to install the Polar MCP server based on their environment:
**Claude Code:**
```bash
# Production
claude mcp add --transport http polar https://mcp.polar.sh/mcp/polar-mcp
# Sandbox (recommended for setup)
claude mcp add --transport http polar-sandbox https://mcp.polar.sh/mcp/polar-sandbox
```
**Cursor** (`.cursor/mcp.json`):
```json
{
"mcpServers": {
"polar-sandbox": {
"url": "https://mcp.polar.sh/mcp/polar-sandbox"
}
}
}
```
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.
### Step 3: Gather Project Context
Ask the user:
1. **Framework**: What framework are you using?
- 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)
- Production
3. **Product type**: What are you selling?
- Subscription (recurring)
- One-time purchase
- Both
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.
### 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
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": "<organization id from Step 4>",
"name": "Pro Plan",
"recurring_interval": "month",
"prices": [{ "amount_type": "fixed", "price_amount": 2900, "price_currency": "<default_presentment_currency>" }]
}
```
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.
### Step 6: Generate Integration Code
Hand off to the `polar-integration` skill to generate the Checkout, Customer Portal, and Webhooks endpoints for the framework selected in Step 3. That skill has the canonical, framework-agnostic recipes plus per-framework variations.
Before invoking it, make sure the user has these environment variables ready (use whatever loader their framework provides):
```bash
POLAR_ACCESS_TOKEN= # organization access token, created below
POLAR_WEBHOOK_SECRET= # created in Step 7
POLAR_ENVIRONMENT=sandbox # omit or set to "production" when going live
```
The quickest way to create the access token is the Polar CLI. Install it with `npm install -g @polar-sh/cli`, then ask the user to run:
```bash
polar auth login --sandbox
token=$(polar auth token --scope checkouts:write --scope customer_sessions:write) && echo "POLAR_ACCESS_TOKEN=$token" >> .env
```
`polar auth token` opens the token page in a browser with those scopes preselected, waits for the user to create the token, and prints it, so this saves it to `.env` without showing it. The token belongs to the active organization and its environment: `polar auth whoami --json` shows which one that is, and `polar auth org` changes it.
Without the CLI, create the token in the sandbox or production dashboard: Settings → Access Tokens → Create token.
Pass along the product ID from Step 5 so checkout links can be tested in Step 8.
### Step 7: Set Up Webhooks
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. If you skipped it in Step 6, 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 <port>/<webhook-path>` in the background, using the path from the route generated in Step 6. Pass `--org <id from Step 4>` 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 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. 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. **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.