mcpField
P
polar-integration

polar-integration

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

关于 polar-integration

面向集成 Polar 支付的开发者,提供基于 @polar-sh/sdk 的 Checkout、Customer Portal 和 Webhooks 实现指南。采用框架无关的 Web 标准示例,并补充常见框架的接入方式。

主要功能

  1. 01创建 Checkout 会话并重定向
  2. 02创建 Customer Portal 会话
  3. 03验证并分发 Webhooks 事件
  4. 04提供多框架适配示例

适用场景

  1. 01为应用添加托管结账流程
  2. 02让客户管理订阅和付款方式
  3. 03接收并处理支付及订阅事件

安装

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

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

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

详细介绍

Polar SDK 集成

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

设置

安装 SDK。1.0 及更高版本需要 Node.js 22 或更新版本:

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

如果项目已使用低于 1.0 的 @polar-sh/sdk(特征:new Polar(...)、successUrl 等 camelCase 字段、从 @polar-sh/sdk/webhooks 导入),请先按照迁移指南升级。不要混用新旧约定。

不要安装已弃用的 @polar-sh/<framework> 适配器(@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 和 @polar-sh/better-auth 仍在维护中;如果项目已在使用它们,请继续保留。

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

  • POLAR_ACCESS_TOKEN — Polar 控制面板中的组织访问令牌(Settings → Developers)。
  • POLAR_WEBHOOK_SECRET — 仅 webhook 示例需要。
  • POLAR_ENVIRONMENT — 开发期间使用 sandbox;生产环境中省略此变量或设为 production。Sandbox 和生产环境相互独立,因此各自都需要单独的令牌。

SDK 按 API 版本管理:导入路径会固定版本。除非项目已导入其他版本,否则请使用当前版本 2026-10。在仅供服务端使用的模块中创建一个客户端,并重复使用:

import { createPolar, type Environment } from "@polar-sh/sdk/2026-10";

const accessToken = process.env.POLAR_ACCESS_TOKEN;
if (!accessToken) throw new Error("POLAR_ACCESS_TOKEN is required");

export const polar = createPolar({
  accessToken,
  environment: (process.env.POLAR_ENVIRONMENT as Environment | undefined) || "production",
});

以下示例会导入此 polar 客户端。各示例相互独立——只需实现用户要求的部分。

约定
  • 服务和方法名称使用 camelCase(polar.checkouts.create、polar.customerSessions.create);所有请求和响应字段均使用 snake_case,与 HTTP API 完全一致。
  • 路径参数按位置传入(polar.checkouts.get(id)),请求体是单个对象。
  • 分页列表通过 iter* 方法获取,并使用 for await 消费。

示例 1 — Checkout 端点

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

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

polar.checkouts.create() 字段参考
字段必填说明
products是产品 UUID 数组。默认选中第一个。
customer_id否现有 Polar 客户 ID。
external_customer_id否你自己的用户 ID。如果没有匹配的 Polar 客户,则会使用此外部 ID 创建一个客户。
customer_email否
customer_name否
customer_billing_name否
customer_billing_address否{ country: "US", line1, line2, city, state, postal_code }。设置地址时,必须提供 country(ISO alpha-2)。
customer_tax_id否
customer_ip_address否用于选择展示货币。
customer_metadata否会复制到创建的客户资料中。
is_business_customer否默认为 false。如果为 true,则必须提供完整的账单地址和名称。
metadata否会复制到生成的订单/订阅中。
custom_field_data否自定义字段 slug → 值的映射。
discount_id否
allow_discount_codes否默认为 true。
require_billing_address否默认为 false。美国客户始终需要提供。
seats否预设席位数(仅适用于按席位定价)。
min_seats / max_seats否仅适用于按席位定价。
units / min_units / max_units否仅适用于按单位定价。
amount否以美分为单位。仅用于 custom(随意付费)价格。
subscription_id否升级现有的免费订阅。
success_url否见下文。
return_url否结账页面上的返回按钮 URL。
embed_origin否在 iframe 中嵌入结账页面时设置。
locale否IETF BCP 47(en、en-US、fr-CA 等)。默认为 en。
currency否ISO 4217(例如 usd、eur)。
trial_interval / trial_interval_count否覆盖产品试用期。
allow_trial否默认为 true。设为 false 可禁用已配置的试用期。
prices否产品 ID → 临时价格的映射(覆盖目录价格)。

响应: { id, url, client_secret, expires_at, status, ... }。将用户重定向到 url。如果要改为在 iframe 中嵌入结账页面,请将 url 传给 @polar-sh/checkout 中的 PolarEmbedCheckout.create,并设置 embed_origin。client_secret 仅用于客户端结账调用和自定义结账界面。

特定字段说明
  • success_url 和 {CHECKOUT_ID}。 Polar 会执行字面字符串替换:在重定向时,URL 中任何 {CHECKOUT_ID} 标记都会替换为实际的会话 ID。官方 API 文档中的示例使用 ?checkout_id={CHECKOUT_ID};参数名可以自行设定。如果成功页面需要通过 polar.checkouts.get(checkoutId) 获取会话,此功能会很有用。不要将重定向视为付款证明——应根据经过验证的 Webhook 或客户状态授予访问权限。
  • theme(UI 提示,不是 API 字段)。 托管结账 UI 支持在 URL 中使用 ?theme=light|dark。若要应用主题,请在创建会话后将其追加到 checkout.url。
  • 客户 ID 来自你的身份验证系统。 用户登录后,应从服务端会话设置 customer_id 或 external_customer_id,绝不要从查询字符串中获取:该 ID 会将订单关联到相应客户。
规范实现(Web 标准 — 请求/响应)
import { polar } from "./polar";

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

export async function handleCheckout(request: Request): Promise<Response> {
  const url = new URL(request.url);
  const products = url.searchParams.getAll("products");

  if (products.length === 0) {
    return Response.json({ error: "Missing products in query params" }, { status: 400 });
  }

  const ipAddress =
    request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
    request.headers.get("x-real-ip") ??
    request.headers.get("cf-connecting-ip") ??
    undefined;

  try {
    const checkout = await polar.checkouts.create({
      products,
      success_url: SUCCESS_URL,
      external_customer_id: await getUserId(request), // your auth helper, or undefined for guests
      customer_email: url.searchParams.get("customer_email") ?? undefined,
      customer_ip_address: ipAddress,
      discount_id: url.searchParams.get("discount_id") ?? undefined,
    });

    const redirectUrl = new URL(checkout.url);
    if (THEME) redirectUrl.searchParams.set("theme", THEME);

    return Response.redirect(redirectUrl.toString(), 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}

此处理程序接受带有 ?products=<product_id> 的 GET 请求。移除不需要的字段,并根据需要添加上表中的字段(metadata、embed_origin、locale 等)。

框架差异

处理程序的主体完全相同——只有入口点不同:

  • 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) } }。

  • 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 };(从 env 参数而不是 process.env 读取密钥)。

  • Nuxt(服务端路由): 使用 server/api/checkout.get.ts,并调用 defineEventHandler((event) => handleCheckout(toWebRequest(event)))。

  • Express:

    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:

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

