polar-integration

Installation
SKILL.md

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:

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.

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_WEBHOOK_SECRET — only needed for the webhook recipe.
  • POLAR_SERVER — set to sandbox while developing, omit (or production) in prod.

Construct one client and reuse it:

import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: (process.env.POLAR_SERVER as "sandbox" | "production") ?? "production",
});

The three recipes below are independent — implement only the ones the user asks for.


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/checkouts/create-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.

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.

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().

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).

import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: "production",
});

const SUCCESS_URL = "https://example.com/success?checkoutId={CHECKOUT_ID}";
const THEME: "light" | "dark" | undefined = undefined;

export async function GET(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 json = (key: string) =>
    url.searchParams.has(key)
      ? JSON.parse(url.searchParams.get(key) ?? "{}")
      : undefined;
  const str = (key: string) => url.searchParams.get(key) ?? undefined;

  try {
    const result = 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,
    });

    const redirectUrl = new URL(result.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> 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.

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:

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:

fastify.get("/checkout", async (request, reply) => {
  const url = new URL(`${request.protocol}://${request.hostname}${request.url}`);
  // ... same body ...
  // reply.redirect(redirectUrl.toString());
});

Hono:

app.get("/checkout", async (c) => {
  const url = new URL(c.req.url);
  // ... same body ...
  return c.redirect(redirectUrl.toString());
});

Elysia:

app.get("/checkout", ({ request, redirect }) => {
  const url = new URL(request.url);
  // ... same body, `return redirect(redirectUrl.toString())` ...
});

Nuxt (server route): server/api/checkout.get.ts using defineEventHandler, getQuery, sendRedirect(event, url, 302).

SvelteKit: src/routes/api/checkout/+server.ts exporting GET({ request, url }) — the canonical handler works directly.

Astro: src/pages/api/checkout.ts exporting export const GET: APIRoute = async ({ request }) => { ... }.


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/customer-portal/sessions/create.

polar.customerSessions.create() field reference

The request body is a union — pass either customerId or externalCustomerId, 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.

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

import { Polar } from "@polar-sh/sdk";

const polar = new Polar({
  accessToken: process.env.POLAR_ACCESS_TOKEN,
  server: "production",
});

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);
  if (!externalCustomerId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const { customerPortalUrl } = await polar.customerSessions.create({
      externalCustomerId,
      returnUrl: RETURN_URL,
    });
    return Response.redirect(customerPortalUrl, 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 externalCustomerId for customerId in the polar.customerSessions.create({ ... }) call. externalCustomerId is usually what you want — your auth system already knows the user'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.


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, webhook-signature headers using validateEvent from @polar-sh/sdk/webhooks. Returns 200 on success, 403 on signature mismatch.

Canonical implementation

import { validateEvent, WebhookVerificationError } from "@polar-sh/sdk/webhooks";

const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET!;

export async function POST(request: Request): Promise<Response> {
  const body = await request.text(); // must be the raw body, not parsed JSON

  let event: ReturnType<typeof validateEvent>;
  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,
    );
  } catch (error) {
    if (error instanceof WebhookVerificationError) {
      return Response.json({ received: false }, { status: 403 });
    }
    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":
    case "subscription.revoked":
      // 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":
      break;
  }

  return Response.json({ received: true });
}

event is fully typed — TypeScript will narrow event.data inside each case.

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).

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:

app.post("/polar/webhook", express.raw({ type: "application/json" }), async (req, res) => {
  const body = (req.body as Buffer).toString("utf8");
  // ... validateEvent / switch ...
  res.json({ received: true });
});

Fastify: add a content-type parser that preserves the raw body, or use request.rawBody with @fastify/raw-body.

Hono: await c.req.text() returns the raw body, and c.req.header("webhook-id") reads headers.

Elysia: await request.text() on the standard Request.

SvelteKit: in +server.ts, await event.request.text().

Astro: in APIRoute, await request.text().

Nuxt: await readRawBody(event) from h3.


  • Don't install @polar-sh/<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.
Installs
1
First Seen
10 days ago