关于 polar-integration
面向集成 Polar 支付的开发者,提供基于 @polar-sh/sdk 的 Checkout、Customer Portal 和 Webhooks 实现指南。采用框架无关的 Web 标准示例,并补充常见框架的接入方式。
主要功能
- 01创建 Checkout 会话并重定向
- 02创建 Customer Portal 会话
- 03验证并分发 Webhooks 事件
- 04提供多框架适配示例
适用场景
- 01为应用添加托管结账流程
- 02让客户管理订阅和付款方式
- 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。仅为你处理的事件订阅此端点。
关键细节
- 使用原始请求正文进行
validateEvent。如果框架已为你解析了 JSON(例如 Express 的express.json()、Fastify 的默认正文解析器),重新序列化后的 JSON 会导致签名校验失败。 - 在 2 秒内响应。 Polar 会在 10 秒后超时,最多重试失败的投递 10 次,并在连续失败 10 次后禁用端点。持久化或将事件加入队列,然后返回 2xx,并在后台任务中处理耗时操作(发送邮件、资源配置)。
- 保证幂等性。 Polar 可能会重新投递事件。根据
webhook-id标头去重——该标头对每次投递都是唯一的,并会在重试时复用。不要根据event.data.id去重:它是资源 ID,同一资源的不同事件(例如order.created和order.paid)会共享此 ID。 - 保持路由公开。 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")获取标头。