配方 2 — 客户门户端点

**目标:**创建一个经过身份验证的端点,用于创建 Polar 客户会话,并将用户通过 302 重定向到托管的客户门户(订阅、发票、付款方式)。

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

polar.customerSessions.create() 字段参考

请求正文是一个联合类型——传入 customer_id 或 external_customer_id,不能同时传入两者。

字段必需说明
customer_id二选一现有 Polar 客户 UUID。
external_customer_id二选一你自己的用户 ID。
return_url否门户页面上的返回按钮 URL。
member_id否仅适用于启用了 member_model_enabled 的组织。Polar 客户成员 UUID。个人客户默认使用所有者成员。
external_member_id否你自己的成员 ID,可替代 member_id。

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

标准实现
import { polar } from "./polar";

const RETURN_URL = "https://example.com/account";

export async function handlePortal(request: Request): Promise<Response> {
  const externalCustomerId = await getUserId(request); // your auth helper
  if (!externalCustomerId) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const session = await polar.customerSessions.create({
      external_customer_id: externalCustomerId,
      return_url: RETURN_URL,
    });
    return Response.redirect(session.customer_portal_url, 302);
  } catch (error) {
    console.error(error);
    return new Response(null, { status: 500 });
  }
}

如果你有 Polar 客户 ID,而不是自己的用户 ID,请将 external_customer_id 换成 customer_id。通常你需要的是 external_customer_id——你的身份验证系统已经知道用户的 ID。绝不要让调用方选择其他客户的 ID。

框架差异

使用与配方 1 相同的入口点。按照框架要求读取身份验证状态(Next.js:从 next/headers 调用 await cookies();Express:req.user;Hono:c.get("user") 等)。


配方 3 — Webhook 端点

