mcpField
P
polar-integration

polar-integration

github.com/polarsource开发6242 次安装1 天前更新
代码仓库

关于 polar-integration

这是一份使用 `@polar-sh/sdk` 集成 Polar 的跨框架指南,面向需要实现 HTTP 端点的开发者,涵盖结账、客户门户和 Webhook。

主要功能

  1. 01创建结账会话并跳转
  2. 02创建客户门户会话
  3. 03验证 Webhook 签名
  4. 04适配不同框架的处理器

适用场景

  1. 01在应用中加入托管结账
  2. 02让客户管理订阅和发票
  3. 03接收并验证 Polar Webhook 事件

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/polarsource/skills --skill polar-integration

安装后在 Claude Code 中输入/polar-integration确认已加载。

详细介绍

Polar SDK 集成

本指南介绍如何直接使用 @polar-sh/sdk 接入 Polar 的三个核心 HTTP 端点——Checkout、Customer Portal 和 Webhooks。示例采用与框架无关的 Web 标准(Request/Response);每个部分也列出了针对各框架的简洁惯用调整。

设置

安装 SDK:

npm install @polar-sh/sdk
# or pnpm / yarn / bun

不要安装 @polar-sh/<framework> 软件包(例如 @polar-sh/nextjs、@polar-sh/express、@polar-sh/hono 等)——这些软件包已弃用。对于这些示例,只需要 @polar-sh/sdk。

必需的环境变量(使用框架提供的任意加载器——process.env、Deno.env、import.meta.env 等):

  • POLAR_ACCESS_TOKEN — Polar 控制面板中的组织访问令牌。
  • POLAR_WEBHOOK_SECRET — 仅 Webhook 示例需要。
  • POLAR_SERVER — 开发时设为 sandbox,生产环境中省略(或设为 production)。

创建一个客户端并重复使用:

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",
});

以下三个示例相互独立——只实现用户要求的部分。


示例 1 — Checkout 端点

**目标:**创建 Polar Checkout 会话并将浏览器重定向(302)到托管的 Checkout 页面。

底层 API:POST /v1/checkouts/(SDK:polar.checkouts.create(...))。完整架构:https://polar.sh/docs/api-reference/checkouts/create-session。

polar.checkouts.create() 字段参考

SDK 使用 camelCase;底层 HTTP API 使用 snake_case。请选择与用户现有代码一致的写法。

SDK 字段(camelCase)API 字段(snake_case)必填说明
productsproducts是产品 UUID 数组。默认选择第一个。
customerIdcustomer_id否现有的 Polar 客户 ID。
externalCustomerIdexternal_customer_id否你自己的用户 ID。如果没有匹配的 Polar 客户,则会使用此外部 ID 创建一个客户。
customerEmailcustomer_email否
customerNamecustomer_name否
customerBillingNamecustomer_billing_name否
customerBillingAddresscustomer_billing_address否{ country: "US", line1, line2, city, state, postal_code }。如果设置了地址,则必须提供 country(ISO alpha-2)。
customerTaxIdcustomer_tax_id否
customerIpAddresscustomer_ip_address否
customerMetadatacustomer_metadata否复制到创建的客户中。
isBusinessCustomeris_business_customer否默认值为 false。如果为 true,则必须提供完整的账单地址和名称。
metadatametadata否复制到生成的订单/订阅中。
customFieldDatacustom_field_data否自定义字段 slug → 值的映射。
discountIddiscount_id否
allowDiscountCodesallow_discount_codes否默认值为 true。
requireBillingAddressrequire_billing_address否默认值为 false。美国客户始终需要提供账单地址。
seatsseats否预设席位数(仅适用于按席位定价)。
minSeats / maxSeatsmin_seats / max_seats否仅适用于按席位定价。
amountamount否以分为单位。仅用于 custom(随心付)价格。
subscriptionIdsubscription_id否升级现有的免费订阅。
successUrlsuccess_url否见下文。
returnUrlreturn_url否结账页面上的返回按钮 URL。
embedOriginembed_origin否在 iframe 中嵌入结账页面时设置。
localelocale否IETF BCP 47(en、en-US、fr-CA 等)。默认值为 en-US。
currencycurrency否ISO 4217(例如 usd、eur)。
trialInterval / trialIntervalCounttrial_interval / trial_interval_count否覆盖产品试用设置。
allowTrialallow_trial否默认值为 true。设为 false 可禁用已配置的试用。
pricesprices否产品 ID → 临时价格的映射(覆盖目录价格)。

响应: { id, url, client_secret, expires_at, status, ... }。将用户重定向到 url。只有通过 @polar-sh/checkout 嵌入结账页面时才需要 client_secret。

特定字段说明
  • successUrl 和 {CHECKOUT_ID}。 Polar 会进行字面字符串替换:重定向时,URL 中任何 {CHECKOUT_ID} 标记都会被替换为实际会话 ID。官方 API 文档中的示例使用 ?checkout_id={CHECKOUT_ID};你可以使用任意参数名称。如果成功页面需要通过 polar.checkouts.get(checkoutId) 获取会话,此功能会很有用。
  • theme(UI 提示,不是 API 字段)。 托管结账 UI 支持在 URL 中使用 ?theme=light|dark。若要应用主题,请在创建会话后将其追加到 result.url 中——它不是 polar.checkouts.create() 的字段。
规范实现(Web 标准 — 请求/响应)

可直接在 Deno、Bun、Cloudflare Workers、Supabase Edge Functions、SvelteKit、Remix、TanStack Start、Astro endpoints 和 Next.js Route Handlers(NextRequest 扩展自 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 });
  }
}

