Update to Polar
Snapshot Nov 3, 2026 · 19:24 UTC · version 0.2.2
Collection source: downloaded plugin package.
Payment or plan references changed
Skill instructions — Now says: SDK. Version 1.0 and later needs Node.js 22 or newer: If the project already uses `@polar-sh/sdk` below 1.0 (signs: `new Polar(...)`, camelCase fields such...
Observed in published text. Live prices and checkout terms have not been verified by this change.
Up to 10 plugins free · no card required.
All fields and original values.
Skill instructions
Nov 3, 2026 · 00:14 UTC
SDK: Don't install `@polar-sh/<framework>` 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. - `POLAR_ACCESS_TOKEN` — organization ac...
Nov 3, 2026 · 19:24 UTC
SDK. Version 1.0 and later needs Node.js 22 or newer: 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 foll...
Compare saved observations
Download comparison JSONSKILL.md text changes
− removed · + added
--- before +++ after @@ -9,33 +9,44 @@ ## 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/<framework>` 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/<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 from the Polar dashboard. +- `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_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 @@ **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/checkouts/create-session>. +**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 -The SDK uses `camelCase`; the underlying HTTP API uses `snake_case`. Pick whichever you see in the user's existing code. +| 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). | -| 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`. +**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<Response> { +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 }, - ); + 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 @@ } ``` -This handler accepts a GET request with `?products=<product_id>` 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=<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 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)`. +The body of the handler is identical — only the entry point changes: -**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 }) => { ... }`. +- **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 @@ **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/customer-portal/sessions/create>. +**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** `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<string | null> { - const session = await getSession(request); // your auth helper - return session?.userId ?? null; -} - -export async function GET(request: Request): Promise<Response> { - const externalCustomerId = await getExternalCustomerId(request); +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 { 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 @@ } ``` -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 @@ **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<Response> { +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: ReturnType<typeof validateEvent>; + 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 @@ // 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 @@ ### 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. +Use the same entry points as Recipe 1 with `POST` (Remix: `action`), plus: -**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/<framework>` 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. +- **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.
Full technical diff · 1 changed fields
changed /skill_md_contents
"---\nname: polar-integration\ndescription: 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.\n---\n\n# Polar SDK integration\n\nA 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.\n\n## Setup\n\nInstall the SDK:\n\n```bash\nnpm install @polar-sh/sdk\n# or pnpm / yarn / bun\n```\n\nDon't install `@polar-sh/<framework>` 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.\n\nRequired environment variables (use whatever loader your framework provides — `process.env`, `Deno.env`, `import.meta.env`, etc.):\n\n- `POLAR_ACCESS_TOKEN` — organization access token from the Polar dashboard.\n- `POLAR_WEBHOOK_SECRET` — only needed for the webhook recipe.\n- `POLAR_SERVER` — set to `sandbox` while developing, omit (or `production`) in prod.\n\nConstruct one client and reuse it:\n\n```ts\nimport { Polar } from \"@polar-sh/sdk\";\n\nconst polar = new Polar({\n accessToken: process.env.POLAR_ACCESS_TOKEN,\n server: (process.env.POLAR_SERVER as \"sandbox\" | \"production\") ?? \"production\",\n});\n```\n\nThe three recipes below are independent — implement only the ones the user asks for.\n\n---\n\n## Recipe 1 — Checkout endpoint\n\n**Goal:** an HTTP endpoint that creates a Polar checkout session and 302-redirects the browser to the hosted checkout page.\n\n**Underlying API:** `POST /v1/checkouts/` (SDK: `polar.checkouts.create(...)`). Full schema: <https://polar.sh/docs/api-reference/checkouts/create-session>.\n\n### `polar.checkouts.create()` field reference\n\nThe SDK uses `camelCase`; the underlying HTTP API uses `snake_case`. Pick whichever you see in the user's existing code.\n\n| SDK field (camelCase) | API field (snake_case) | Required | Notes |\n|---|---|---|---|\n| `products` | `products` | yes | Array of product UUIDs. The first is selected by default. |\n| `customerId` | `customer_id` | no | Existing Polar customer ID. |\n| `externalCustomerId` | `external_customer_id` | no | Your own user ID. If no Polar customer matches, one is created with this external ID. |\n| `customerEmail` | `customer_email` | no | |\n| `customerName` | `customer_name` | no | |\n| `customerBillingName` | `customer_billing_name` | no | |\n| `customerBillingAddress` | `customer_billing_address` | no | `{ country: \"US\", line1, line2, city, state, postal_code }`. `country` (ISO alpha-2) is required if address is set. |\n| `customerTaxId` | `customer_tax_id` | no | |\n| `customerIpAddress` | `customer_ip_address` | no | |\n| `customerMetadata` | `customer_metadata` | no | Copied to the created customer. |\n| `isBusinessCustomer` | `is_business_customer` | no | Default `false`. If `true`, full billing address + name required. |\n| `metadata` | `metadata` | no | Copied to the resulting order/subscription. |\n| `customFieldData` | `custom_field_data` | no | Map of custom field slug → value. |\n| `discountId` | `discount_id` | no | |\n| `allowDiscountCodes` | `allow_discount_codes` | no | Default `true`. |\n| `requireBillingAddress` | `require_billing_address` | no | Default `false`. US customers always required regardless. |\n| `seats` | `seats` | no | Predefined number of seats (seat-based pricing only). |\n| `minSeats` / `maxSeats` | `min_seats` / `max_seats` | no | Seat-based pricing only. |\n| `amount` | `amount` | no | Cents. Only used for `custom` (pay-what-you-want) prices. |\n| `subscriptionId` | `subscription_id` | no | Upgrade an existing free subscription. |\n| `successUrl` | `success_url` | no | See below. |\n| `returnUrl` | `return_url` | no | Back-button URL on the checkout page. |\n| `embedOrigin` | `embed_origin` | no | Set when embedding the checkout in an iframe. |\n| `locale` | `locale` | no | IETF BCP 47 (`en`, `en-US`, `fr-CA`, ...). Defaults to `en-US`. |\n| `currency` | `currency` | no | ISO 4217 (e.g. `usd`, `eur`). |\n| `trialInterval` / `trialIntervalCount` | `trial_interval` / `trial_interval_count` | no | Override product trial. |\n| `allowTrial` | `allow_trial` | no | Default `true`. Set `false` to disable an otherwise-configured trial. |\n| `prices` | `prices` | no | Map of product ID → ad-hoc prices (override catalog prices). |\n\n**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`.\n\n### Notes on specific fields\n\n- **`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)`.\n- **`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()`.\n\n### Canonical implementation (Web Standards — Request/Response)\n\nWorks as-is in Deno, Bun, Cloudflare Workers, Supabase Edge Functions, SvelteKit, Remix, TanStack Start, Astro endpoints, and Next.js Route Handlers (NextRequest extends Request).\n\n```ts\nimport { Polar } from \"@polar-sh/sdk\";\n\nconst polar = new Polar({\n accessToken: process.env.POLAR_ACCESS_TOKEN,\n server: \"production\",\n});\n\nconst SUCCESS_URL = \"https://example.com/success?checkoutId={CHECKOUT_ID}\";\nconst THEME: \"light\" | \"dark\" | undefined = undefined;\n\nexport async function GET(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const products = url.searchParams.getAll(\"products\");\n\n if (products.length === 0) {\n return Response.json(\n { error: \"Missing products in query params\" },\n { status: 400 },\n );\n }\n\n const json = (key: string) =>\n url.searchParams.has(key)\n ? JSON.parse(url.searchParams.get(key) ?? \"{}\")\n : undefined;\n const str = (key: string) => url.searchParams.get(key) ?? undefined;\n\n try {\n const result = await polar.checkouts.create({\n products,\n successUrl: SUCCESS_URL,\n customerId: str(\"customerId\"),\n externalCustomerId: str(\"customerExternalId\"),\n customerEmail: str(\"customerEmail\"),\n customerName: str(\"customerName\"),\n customerBillingAddress: json(\"customerBillingAddress\"),\n customerTaxId: str(\"customerTaxId\"),\n customerIpAddress: str(\"customerIpAddress\"),\n customerMetadata: json(\"customerMetadata\"),\n metadata: json(\"metadata\"),\n discountId: str(\"discountId\"),\n allowDiscountCodes: url.searchParams.has(\"allowDiscountCodes\")\n ? url.searchParams.get(\"allowDiscountCodes\") === \"true\"\n : undefined,\n seats: url.searchParams.has(\"seats\")\n ? Number.parseInt(url.searchParams.get(\"seats\") ?? \"1\", 10)\n : undefined,\n });\n\n const redirectUrl = new URL(result.url);\n if (THEME) redirectUrl.searchParams.set(\"theme\", THEME);\n\n return Response.redirect(redirectUrl.toString(), 302);\n } catch (error) {\n console.error(error);\n return new Response(null, { status: 500 });\n }\n}\n```\n\nThis handler accepts a GET request with `?products=<product_id>` 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.\n\n### Framework variations\n\nThe body of the handler is identical — only the signature, URL access, and response constructors change.\n\n**Next.js (App Router):** put the canonical handler in `app/api/checkout/route.ts`. `NextRequest`/`NextResponse` are optional; `request: Request` works.\n\n**Express:**\n\n```ts\nimport type { Request, Response } from \"express\";\n\napp.get(\"/checkout\", async (req: Request, res: Response) => {\n const url = new URL(`${req.protocol}://${req.get(\"host\")}${req.originalUrl}`);\n // ... same body using `url.searchParams` ...\n // res.redirect(redirectUrl.toString());\n // res.status(400).json({ error: \"...\" });\n});\n```\n\n**Fastify:**\n\n```ts\nfastify.get(\"/checkout\", async (request, reply) => {\n const url = new URL(`${request.protocol}://${request.hostname}${request.url}`);\n // ... same body ...\n // reply.redirect(redirectUrl.toString());\n});\n```\n\n**Hono:**\n\n```ts\napp.get(\"/checkout\", async (c) => {\n const url = new URL(c.req.url);\n // ... same body ...\n return c.redirect(redirectUrl.toString());\n});\n```\n\n**Elysia:**\n\n```ts\napp.get(\"/checkout\", ({ request, redirect }) => {\n const url = new URL(request.url);\n // ... same body, `return redirect(redirectUrl.toString())` ...\n});\n```\n\n**Nuxt (server route):** `server/api/checkout.get.ts` using `defineEventHandler`, `getQuery`, `sendRedirect(event, url, 302)`.\n\n**SvelteKit:** `src/routes/api/checkout/+server.ts` exporting `GET({ request, url })` — the canonical handler works directly.\n\n**Astro:** `src/pages/api/checkout.ts` exporting `export const GET: APIRoute = async ({ request }) => { ... }`.\n\n---\n\n## Recipe 2 — Customer Portal endpoint\n\n**Goal:** an authenticated endpoint that creates a Polar customer session and 302-redirects to the hosted customer portal (subscriptions, invoices, payment methods).\n\n**Underlying API:** `POST /v1/customer-sessions/` (SDK: `polar.customerSessions.create(...)`). Full schema: <https://polar.sh/docs/api-reference/customer-portal/sessions/create>.\n\n### `polar.customerSessions.create()` field reference\n\nThe request body is a union — pass **either** `customerId` **or** `externalCustomerId`, not both.\n\n| SDK field | API field | Required | Notes |\n|---|---|---|---|\n| `customerId` | `customer_id` | one of these two | Existing Polar customer UUID. |\n| `externalCustomerId` | `external_customer_id` | one of these two | Your own user ID. |\n| `returnUrl` | `return_url` | no | Back-button URL on the portal page. |\n| `memberId` | `member_id` | no | Only for orgs with `member_model_enabled`. Polar customer member UUID. Defaults to the owner member for individual customers. |\n| `externalMemberId` | `external_member_id` | no | Your member ID, alternative to `memberId`. |\n\n**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.\n\n### Canonical implementation\n\n```ts\nimport { Polar } from \"@polar-sh/sdk\";\n\nconst polar = new Polar({\n accessToken: process.env.POLAR_ACCESS_TOKEN,\n server: \"production\",\n});\n\nconst RETURN_URL = \"https://example.com/account\";\n\n// Replace with your auth: read a cookie/JWT/session and return your user id.\nasync function getExternalCustomerId(request: Request): Promise<string | null> {\n const session = await getSession(request); // your auth helper\n return session?.userId ?? null;\n}\n\nexport async function GET(request: Request): Promise<Response> {\n const externalCustomerId = await getExternalCustomerId(request);\n if (!externalCustomerId) {\n return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n\n try {\n const { customerPortalUrl } = await polar.customerSessions.create({\n externalCustomerId,\n returnUrl: RETURN_URL,\n });\n return Response.redirect(customerPortalUrl, 302);\n } catch (error) {\n console.error(error);\n return new Response(null, { status: 500 });\n }\n}\n```\n\nIf 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.\n\n### Framework variations\n\nSame 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`.\n\n---\n\n## Recipe 3 — Webhooks endpoint\n\n**Goal:** receive Polar webhook events, verify the signature, and dispatch to per-event handlers.\n\n**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.\n\n### Canonical implementation\n\n```ts\nimport { validateEvent, WebhookVerificationError } from \"@polar-sh/sdk/webhooks\";\n\nconst WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!;\n\nexport async function POST(request: Request): Promise<Response> {\n const body = await request.text(); // must be the raw body, not parsed JSON\n\n let event: ReturnType<typeof validateEvent>;\n try {\n event = validateEvent(\n body,\n {\n \"webhook-id\": request.headers.get(\"webhook-id\") ?? \"\",\n \"webhook-timestamp\": request.headers.get(\"webhook-timestamp\") ?? \"\",\n \"webhook-signature\": request.headers.get(\"webhook-signature\") ?? \"\",\n },\n WEBHOOK_SECRET,\n );\n } catch (error) {\n if (error instanceof WebhookVerificationError) {\n return Response.json({ received: false }, { status: 403 });\n }\n throw error;\n }\n\n switch (event.type) {\n case \"checkout.created\":\n case \"checkout.updated\":\n // event.data is the Checkout\n break;\n\n case \"order.created\":\n case \"order.updated\":\n case \"order.paid\":\n case \"order.refunded\":\n // event.data is the Order — fulfill, send receipt, etc.\n break;\n\n case \"subscription.created\":\n case \"subscription.updated\":\n case \"subscription.active\":\n case \"subscription.canceled\":\n case \"subscription.uncanceled\":\n case \"subscription.revoked\":\n // event.data is the Subscription — flip entitlements in your DB\n break;\n\n case \"refund.created\":\n case \"refund.updated\":\n break;\n\n case \"product.created\":\n case \"product.updated\":\n break;\n\n case \"benefit.created\":\n case \"benefit.updated\":\n break;\n\n case \"benefit_grant.created\":\n case \"benefit_grant.updated\":\n case \"benefit_grant.revoked\":\n // grant or revoke the user's access to a benefit\n break;\n\n case \"customer.created\":\n case \"customer.updated\":\n case \"customer.deleted\":\n case \"customer.state_changed\":\n break;\n\n case \"organization.updated\":\n break;\n }\n\n return Response.json({ received: true });\n}\n```\n\n`event` is fully typed — TypeScript will narrow `event.data` inside each `case`.\n\n### Critical details\n\n1. **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.\n2. **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.\n3. **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`).\n4. **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.\n\n### Test it locally\n\nWith the dev server running, forward events with the Polar CLI and send one (setup in `polar-testing`, \"Local Webhook Testing\"):\n\n```bash\npolar webhook listen 3000/api/polar/webhook # keep it running in the background\npolar webhook trigger order.paid # exits 1 and prints the response when the handler fails\n```\n\nLocally, `POLAR_WEBHOOK_SECRET` is the secret from `polar webhook listen --print-secret`.\n\n### Framework variations\n\n**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.\n\n**Express:** mount with the raw body parser on this route only:\n\n```ts\napp.post(\"/polar/webhook\", express.raw({ type: \"application/json\" }), async (req, res) => {\n const body = (req.body as Buffer).toString(\"utf8\");\n // ... validateEvent / switch ...\n res.json({ received: true });\n});\n```\n\n**Fastify:** add a content-type parser that preserves the raw body, or use `request.rawBody` with `@fastify/raw-body`.\n\n**Hono:** `await c.req.text()` returns the raw body, and `c.req.header(\"webhook-id\")` reads headers.\n\n**Elysia:** `await request.text()` on the standard `Request`.\n\n**SvelteKit:** in `+server.ts`, `await event.request.text()`.\n\n**Astro:** in `APIRoute`, `await request.text()`.\n\n**Nuxt:** `await readRawBody(event)` from h3.\n\n---\n\n- Don't install `@polar-sh/<framework>` 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.\n\n""---\nname: polar-integration\ndescription: 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.\n---\n\n# Polar SDK integration\n\nA 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.\n\n## Setup\n\nInstall the SDK. Version 1.0 and later needs Node.js 22 or newer:\n\n```bash\nnpm install @polar-sh/sdk\n# or pnpm / yarn / bun\n```\n\nIf 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.\n\nDon'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.\n\nRequired environment variables (use whatever loader your framework provides — `process.env`, `Deno.env`, `import.meta.env`, etc.):\n\n- `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).\n- `POLAR_WEBHOOK_SECRET` — only needed for the webhook recipe.\n- `POLAR_ENVIRONMENT` — `sandbox` while developing; omit it or set `production` in production. Sandbox and production are separate, so each needs its own token.\n\nThe 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:\n\n```ts\nimport { createPolar, type Environment } from \"@polar-sh/sdk/2026-10\";\n\nconst accessToken = process.env.POLAR_ACCESS_TOKEN;\nif (!accessToken) throw new Error(\"POLAR_ACCESS_TOKEN is required\");\n\nexport const polar = createPolar({\n accessToken,\n environment: (process.env.POLAR_ENVIRONMENT as Environment | undefined) || \"production\",\n});\n```\n\nThe recipes below import this `polar` client. They are independent — implement only the ones the user asks for.\n\n### Conventions\n\n- 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.\n- Path parameters are positional (`polar.checkouts.get(id)`), request bodies are a single object.\n- Paginated lists come from `iter*` methods consumed with `for await`.\n\n---\n\n## Recipe 1 — Checkout endpoint\n\n**Goal:** an HTTP endpoint that creates a Polar checkout session and 302-redirects the browser to the hosted checkout page.\n\n**Underlying API:** `POST /v1/checkouts/` (SDK: `polar.checkouts.create(...)`). Full schema: <https://polar.sh/docs/api-reference/current/checkouts/create-checkout-session>.\n\n### `polar.checkouts.create()` field reference\n\n| Field | Required | Notes |\n|---|---|---|\n| `products` | yes | Array of product UUIDs. The first is selected by default. |\n| `customer_id` | no | Existing Polar customer ID. |\n| `external_customer_id` | no | Your own user ID. If no Polar customer matches, one is created with this external ID. |\n| `customer_email` | no | |\n| `customer_name` | no | |\n| `customer_billing_name` | no | |\n| `customer_billing_address` | no | `{ country: \"US\", line1, line2, city, state, postal_code }`. `country` (ISO alpha-2) is required if address is set. |\n| `customer_tax_id` | no | |\n| `customer_ip_address` | no | Used to pick the presentment currency. |\n| `customer_metadata` | no | Copied to the created customer. |\n| `is_business_customer` | no | Default `false`. If `true`, full billing address + name required. |\n| `metadata` | no | Copied to the resulting order/subscription. |\n| `custom_field_data` | no | Map of custom field slug → value. |\n| `discount_id` | no | |\n| `allow_discount_codes` | no | Default `true`. |\n| `require_billing_address` | no | Default `false`. US customers always required regardless. |\n| `seats` | no | Predefined number of seats (seat-based pricing only). |\n| `min_seats` / `max_seats` | no | Seat-based pricing only. |\n| `units` / `min_units` / `max_units` | no | Unit-based pricing only. |\n| `amount` | no | Cents. Only used for `custom` (pay-what-you-want) prices. |\n| `subscription_id` | no | Upgrade an existing free subscription. |\n| `success_url` | no | See below. |\n| `return_url` | no | Back-button URL on the checkout page. |\n| `embed_origin` | no | Set when embedding the checkout in an iframe. |\n| `locale` | no | IETF BCP 47 (`en`, `en-US`, `fr-CA`, ...). Defaults to `en`. |\n| `currency` | no | ISO 4217 (e.g. `usd`, `eur`). |\n| `trial_interval` / `trial_interval_count` | no | Override product trial. |\n| `allow_trial` | no | Default `true`. Set `false` to disable an otherwise-configured trial. |\n| `prices` | no | Map of product ID → ad-hoc prices (override catalog prices). |\n\n**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.\n\n### Notes on specific fields\n\n- **`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.\n- **`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.\n- **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.\n\n### Canonical implementation (Web Standards — Request/Response)\n\n```ts\nimport { polar } from \"./polar\";\n\nconst SUCCESS_URL = \"https://example.com/success?checkout_id={CHECKOUT_ID}\";\nconst THEME: \"light\" | \"dark\" | undefined = undefined;\n\nexport async function handleCheckout(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const products = url.searchParams.getAll(\"products\");\n\n if (products.length === 0) {\n return Response.json({ error: \"Missing products in query params\" }, { status: 400 });\n }\n\n const ipAddress =\n request.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ??\n request.headers.get(\"x-real-ip\") ??\n request.headers.get(\"cf-connecting-ip\") ??\n undefined;\n\n try {\n const checkout = await polar.checkouts.create({\n products,\n success_url: SUCCESS_URL,\n external_customer_id: await getUserId(request), // your auth helper, or undefined for guests\n customer_email: url.searchParams.get(\"customer_email\") ?? undefined,\n customer_ip_address: ipAddress,\n discount_id: url.searchParams.get(\"discount_id\") ?? undefined,\n });\n\n const redirectUrl = new URL(checkout.url);\n if (THEME) redirectUrl.searchParams.set(\"theme\", THEME);\n\n return Response.redirect(redirectUrl.toString(), 302);\n } catch (error) {\n console.error(error);\n return new Response(null, { status: 500 });\n }\n}\n```\n\nThis 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.\n\n### Framework variations\n\nThe body of the handler is identical — only the entry point changes:\n\n- **Next.js (App Router):** `app/api/checkout/route.ts`: `export const GET = handleCheckout;`\n- **SvelteKit:** `src/routes/api/checkout/+server.ts`: `export const GET = ({ request }) => handleCheckout(request);`\n- **Astro:** `src/pages/api/checkout.ts`: `export const GET: APIRoute = ({ request }) => handleCheckout(request);`\n- **Remix / React Router:** `export const loader = ({ request }) => handleCheckout(request);`\n- **TanStack Start:** `server: { handlers: { GET: ({ request }) => handleCheckout(request) } }` on the route.\n- **Hono:** `app.get(\"/checkout\", (c) => handleCheckout(c.req.raw));`\n- **Elysia:** `app.get(\"/checkout\", ({ request }) => handleCheckout(request));`\n- **Deno / Supabase Edge Functions:** `Deno.serve(handleCheckout);`\n- **Bun:** `Bun.serve({ fetch: handleCheckout });`\n- **Cloudflare Workers:** `export default { fetch: handleCheckout };` (read secrets from the `env` argument instead of `process.env`).\n- **Nuxt (server route):** `server/api/checkout.get.ts` with `defineEventHandler((event) => handleCheckout(toWebRequest(event)))`.\n- **Express:**\n\n ```ts\n app.get(\"/checkout\", async (req, res) => {\n const url = new URL(`${req.protocol}://${req.get(\"host\")}${req.originalUrl}`);\n // ... same body using `url.searchParams`, then res.redirect(redirectUrl.toString()) ...\n });\n ```\n\n- **Fastify:**\n\n ```ts\n fastify.get(\"/checkout\", async (request, reply) => {\n const url = new URL(`${request.protocol}://${request.hostname}${request.url}`);\n // ... same body, then reply.redirect(redirectUrl.toString()) ...\n });\n ```\n\n---\n\n## Recipe 2 — Customer Portal endpoint\n\n**Goal:** an authenticated endpoint that creates a Polar customer session and 302-redirects to the hosted customer portal (subscriptions, invoices, payment methods).\n\n**Underlying API:** `POST /v1/customer-sessions/` (SDK: `polar.customerSessions.create(...)`). Full schema: <https://polar.sh/docs/api-reference/current/customer-sessions/create-customer-session>.\n\n### `polar.customerSessions.create()` field reference\n\nThe request body is a union — pass **either** `customer_id` **or** `external_customer_id`, not both.\n\n| Field | Required | Notes |\n|---|---|---|\n| `customer_id` | one of these two | Existing Polar customer UUID. |\n| `external_customer_id` | one of these two | Your own user ID. |\n| `return_url` | no | Back-button URL on the portal page. |\n| `member_id` | no | Only for orgs with `member_model_enabled`. Polar customer member UUID. Defaults to the owner member for individual customers. |\n| `external_member_id` | no | Your member ID, alternative to `member_id`. |\n\n**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.\n\n### Canonical implementation\n\n```ts\nimport { polar } from \"./polar\";\n\nconst RETURN_URL = \"https://example.com/account\";\n\nexport async function handlePortal(request: Request): Promise<Response> {\n const externalCustomerId = await getUserId(request); // your auth helper\n if (!externalCustomerId) {\n return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n\n try {\n const session = await polar.customerSessions.create({\n external_customer_id: externalCustomerId,\n return_url: RETURN_URL,\n });\n return Response.redirect(session.customer_portal_url, 302);\n } catch (error) {\n console.error(error);\n return new Response(null, { status: 500 });\n }\n}\n```\n\nIf 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.\n\n### Framework variations\n\nUse 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.).\n\n---\n\n## Recipe 3 — Webhooks endpoint\n\n**Goal:** receive Polar webhook events, verify the signature, and dispatch to per-event handlers.\n\n**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.\n\n### Canonical implementation\n\n```ts\nimport { webhooks } from \"@polar-sh/sdk/2026-10\";\n\nconst WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET;\nif (!WEBHOOK_SECRET) throw new Error(\"POLAR_WEBHOOK_SECRET is required\");\n\nexport async function handleWebhook(request: Request): Promise<Response> {\n const body = await request.text(); // must be the raw body, not parsed JSON\n const headers = {\n \"webhook-id\": request.headers.get(\"webhook-id\") ?? \"\",\n \"webhook-timestamp\": request.headers.get(\"webhook-timestamp\") ?? \"\",\n \"webhook-signature\": request.headers.get(\"webhook-signature\") ?? \"\",\n };\n\n let event: webhooks.WebhookPayload;\n try {\n event = await webhooks.validateEvent(body, headers, WEBHOOK_SECRET!);\n } catch (error) {\n if (error instanceof webhooks.PolarWebhookVerificationError) {\n return Response.json({ received: false }, { status: 403 });\n }\n if (error instanceof webhooks.PolarWebhookUnknownTypeError) {\n // Signed, but an event type this SDK version doesn't know. Acknowledge it so Polar doesn't retry.\n return Response.json({ received: true }, { status: 202 });\n }\n if (error instanceof webhooks.PolarWebhookError) {\n return Response.json({ received: false }, { status: 400 });\n }\n throw error;\n }\n\n switch (event.type) {\n case \"order.paid\":\n // event.data is the Order — fulfill, send receipt, etc.\n break;\n\n case \"subscription.active\":\n case \"subscription.canceled\":\n case \"subscription.uncanceled\":\n case \"subscription.revoked\":\n // event.data is the Subscription — flip entitlements in your DB\n break;\n\n case \"benefit_grant.created\":\n case \"benefit_grant.updated\":\n case \"benefit_grant.revoked\":\n // grant or revoke the user's access to a benefit\n break;\n\n case \"customer.state_changed\":\n // event.data is the full customer state — reconcile access from granted_benefits\n break;\n }\n\n return Response.json({ received: true }, { status: 202 });\n}\n```\n\n`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.\n\n### Critical details\n\n1. **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.\n2. **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.\n3. **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`).\n4. **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.\n\n### Test it locally\n\nWith the dev server running, forward events with the Polar CLI and send one (setup in `polar-testing`, \"Local Webhook Testing\"):\n\n```bash\npolar webhook listen 3000/api/polar/webhook # keep it running in the background\npolar webhook trigger order.paid # exits 1 and prints the response when the handler fails\n```\n\nLocally, `POLAR_WEBHOOK_SECRET` is the secret from `polar webhook listen --print-secret`.\n\n### Framework variations\n\nUse the same entry points as Recipe 1 with `POST` (Remix: `action`), plus:\n\n- **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.\n- **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`:\n\n ```ts\n app.post(\"/polar/webhook\", express.raw({ type: \"application/json\" }), async (req, res, next) => {\n try {\n const event = await webhooks.validateEvent(req.body, {\n \"webhook-id\": req.header(\"webhook-id\") ?? \"\",\n \"webhook-timestamp\": req.header(\"webhook-timestamp\") ?? \"\",\n \"webhook-signature\": req.header(\"webhook-signature\") ?? \"\",\n }, WEBHOOK_SECRET!);\n // ... handle event, catch the error classes as in the canonical handler ...\n res.sendStatus(202);\n } catch (error) {\n next(error);\n }\n });\n app.use(express.json());\n ```\n\n- **Fastify:** register `fastify-raw-body` and set `config: { rawBody: true }` on the route, then pass `request.rawBody`.\n- **Hono:** `app.post(\"/polar/webhook\", (c) => handleWebhook(c.req.raw));`\n- **Nuxt:** `await readRawBody(event)` from h3 for the body, `getHeader(event, \"webhook-id\")` for headers.\n"Full snapshot data
{
"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.",
"included_files": [],
"name": "polar-integration",
"skill_md_contents": "---\nname: polar-integration\ndescription: 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.\n---\n\n# Polar SDK integration\n\nA 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.\n\n## Setup\n\nInstall the SDK. Version 1.0 and later needs Node.js 22 or newer:\n\n```bash\nnpm install @polar-sh/sdk\n# or pnpm / yarn / bun\n```\n\nIf 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.\n\nDon'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.\n\nRequired environment variables (use whatever loader your framework provides — `process.env`, `Deno.env`, `import.meta.env`, etc.):\n\n- `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).\n- `POLAR_WEBHOOK_SECRET` — only needed for the webhook recipe.\n- `POLAR_ENVIRONMENT` — `sandbox` while developing; omit it or set `production` in production. Sandbox and production are separate, so each needs its own token.\n\nThe 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:\n\n```ts\nimport { createPolar, type Environment } from \"@polar-sh/sdk/2026-10\";\n\nconst accessToken = process.env.POLAR_ACCESS_TOKEN;\nif (!accessToken) throw new Error(\"POLAR_ACCESS_TOKEN is required\");\n\nexport const polar = createPolar({\n accessToken,\n environment: (process.env.POLAR_ENVIRONMENT as Environment | undefined) || \"production\",\n});\n```\n\nThe recipes below import this `polar` client. They are independent — implement only the ones the user asks for.\n\n### Conventions\n\n- 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.\n- Path parameters are positional (`polar.checkouts.get(id)`), request bodies are a single object.\n- Paginated lists come from `iter*` methods consumed with `for await`.\n\n---\n\n## Recipe 1 — Checkout endpoint\n\n**Goal:** an HTTP endpoint that creates a Polar checkout session and 302-redirects the browser to the hosted checkout page.\n\n**Underlying API:** `POST /v1/checkouts/` (SDK: `polar.checkouts.create(...)`). Full schema: <https://polar.sh/docs/api-reference/current/checkouts/create-checkout-session>.\n\n### `polar.checkouts.create()` field reference\n\n| Field | Required | Notes |\n|---|---|---|\n| `products` | yes | Array of product UUIDs. The first is selected by default. |\n| `customer_id` | no | Existing Polar customer ID. |\n| `external_customer_id` | no | Your own user ID. If no Polar customer matches, one is created with this external ID. |\n| `customer_email` | no | |\n| `customer_name` | no | |\n| `customer_billing_name` | no | |\n| `customer_billing_address` | no | `{ country: \"US\", line1, line2, city, state, postal_code }`. `country` (ISO alpha-2) is required if address is set. |\n| `customer_tax_id` | no | |\n| `customer_ip_address` | no | Used to pick the presentment currency. |\n| `customer_metadata` | no | Copied to the created customer. |\n| `is_business_customer` | no | Default `false`. If `true`, full billing address + name required. |\n| `metadata` | no | Copied to the resulting order/subscription. |\n| `custom_field_data` | no | Map of custom field slug → value. |\n| `discount_id` | no | |\n| `allow_discount_codes` | no | Default `true`. |\n| `require_billing_address` | no | Default `false`. US customers always required regardless. |\n| `seats` | no | Predefined number of seats (seat-based pricing only). |\n| `min_seats` / `max_seats` | no | Seat-based pricing only. |\n| `units` / `min_units` / `max_units` | no | Unit-based pricing only. |\n| `amount` | no | Cents. Only used for `custom` (pay-what-you-want) prices. |\n| `subscription_id` | no | Upgrade an existing free subscription. |\n| `success_url` | no | See below. |\n| `return_url` | no | Back-button URL on the checkout page. |\n| `embed_origin` | no | Set when embedding the checkout in an iframe. |\n| `locale` | no | IETF BCP 47 (`en`, `en-US`, `fr-CA`, ...). Defaults to `en`. |\n| `currency` | no | ISO 4217 (e.g. `usd`, `eur`). |\n| `trial_interval` / `trial_interval_count` | no | Override product trial. |\n| `allow_trial` | no | Default `true`. Set `false` to disable an otherwise-configured trial. |\n| `prices` | no | Map of product ID → ad-hoc prices (override catalog prices). |\n\n**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.\n\n### Notes on specific fields\n\n- **`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.\n- **`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.\n- **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.\n\n### Canonical implementation (Web Standards — Request/Response)\n\n```ts\nimport { polar } from \"./polar\";\n\nconst SUCCESS_URL = \"https://example.com/success?checkout_id={CHECKOUT_ID}\";\nconst THEME: \"light\" | \"dark\" | undefined = undefined;\n\nexport async function handleCheckout(request: Request): Promise<Response> {\n const url = new URL(request.url);\n const products = url.searchParams.getAll(\"products\");\n\n if (products.length === 0) {\n return Response.json({ error: \"Missing products in query params\" }, { status: 400 });\n }\n\n const ipAddress =\n request.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ??\n request.headers.get(\"x-real-ip\") ??\n request.headers.get(\"cf-connecting-ip\") ??\n undefined;\n\n try {\n const checkout = await polar.checkouts.create({\n products,\n success_url: SUCCESS_URL,\n external_customer_id: await getUserId(request), // your auth helper, or undefined for guests\n customer_email: url.searchParams.get(\"customer_email\") ?? undefined,\n customer_ip_address: ipAddress,\n discount_id: url.searchParams.get(\"discount_id\") ?? undefined,\n });\n\n const redirectUrl = new URL(checkout.url);\n if (THEME) redirectUrl.searchParams.set(\"theme\", THEME);\n\n return Response.redirect(redirectUrl.toString(), 302);\n } catch (error) {\n console.error(error);\n return new Response(null, { status: 500 });\n }\n}\n```\n\nThis 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.\n\n### Framework variations\n\nThe body of the handler is identical — only the entry point changes:\n\n- **Next.js (App Router):** `app/api/checkout/route.ts`: `export const GET = handleCheckout;`\n- **SvelteKit:** `src/routes/api/checkout/+server.ts`: `export const GET = ({ request }) => handleCheckout(request);`\n- **Astro:** `src/pages/api/checkout.ts`: `export const GET: APIRoute = ({ request }) => handleCheckout(request);`\n- **Remix / React Router:** `export const loader = ({ request }) => handleCheckout(request);`\n- **TanStack Start:** `server: { handlers: { GET: ({ request }) => handleCheckout(request) } }` on the route.\n- **Hono:** `app.get(\"/checkout\", (c) => handleCheckout(c.req.raw));`\n- **Elysia:** `app.get(\"/checkout\", ({ request }) => handleCheckout(request));`\n- **Deno / Supabase Edge Functions:** `Deno.serve(handleCheckout);`\n- **Bun:** `Bun.serve({ fetch: handleCheckout });`\n- **Cloudflare Workers:** `export default { fetch: handleCheckout };` (read secrets from the `env` argument instead of `process.env`).\n- **Nuxt (server route):** `server/api/checkout.get.ts` with `defineEventHandler((event) => handleCheckout(toWebRequest(event)))`.\n- **Express:**\n\n ```ts\n app.get(\"/checkout\", async (req, res) => {\n const url = new URL(`${req.protocol}://${req.get(\"host\")}${req.originalUrl}`);\n // ... same body using `url.searchParams`, then res.redirect(redirectUrl.toString()) ...\n });\n ```\n\n- **Fastify:**\n\n ```ts\n fastify.get(\"/checkout\", async (request, reply) => {\n const url = new URL(`${request.protocol}://${request.hostname}${request.url}`);\n // ... same body, then reply.redirect(redirectUrl.toString()) ...\n });\n ```\n\n---\n\n## Recipe 2 — Customer Portal endpoint\n\n**Goal:** an authenticated endpoint that creates a Polar customer session and 302-redirects to the hosted customer portal (subscriptions, invoices, payment methods).\n\n**Underlying API:** `POST /v1/customer-sessions/` (SDK: `polar.customerSessions.create(...)`). Full schema: <https://polar.sh/docs/api-reference/current/customer-sessions/create-customer-session>.\n\n### `polar.customerSessions.create()` field reference\n\nThe request body is a union — pass **either** `customer_id` **or** `external_customer_id`, not both.\n\n| Field | Required | Notes |\n|---|---|---|\n| `customer_id` | one of these two | Existing Polar customer UUID. |\n| `external_customer_id` | one of these two | Your own user ID. |\n| `return_url` | no | Back-button URL on the portal page. |\n| `member_id` | no | Only for orgs with `member_model_enabled`. Polar customer member UUID. Defaults to the owner member for individual customers. |\n| `external_member_id` | no | Your member ID, alternative to `member_id`. |\n\n**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.\n\n### Canonical implementation\n\n```ts\nimport { polar } from \"./polar\";\n\nconst RETURN_URL = \"https://example.com/account\";\n\nexport async function handlePortal(request: Request): Promise<Response> {\n const externalCustomerId = await getUserId(request); // your auth helper\n if (!externalCustomerId) {\n return Response.json({ error: \"Unauthorized\" }, { status: 401 });\n }\n\n try {\n const session = await polar.customerSessions.create({\n external_customer_id: externalCustomerId,\n return_url: RETURN_URL,\n });\n return Response.redirect(session.customer_portal_url, 302);\n } catch (error) {\n console.error(error);\n return new Response(null, { status: 500 });\n }\n}\n```\n\nIf 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.\n\n### Framework variations\n\nUse 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.).\n\n---\n\n## Recipe 3 — Webhooks endpoint\n\n**Goal:** receive Polar webhook events, verify the signature, and dispatch to per-event handlers.\n\n**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.\n\n### Canonical implementation\n\n```ts\nimport { webhooks } from \"@polar-sh/sdk/2026-10\";\n\nconst WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET;\nif (!WEBHOOK_SECRET) throw new Error(\"POLAR_WEBHOOK_SECRET is required\");\n\nexport async function handleWebhook(request: Request): Promise<Response> {\n const body = await request.text(); // must be the raw body, not parsed JSON\n const headers = {\n \"webhook-id\": request.headers.get(\"webhook-id\") ?? \"\",\n \"webhook-timestamp\": request.headers.get(\"webhook-timestamp\") ?? \"\",\n \"webhook-signature\": request.headers.get(\"webhook-signature\") ?? \"\",\n };\n\n let event: webhooks.WebhookPayload;\n try {\n event = await webhooks.validateEvent(body, headers, WEBHOOK_SECRET!);\n } catch (error) {\n if (error instanceof webhooks.PolarWebhookVerificationError) {\n return Response.json({ received: false }, { status: 403 });\n }\n if (error instanceof webhooks.PolarWebhookUnknownTypeError) {\n // Signed, but an event type this SDK version doesn't know. Acknowledge it so Polar doesn't retry.\n return Response.json({ received: true }, { status: 202 });\n }\n if (error instanceof webhooks.PolarWebhookError) {\n return Response.json({ received: false }, { status: 400 });\n }\n throw error;\n }\n\n switch (event.type) {\n case \"order.paid\":\n // event.data is the Order — fulfill, send receipt, etc.\n break;\n\n case \"subscription.active\":\n case \"subscription.canceled\":\n case \"subscription.uncanceled\":\n case \"subscription.revoked\":\n // event.data is the Subscription — flip entitlements in your DB\n break;\n\n case \"benefit_grant.created\":\n case \"benefit_grant.updated\":\n case \"benefit_grant.revoked\":\n // grant or revoke the user's access to a benefit\n break;\n\n case \"customer.state_changed\":\n // event.data is the full customer state — reconcile access from granted_benefits\n break;\n }\n\n return Response.json({ received: true }, { status: 202 });\n}\n```\n\n`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.\n\n### Critical details\n\n1. **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.\n2. **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.\n3. **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`).\n4. **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.\n\n### Test it locally\n\nWith the dev server running, forward events with the Polar CLI and send one (setup in `polar-testing`, \"Local Webhook Testing\"):\n\n```bash\npolar webhook listen 3000/api/polar/webhook # keep it running in the background\npolar webhook trigger order.paid # exits 1 and prints the response when the handler fails\n```\n\nLocally, `POLAR_WEBHOOK_SECRET` is the secret from `polar webhook listen --print-secret`.\n\n### Framework variations\n\nUse the same entry points as Recipe 1 with `POST` (Remix: `action`), plus:\n\n- **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.\n- **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`:\n\n ```ts\n app.post(\"/polar/webhook\", express.raw({ type: \"application/json\" }), async (req, res, next) => {\n try {\n const event = await webhooks.validateEvent(req.body, {\n \"webhook-id\": req.header(\"webhook-id\") ?? \"\",\n \"webhook-timestamp\": req.header(\"webhook-timestamp\") ?? \"\",\n \"webhook-signature\": req.header(\"webhook-signature\") ?? \"\",\n }, WEBHOOK_SECRET!);\n // ... handle event, catch the error classes as in the canonical handler ...\n res.sendStatus(202);\n } catch (error) {\n next(error);\n }\n });\n app.use(express.json());\n ```\n\n- **Fastify:** register `fastify-raw-body` and set `config: { rawBody: true }` on the route, then pass `request.rawBody`.\n- **Hono:** `app.post(\"/polar/webhook\", (c) => handleWebhook(c.req.raw));`\n- **Nuxt:** `await readRawBody(event)` from h3 for the body, `getHeader(event, \"webhook-id\")` for headers.\n"
}SHA-256 of public snapshot: 1edfe486e10849b52a6ffc77b2b221eec96f9b947ab9b01867076544c5675c7d