**目标:**接收 Polar webhook 事件、验证签名,并将事件分发给各个事件处理程序。

**约定:**在 Polar 控制面板中配置一个位于稳定 URL 的 POST 端点。使用与客户端相同版本的导入中的 webhooks.validateEvent 验证 webhook-id、webhook-timestamp 和 webhook-signature 标头。保持该版本与控制面板中 webhook 端点的 API 版本一致,以确保负载与 SDK 的类型相匹配。

标准实现
import { webhooks } from "@polar-sh/sdk/2026-10";

const WEBHOOK_SECRET = process.env.POLAR_WEBHOOK_SECRET;
if (!WEBHOOK_SECRET) throw new Error("POLAR_WEBHOOK_SECRET is required");

export async function handleWebhook(request: Request): Promise<Response> {
  const body = await request.text(); // must be the raw body, not parsed JSON
  const headers = {
    "webhook-id": request.headers.get("webhook-id") ?? "",
    "webhook-timestamp": request.headers.get("webhook-timestamp") ?? "",
    "webhook-signature": request.headers.get("webhook-signature") ?? "",
  };

  let event: webhooks.WebhookPayload;
  try {
    event = await webhooks.validateEvent(body, headers, WEBHOOK_SECRET!);
  } catch (error) {
    if (error instanceof webhooks.PolarWebhookVerificationError) {
      return Response.json({ received: false }, { status: 403 });
    }
    if (error instanceof webhooks.PolarWebhookUnknownTypeError) {
      // Signed, but an event type this SDK version doesn't know. Acknowledge it so Polar doesn't retry.
      return Response.json({ received: true }, { status: 202 });
    }
    if (error instanceof webhooks.PolarWebhookError) {
      return Response.json({ received: false }, { status: 400 });
    }
    throw error;
  }

  switch (event.type) {
    case "order.paid":
      // event.data is the Order — fulfill, send receipt, etc.
      break;

    case "subscription.active":
    case "subscription.canceled":
    case "subscription.uncanceled":
    case "subscription.revoked":
      // event.data is the Subscription — flip entitlements in your DB
      break;

    case "benefit_grant.created":
    case "benefit_grant.updated":
    case "benefit_grant.revoked":
      // grant or revoke the user's access to a benefit
      break;

    case "customer.state_changed":
      // event.data is the full customer state — reconcile access from granted_benefits
      break;
  }

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

event 完全经过类型定义——TypeScript 会在每个 case 中收窄 event.data 的类型。其他事件包括 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 和 organization.updated。仅为你处理的事件订阅此端点。

关键细节
  1. 使用原始请求正文进行 validateEvent。如果框架已为你解析了 JSON(例如 Express 的 express.json()、Fastify 的默认正文解析器),重新序列化后的 JSON 会导致签名校验失败。
  2. 在 2 秒内响应。 Polar 会在 10 秒后超时,最多重试失败的投递 10 次,并在连续失败 10 次后禁用端点。持久化或将事件加入队列,然后返回 2xx,并在后台任务中处理耗时操作(发送邮件、资源配置)。
  3. 保证幂等性。 Polar 可能会重新投递事件。根据 webhook-id 标头去重——该标头对每次投递都是唯一的,并会在重试时复用。不要根据 event.data.id 去重:它是资源 ID,同一资源的不同事件(例如 order.created 和 order.paid)会共享此 ID。
  4. 保持路由公开。 Polar 不会跟随重定向,因此,如果路由位于会将请求重定向到登录页面的身份验证中间件后面,每次投递都会失败。签名就是身份验证。
在本地测试

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

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

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

框架差异

使用与配方 1 相同的入口点,并采用 POST(Remix 中为 action),此外:

  • Next.js Route Handler: app/api/polar/webhook/route.ts:export const POST = handleWebhook;。App Router 不会解析正文,因此使用 request.text() 是正确的。

  • Express: 在 app.use(express.json()) 或任何其他正文解析器之前,使用 express.raw 注册 webhook 路由;一旦更早的解析器读取了数据流,带签名的字节就会丢失。将 Buffer 直接传给 validateEvent:

    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: 注册 fastify-raw-body,并在路由上设置 config: { rawBody: true },然后传入 request.rawBody。

  • Hono: app.post("/polar/webhook", (c) => handleWebhook(c.req.raw));

  • Nuxt: 使用 h3 的 await readRawBody(event) 获取正文,使用 getHeader(event, "webhook-id") 获取标头。

评论

支持 Markdown