polarsource/skills

polar-integration

Add Polar billing to a TypeScript/JavaScript app using the @polar-sh/sdk package.

Quelltext ansehen
Originales Skill-Dokument

Aus dem Quell-Repository gerendert; Überschriften, Beispiele, Code, Tabellen, Links und Bilder bleiben erhalten.

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:

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.

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:

ts
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)RequiredNotes
productsproductsyesArray of product UUIDs. The first is selected by default.
customerIdcustomer_idnoExisting Polar customer ID.
externalCustomerIdexternal_customer_idnoYour own user ID. If no Polar customer matches, one is created with this external ID.
customerEmailcustomer_emailno
customerNamecustomer_nameno
customerBillingNamecustomer_billing_nameno
customerBillingAddresscustomer_billing_addressno{ country: "US", line1, line2, city, state, postal_code }. country (ISO alpha-2) is required if address is set.
customerTaxIdcustomer_tax_idno
customerIpAddresscustomer_ip_addressno
customerMetadatacustomer_metadatanoCopied to the created customer.
isBusinessCustomeris_business_customernoDefault false. If true, full billing address + name required.
metadatametadatanoCopied to the resulting order/subscription.
customFieldDatacustom_field_datanoMap of custom field slug → value.
discountIddiscount_idno
allowDiscountCodesallow_discount_codesnoDefault true.
requireBillingAddressrequire_billing_addressnoDefault false. US customers always required regardless.
seatsseatsnoPredefined number of seats (seat-based pricing only).
minSeats / maxSeatsmin_seats / max_seatsnoSeat-based pricing only.
amountamountnoCents. Only used for custom (pay-what-you-want) prices.
subscriptionIdsubscription_idnoUpgrade an existing free subscription.
successUrlsuccess_urlnoSee below.
returnUrlreturn_urlnoBack-button URL on the checkout page.
embedOriginembed_originnoSet when embedding the checkout in an iframe.
localelocalenoIETF BCP 47 (en, en-US, fr-CA, ...). Defaults to en-US.
currencycurrencynoISO 4217 (e.g. usd, eur).
trialInterval / trialIntervalCounttrial_interval / trial_interval_countnoOverride product trial.
allowTrialallow_trialnoDefault true. Set false to disable an otherwise-configured trial.
pricespricesnoMap 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).

ts
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:

ts
import type { Request, Response } from "express";

app.get("/checkout", async (req: Request, res: Response) => {
  const url = new URL(`${req.protocol}://${req.get("host")}${req.originalUrl}`);
  // ... same body using `url.searchParams` ...
  // res.redirect(redirectUrl.toString());
  // res.status(400).json({ error: "..." });
});

Fastify:

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

Hono:

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

Elysia:

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

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

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

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


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 fieldAPI fieldRequiredNotes
customerIdcustomer_idone of these twoExisting Polar customer UUID.
externalCustomerIdexternal_customer_idone of these twoYour own user ID.
returnUrlreturn_urlnoBack-button URL on the portal page.
memberIdmember_idnoOnly for orgs with member_model_enabled. Polar customer member UUID. Defaults to the owner member for individual customers.
externalMemberIdexternal_member_idnoYour 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

ts
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

ts
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:

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

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

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

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

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

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

Nuxt: await readRawBody(event) from h3.


  • Don't install @polar-sh/<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.
aus demselben Repository

Weitere Skills

Alle Skills