Node.js 中的 Polar.sh 支付:后端完整指南
更新于 2026年1月3日
Polar.sh 支付与 Node.js 后端集成
这篇文章扩展了我关于 Polar.sh 支付的 X 帖子,包含完整的代码示例、错误处理以及生产环境使用中的经验教训。
如果你正在构建 SaaS 或销售数字产品,你需要一个支付提供商。Polar.sh 已成为我独立项目的首选——以下是如何将其与 Node.js 后端集成。
为什么选择 Polar.sh? #why-polar-sh
在深入代码之前,以下是我选择 Polar.sh 而非其他替代方案的原因:
与 Stripe 相比:Polar 开箱即用的功能更多。你无需自行构建即可获得托管结账页面、客户门户和订阅生命周期管理。虽然 Stripe 给予你更多控制权,但需要编写更多代码。
与 Paddle/Lemon Squeezy 相比:两者都具有商户记录(Merchant of Record)的优势(负责处理增值税/销售税),但 Polar 是专门为开发者和开源项目构建的。其开发者体验(DX)明显更好。
Polar 为你处理:
- 托管结账页面
- 支付处理
- 客户门户
- 订阅生命周期
- 税务合规(作为商户记录)
你需要处理:
- Webhook 事件
- 用户权限
- 使用量追踪
项目设置 #project-setup
安装 SDK 和 Webhook 验证库:
npm install @polar-sh/sdk standardwebhooks
初始化 Polar 客户端:
import { Polar } from '@polar-sh/sdk';
export const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN!,
server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
所需的环境变量:
POLAR_ACCESS_TOKEN=your_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret
从你的 Polar 仪表盘 获取这些信息。在开发期间使用沙盒模式——它提供测试卡且不会扣除真实资金。
创建结账会话 #creating-checkout-sessions
当用户点击“订阅”或“购买”时,创建结账会话并重定向他们:
import { polar } from '../lib/polar';
export async function createCheckout(user: { id: string; email: string }, productId: string) {
const checkout = await polar.checkouts.create({
products: [productId],
successUrl: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
customerEmail: user.email,
metadata: {
userId: String(user.id),
},
});
return checkout.url;
}
关键点:
metadata.userId将 Polar 客户链接到你的用户。在 Webhook 中你需要用到这个。successUrl可以包含{CHECKOUT_SESSION_ID},Polar 会将其替换为实际的 ID。products接受一个数组——你可以在一个结账中捆绑多个产品。
Express 路由示例:
router.post('/create-checkout', async (req, res) => {
const user = req.user; // 来自你的认证中间件
const { productId } = req.body;
if (!productId) {
return res.status(400).json({ error: '需要产品 ID' });
}
try {
const checkoutUrl = await createCheckout(user, productId);
res.json({ url: checkoutUrl });
} catch (error) {
console.error('结账创建失败:', error);
res.status(500).json({ error: '创建结账失败' });
}
});
Webhook 处理 #webhook-handling
Webhook 是 Polar 告知你的应用支付事件的方式。这是最关键的部分,必须正确处理。
签名验证
Polar 使用 Standard Webhooks 规范。你必须验证签名以防止伪造请求:
import { Webhook } from 'standardwebhooks';
const secret = 'whsec_' + Buffer.from(
process.env.POLAR_WEBHOOK_SECRET!
).toString('base64');
const wh = new Webhook(secret);
export function verifyWebhook(rawBody: string, headers: Record<string, string>) {
try {
return wh.verify(rawBody, headers);
} catch (error) {
console.error('Webhook 验证失败:', error);
throw new Error('无效的 Webhook 签名');
}
}
路由设置(关键!)
Webhook 路由需要原始请求体来进行签名验证。确保在 JSON 中间件之前注册它:
import express from 'express';
import { webhookRouter } from './routes/webhooks';
const app = express();
// Webhook 路由优先 - 需要原始 body
app.use('/api/webhooks', webhookRouter);
// Webhooks 之后进行 JSON 解析
app.use(express.json());
// 其他路由...
app.use('/api', apiRouter);
import { Router } from 'express';
import express from 'express';
import { verifyWebhook } from '../webhooks/polar';
import { handlePolarEvent } from '../services/polar-events';
const router = Router();
router.post('/polar',
express.raw({ type: 'application/json' }),
async (req, res) => {
try {
const event = verifyWebhook(
req.body.toString(),
req.headers as Record<string, string>
);
await handlePolarEvent(event);
res.status(200).json({ received: true });
} catch (error) {
console.error('Webhook 错误:', error);
res.status(400).json({ error: 'Webhook 处理失败' });
}
}
);
export { router as webhookRouter };
事件处理器
以下是处理关键订阅事件的方法:
import { db } from '../lib/db';
interface PolarEvent {
type: string;
data: {
id: string;
status: string;
customer_id: string;
metadata?: { userId?: string };
current_period_end?: string;
};
}
export async function handlePolarEvent(event: PolarEvent) {
const { type, data } = event;
switch (type) {
case 'subscription.created':
// 将 Polar 客户链接到你的用户
await db.user.update({
where: { id: data.metadata?.userId },
data: {
polarCustomerId: data.customer_id,
subscriptionId: data.id,
subscriptionStatus: 'pending',
},
});
break;
case 'subscription.active':
// 支付成功 - 激活订阅
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: 'active',
currentPeriodEnd: new Date(data.current_period_end!),
// 设置特定计划的限制
apiCallsLimit: 10000,
storageLimit: 5 * 1024 * 1024 * 1024, // 5GB
},
});
break;
case 'subscription.updated':
// 计划变更、续订等
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: data.status,
currentPeriodEnd: new Date(data.current_period_end!),
},
});
break;
case 'subscription.canceled':
// 用户取消 - 在周期结束前仍处于活动状态
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: 'canceled',
canceledAt: new Date(),
},
});
break;
case 'subscription.revoked':
// 立即撤销(支付失败、退款等)
await db.user.update({
where: { polarCustomerId: data.customer_id },
data: {
subscriptionStatus: 'revoked',
apiCallsLimit: 0,
storageLimit: 0,
},
});
break;
case 'order.paid':
// 一次性购买完成
await handleOneTimePurchase(data);
break;
default:
console.log(`未处理的事件类型: ${type}`);
}
}
async function handleOneTimePurchase(data: PolarEvent['data']) {
// 授予终身访问权限、积分等
await db.user.update({
where: { id: data.metadata?.userId },
data: {
lifetimeAccess: true,
},
});
}
订阅管理 #subscription-management
取消订阅
允许用户从你的应用中取消(他们在周期结束前仍保留访问权限):
import { polar } from '../lib/polar';
export async function cancelSubscription(subscriptionId: string) {
await polar.subscriptions.update({
id: subscriptionId,
subscriptionUpdate: {
cancelAtPeriodEnd: true,
},
});
}
客户门户
Polar 提供了一个托管门户,客户可以在其中管理账单、更新支付方式和查看发票:
export async function getCustomerPortalUrl(polarCustomerId: string) {
const session = await polar.customerSessions.create({
customerId: polarCustomerId,
});
return session.customerPortalUrl;
}
Express 路由:
router.post('/billing-portal', async (req, res) => {
const user = req.user;
if (!user.polarCustomerId) {
return res.status(400).json({ error: '未找到订阅' });
}
try {
const portalUrl = await getCustomerPortalUrl(user.polarCustomerId);
res.json({ url: portalUrl });
} catch (error) {
console.error('门户创建失败:', error);
res.status(500).json({ error: '创建门户会话失败' });
}
});
常见陷阱 #common-pitfalls
在生产环境中集成 Polar 后,以下是我见过(也犯过)的错误:
1. JSON 中间件在 Webhooks 之前
如果你遇到签名验证错误,这几乎总是原因。原始 body 在你的 Webhook 处理器看到它之前已经被 express.json() 解析了。
修复: 在 express.json() 之前注册 Webhook 路由。
2. 未处理所有订阅状态
订阅可以是:active、canceled、past_due、incomplete、trialing 或 revoked。不要只检查 active——要处理完整的生命周期。
3. 缺少元数据
如果你忘记在结账元数据中传递 userId,你就无法将 Polar 客户链接到你的用户。始终包含识别信息。
4. Webhook 处理器不具备幂等性
Polar 可能会重试 Webhook。你的处理器应该是幂等的——处理两次相同的事件不应破坏任何东西。
// 错误 - 创建重复记录
await db.payment.create({ data: { orderId: event.data.id, ... } });
// 正确 - 基于唯一 ID 进行 Upsert
await db.payment.upsert({
where: { polarOrderId: event.data.id },
create: { polarOrderId: event.data.id, ... },
update: { ... },
});
5. 阻塞 Webhook 响应
Webhook 端点应快速响应。如果你需要进行繁重的处理,请立即确认并异步处理:
router.post('/polar', express.raw({ type: 'application/json' }), async (req, res) => {
const event = verifyWebhook(req.body.toString(), req.headers);
// 立即响应
res.status(200).json({ received: true });
// 异步处理(在生产环境中使用适当的作业队列)
setImmediate(() => handlePolarEvent(event));
});
测试你的集成 #testing-your-integration
使用沙盒模式
Polar 的沙盒环境允许你在不产生真实费用的情况下测试完整流程:
const polar = new Polar({
accessToken: process.env.POLAR_ACCESS_TOKEN,
server: 'sandbox', // 测试模式
});
测试卡
在沙盒中,使用以下测试卡号:
4242 4242 4242 4242- 成功支付4000 0000 0000 0002- 卡片被拒绝
本地 Webhook 测试
使用 ngrok 或类似工具暴露你的本地服务器:
ngrok http 3000
然后在 Polar 仪表盘中将你的 Webhook URL 设置为:https://your-ngrok-url.ngrok.io/api/webhooks/polar
验证你的设置
上线前的检查清单:
- 结账成功创建并重定向到 Polar
- 成功 URL 重定向回你的应用
- Webhook 签名验证通过
-
subscription.active事件激活用户访问权限 - 客户门户正确加载
- 取消功能正常工作并设置
cancelAtPeriodEnd
这就是完整的 Polar.sh 支付集成。关键在于:让 Polar 处理它擅长的部分(结账 UI、支付处理、客户门户),并将你的代码重点放在 Webhook 处理和用户权限上。
更多详情,请查阅 官方 Polar 文档。