博客 资源 关于 搜索 主题
教程

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 客户端:

src/lib/polar.ts
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',
});

所需的环境变量:

.env
POLAR_ACCESS_TOKEN=your_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret

从你的 Polar 仪表盘 获取这些信息。在开发期间使用沙盒模式——它提供测试卡且不会扣除真实资金。

创建结账会话 #creating-checkout-sessions

当用户点击“订阅”或“购买”时,创建结账会话并重定向他们:

src/routes/checkout.ts
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 路由示例:

src/routes/api.ts
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 规范。你必须验证签名以防止伪造请求:

src/webhooks/polar.ts
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 中间件之前注册它:

src/app.ts
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);
src/routes/webhooks.ts
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 };

事件处理器

以下是处理关键订阅事件的方法:

src/services/polar-events.ts
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

取消订阅

允许用户从你的应用中取消(他们在周期结束前仍保留访问权限):

src/services/subscriptions.ts
import { polar } from '../lib/polar';

export async function cancelSubscription(subscriptionId: string) {
    await polar.subscriptions.update({
        id: subscriptionId,
        subscriptionUpdate: {
            cancelAtPeriodEnd: true,
        },
    });
}

客户门户

Polar 提供了一个托管门户,客户可以在其中管理账单、更新支付方式和查看发票:

src/services/subscriptions.ts
export async function getCustomerPortalUrl(polarCustomerId: string) {
    const session = await polar.customerSessions.create({
        customerId: polarCustomerId,
    });

    return session.customerPortalUrl;
}

Express 路由:

src/routes/api.ts
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. 未处理所有订阅状态

订阅可以是:activecanceledpast_dueincompletetrialingrevoked。不要只检查 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 文档

分类 教程
分享

将最新的AI见解发送到您的收件箱

了解最新的趋势、教程和行业见解。加入信任我们新闻通讯的开发人员社区。

仅新账户。提交您的电子邮件即表示您同意我们的 隐私政策