此处理器接受带有 ?products=<product_id> 的 GET 请求,并将可选的客户预填参数直接传递给 polar.checkouts.create()。删除调用方永远不会提供的字段;根据用例需要,添加上表中的字段(metadata、embedOrigin、locale 等)。如果前端采用服务器端渲染,且已经知道用户身份,请从身份验证上下文中直接指定 externalCustomerId,而不是从查询参数中读取。

框架变体

处理器主体完全相同,只有签名、URL 访问方式和响应构造函数不同。

Next.js(App Router): 将规范处理器放入 app/api/checkout/route.ts。NextRequest/NextResponse 是可选的;使用 request: Request 即可。

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(服务器路由): 使用 defineEventHandler、getQuery、sendRedirect(event, url, 302) 编写 server/api/checkout.get.ts。

SvelteKit: 在 src/routes/api/checkout/+server.ts 中导出 GET({ request, url }) — 规范处理器可直接使用。

Astro: 在 src/pages/api/checkout.ts 中导出 export const GET: APIRoute = async ({ request }) => { ... }。


配方 2 — 客户门户端点

目标: 创建 Polar 客户会话并将用户 302 重定向到托管客户门户(订阅、发票、支付方式)的身份验证端点。

底层 API: POST /v1/customer-sessions/(SDK:polar.customerSessions.create(...))。完整架构:https://polar.sh/docs/api-reference/customer-portal/sessions/create。

polar.customerSessions.create() 字段参考

请求正文是一个联合类型 — 传入 customerId 或 externalCustomerId,不可同时传入两者。

SDK 字段API 字段必填说明
customerIdcustomer_id两者之一已有 Polar 客户的 UUID。
externalCustomerIdexternal_customer_id两者之一你自己的用户 ID。
returnUrlreturn_url否门户页面上的返回按钮 URL。
memberIdmember_id否仅适用于启用了 member_model_enabled 的组织。Polar 客户成员 UUID。个人客户默认使用所有者成员。
externalMemberIdexternal_member_id否你自己的成员 ID,可替代 memberId。

响应: { id, customer_portal_url, token, expires_at, customer, customer_id, return_url, ... }。将用户重定向到 customer_portal_url。如果想在自己的 UI 中呈现门户数据,而不是进行重定向,token 和 customer 会很有用。

规范实现
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 });
  }
}

如果你有 Polar 客户 ID,而不是自己的用户 ID,请在 polar.customerSessions.create({ ... }) 调用中将 externalCustomerId 替换为 customerId。通常应该使用 externalCustomerId — 你的身份验证系统已经知道用户的 ID。

框架变体

结构与 Checkout 相同 — 只有请求/响应惯用写法不同。按照框架要求读取身份验证状态(Next.js:从 next/headers 获取 cookies(),Express:req.user,Hono:c.get('user') 等),并将结果传入 polar.customerSessions.create。


配方 3 — Webhook 端点

目标: 接收 Polar webhook 事件、验证签名,并分发给相应的事件处理器。

约定: 在 Polar 控制面板中配置一个位于稳定 URL 的 POST 端点。使用 @polar-sh/sdk/webhooks 中的 validateEvent,通过 webhook-id、webhook-timestamp、webhook-signature 请求头验证签名。成功时返回 200,签名不匹配时返回 403。

规范实现
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 具有完整类型定义 — TypeScript 会在每个 case 中缩窄 event.data 的类型。

关键细节
  1. 在 validateEvent 中使用原始请求正文。 如果框架已为你解析 JSON(例如 Express 的 express.json()、Fastify 的默认正文解析器),请在此路由上禁用它,或手动读取原始正文。重新序列化 JSON 会导致签名验证失败。
  2. 快速响应。 Polar 会在收到非 2xx 响应或请求超时时重试。如果处理程序运行缓慢(例如发送邮件、同步库存),请将任务加入队列(队列/cron/后台作业),并立即返回 200。
  3. 幂等性。 Polar 可能会重新投递事件。请根据 webhook-id 标头去重——它在每次投递时唯一,并会在重试时复用(Standard Webhooks 规范)。不要根据 event.data.id 去重:它是资源 ID,同一资源的不同事件(例如 order.created 和 order.paid)会共享该 ID。
  4. 保持路由公开。 Polar 不会跟随重定向,因此,位于身份验证中间件之后且会重定向到登录页面的路由会导致每次投递都失败。签名就是身份验证凭据。
本地测试

开发服务器运行时,使用 Polar CLI 转发事件并发送一个事件(设置步骤见 polar-testing 中的“本地 Webhook 测试”):

polar listen 3000/api/polar/webhook   # keep it running in the background
polar trigger order.paid              # exits 1 and prints the response when the handler fails

在本地,POLAR_WEBHOOK_SECRET 是通过 polar listen --print-secret 获取的密钥。

框架差异

Next.js Route Handler: 将标准处理程序放入 app/api/polar/webhook/route.ts。App Router 不会自动解析正文,因此使用 request.text() 是正确做法。

Express: 仅在此路由上使用原始正文解析器:

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: 添加一个能保留原始正文的内容类型解析器,或使用 @fastify/raw-body 提供的 request.rawBody。

Hono: await c.req.text() 会返回原始正文,c.req.header("webhook-id") 会读取标头。

Elysia: 对标准 Request 使用 await request.text()。

SvelteKit: 在 +server.ts 中使用 await event.request.text()。

Astro: 在 APIRoute 中使用 await request.text()。

Nuxt: 使用 h3 中的 await readRawBody(event)。


  • 不要安装 @polar-sh/<framework> 软件包(例如 @polar-sh/express)——它们已弃用。对于这些示例,只需使用 @polar-sh/sdk。仅以下情况例外:Next.js App Router 使用 @polar-sh/nextjs,Better Auth 使用 @polar-sh/better-auth。

评论

支持 Markdown