PAYATHON 2026

如何为 Chrome extension 添加按月订阅付款

支付阿杰

结论

Chrome extension 要实现按月订阅,建议采用“第三方支付平台 + 自有后端 + 订阅权限校验”的方案:

  1. 在 Stripe Billing、Paddle 等平台创建月度订阅,并使用平台托管的结账页面。
  2. 扩展负责用户登录、打开付款页面和查询订阅状态。
  3. 后端负责创建 Checkout Session、接收 Webhook,以及保存用户的订阅权限。
  4. 是否开放高级功能,以后端返回的权限状态为准。

不要在扩展中保存支付密钥或处理银行卡信息,也不要再使用已经停止服务的 Chrome Web Store Payments。最终采用哪种结账方式,还要根据发布时有效的 Chrome Web Store 政策,以及支付平台支持的地区来决定。

为什么需要后端

Chrome extension 的代码会下载到用户设备,无法安全保存 STRIPE_SECRET_KEY 等敏感凭据。压缩或混淆代码也解决不了这个问题,攻击者仍可能提取密钥。

前端显示“付款成功”同样不能证明订阅仍然有效。用户可能取消付款、申请退款或发生拒付,订阅也可能因扣款失败进入异常状态。后端需要通过支付平台的 Webhook 同步订阅状态,再向扩展提供查询结果。

常见的数据流如下:

Chrome extension
    ├── 用户登录
    ├── 请求后端创建 Checkout Session
    └── 查询当前 entitlement
              │
              ▼
          自有后端
    ├── 调用支付平台 API
    ├── 接收并验证 Webhook
    └── 保存订阅和权限状态
              │
              ▼
        Stripe / Paddle

这里的 entitlement 表示用户是否有权使用付费功能。与检查某个页面是否显示“付款成功”相比,查询 entitlement 更可靠。

实现步骤

1. 建立用户身份系统

首先要解决“付款记录属于哪个扩展用户”的问题。可以使用:

  • 自有邮箱登录;
  • Google OAuth;
  • Firebase Authentication、Auth0、Supabase Auth 等身份服务;
  • 通过 chrome.identity 获取授权身份,再交给后端验证。

不要把浏览器安装 ID 作为唯一的订阅账号。用户重装扩展、清理数据或更换电脑后,安装 ID 可能会变化。订阅应该绑定到可以恢复的用户账号。

2. 在支付平台创建月度价格

以 Stripe 为例,需要在 Dashboard 中创建:

  • 一个 Product,例如 Extension Pro;
  • 一个按月循环收费的 Price;
  • 对应的 price_... 标识。

金额、币种、税务处理和试用期应该在支付平台中配置,不要允许扩展传入任意价格。后端只接受预先允许的 Price ID,防止用户通过篡改请求修改金额。

3. 后端创建结账会话

下面的示例使用 Node.js、Express 和 Stripe。实际项目还要加入登录鉴权、参数校验、限流和错误处理。

import express from "express";
import Stripe from "stripe";

const app = express();
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

app.use(express.json());

app.post("/api/billing/checkout", requireUser, async (req, res) => {
  const user = req.user;

  const session = await stripe.checkout.sessions.create({
    mode: "subscription",
    line_items: [
      {
        price: process.env.STRIPE_MONTHLY_PRICE_ID,
        quantity: 1
      }
    ],
    client_reference_id: user.id,
    customer_email: user.email,
    success_url: "https://example.com/billing/success?session_id={CHECKOUT_SESSION_ID}",
    cancel_url: "https://example.com/billing/cancel",
    metadata: {
      userId: user.id
    }
  });

  res.json({ url: session.url });
});

requireUser 代表项目自己的鉴权中间件。不要信任客户端直接提交的 userId,后端应该从已验证的登录令牌中获取当前用户。

4. 从扩展打开托管付款页面

扩展向后端请求 Checkout URL,然后在普通浏览器标签页中打开:

async function startSubscription() {
  const token = await getAccessToken();

  const response = await fetch(
    "https://api.example.com/api/billing/checkout",
    {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${token}`,
        "Content-Type": "application/json"
      }
    }
  );

  if (!response.ok) {
    throw new Error("Unable to create checkout session");
  }

  const { url } = await response.json();

  if (!url) {
    throw new Error("Checkout URL is missing");
  }

  await chrome.tabs.create({ url });
}

如果扩展使用 Manifest V3,需要在 manifest.json 中配置访问后端所需的权限:

{
  "manifest_version": 3,
  "name": "Example Extension",
  "version": "1.0.0",
  "permissions": ["storage"],
  "host_permissions": [
    "https://api.example.com/*"
  ]
}

是否需要 tabs 权限,要看扩展具体使用了哪个 Chrome API。不要为了方便而申请与功能无关的广泛权限。

5. 使用 Webhook 同步订阅状态

支付平台会通过 Webhook 通知后端订阅创建、续费成功、取消、退款和支付失败等事件。后端必须验证 Webhook 签名。

Stripe Webhook 路由需要读取原始请求体,所以通常要在全局 express.json() 之前注册:

app.post(
  "/api/billing/webhook",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers["stripe-signature"],
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (error) {
      return res.status(400).send("Invalid webhook signature");
    }

    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;

        await linkSubscriptionToUser({
          userId: session.metadata?.userId,
          customerId: session.customer,
          subscriptionId: session.subscription
        });
        break;
      }

      case "customer.subscription.updated":
      case "customer.subscription.deleted": {
        const subscription = event.data.object;

        await updateSubscriptionStatus({
          subscriptionId: subscription.id,
          status: subscription.status,
          currentPeriodEnd: getCurrentPeriodEnd(subscription)
        });
        break;
      }

      case "invoice.paid":
      case "invoice.payment_failed":
        await processInvoiceEvent(event);
        break;
    }

    res.sendStatus(200);
  }
);

支付平台可能调整事件结构,开发时应参考当前 SDK 的类型定义和官方文档。例如,订阅周期字段在不同 API 版本中的位置可能不同,示例里的 getCurrentPeriodEnd() 需要按照实际响应来实现。

Webhook 处理还要做到:

  • 使用 event.id 进行幂等处理;
  • 能够处理事件的重复投递;
  • 不假设事件会按顺序到达;
  • 先快速返回成功,再异步处理耗时任务;
  • 保存必要的事件日志,以便排查账单问题。

6. 提供权限查询接口

扩展不应该直接调用 Stripe 查询用户订阅,而应该查询自己的后端:

app.get("/api/entitlements", requireUser, async (req, res) => {
  const entitlement = await findEntitlement(req.user.id);

  res.json({
    plan: entitlement?.plan ?? "free",
    active: entitlement?.active ?? false,
    validUntil: entitlement?.validUntil ?? null
  });
});

扩展根据返回结果启用或禁用付费功能:

async function refreshEntitlements() {
  const token = await getAccessToken();

  const response = await fetch(
    "https://api.example.com/api/entitlements",
    {
      headers: {
        "Authorization": `Bearer ${token}`
      }
    }
  );

  if (!response.ok) {
    throw new Error("Unable to load subscription status");
  }

  const entitlement = await response.json();

  await chrome.storage.local.set({
    entitlement,
    entitlementCheckedAt: Date.now()
  });

  return entitlement;
}

本地缓存可以加快启动速度,但不能作为永久可信的数据源。如果某项功能成本较高或容易被滥用,例如云端处理或 API 调用额度,后端应该在每次请求时重新验证权限。

7. 提供订阅管理入口

用户还需要修改付款方式、下载发票或取消订阅。通常可以直接使用支付平台提供的 Customer Portal:

app.post("/api/billing/portal", requireUser, async (req, res) => {
  const customerId = await findStripeCustomerId(req.user.id);

  if (!customerId) {
    return res.status(404).json({ error: "Billing customer not found" });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer: customerId,
    return_url: "https://example.com/account"
  });

  res.json({ url: session.url });
});

扩展拿到 URL 后,同样通过 chrome.tabs.create() 打开。

订阅状态如何判断

不能只看订阅记录是否存在,至少要区分以下状态:

  • active:通常可以使用付费功能;
  • trialing:是否可用由试用策略决定;
  • past_due:可以设置一段较短的宽限期;
  • unpaid:通常应该暂停付费权限;
  • canceled:需要判断订阅是立即失效,还是在当前计费周期结束后失效;
  • incomplete:首次付款尚未完成,不应该直接开通权限。

各支付平台的状态名称和具体含义可能不同。建议在数据库中保留原始支付状态,同时计算应用自己的权限字段,例如:

{
  "userId": "user_123",
  "plan": "pro",
  "billingStatus": "active",
  "entitled": true,
  "validUntil": "2026-09-29T00:00:00Z"
}

这样,业务代码只需要判断 entitled,支付状态的转换规则统一由后端处理。

支付平台如何选择

Stripe Billing

Stripe Billing 适合已经有后端,并且希望自己控制结账流程和用户数据的团队。它的 API、Webhook 和订阅管理功能比较完整,但商户通常要承担更多税务、退款和合规工作。

Paddle 等 Merchant of Record 平台

在支持的地区,这类平台通常可以作为记录商户,代为处理部分销售税、VAT 和账单事务。相应的代价可能包括更高的费用、更严格的审核,以及对结账流程的限制。

选择平台前,需要确认:

  • 是否支持商户注册地和目标销售地区;
  • 是否支持需要的币种和付款方式;
  • 平台属于支付处理商还是 Merchant of Record;
  • 税务、退款和拒付分别由谁负责;
  • 是否允许销售 Chrome extension 的数字服务;
  • 提现周期、交易费用和订阅管理能力是否合适。

这些条件会随着地区和平台政策变化,上线前需要查看最新条款。

重要注意事项

不要把密钥放进扩展

下面这些内容都不应该出现在扩展源码、manifest.json 或 chrome.storage 中:

STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET
数据库管理员密钥
服务账号私钥

扩展中只能使用可以公开的标识,例如受限的 publishable key。如果使用托管 Checkout,通常连 publishable key 也不必放进扩展。

不要仅依赖成功跳转

success_url 的作用是改善用户体验,不能作为开通权限的凭据。是否开通权限,应该以经过签名验证的 Webhook,以及后端从支付平台确认的订阅状态为准。

考虑离线使用

如果扩展需要离线工作,可以缓存最近一次有效权限,并设置有限的离线宽限期。例如,在最后一次成功校验后的 24 小时内继续允许使用。宽限期越长,用户在权限被篡改或退款后继续使用的风险就越高。

明确取消和退款规则

购买前应该清楚说明:

  • 自动续费周期;
  • 如何取消;
  • 取消后何时失效;
  • 是否提供试用;
  • 退款条件;
  • 数据如何保存和删除。

还要提供隐私政策、服务条款,以及用户可以联系到的客户支持渠道。

遵守商店和支付政策

Chrome Web Store Payments 已经不适合用于新的订阅方案。即使使用外部支付服务,也要确保付款入口、功能描述、用户数据处理方式和远程代码行为符合发布时有效的 Chrome Web Store Developer Program Policies。

如果扩展还会发布到其他浏览器商店,需要分别检查各商店对外部支付和数字订阅的规定,不能假设所有商店都采用相同规则。

推荐的最小可用方案

对于准备首次上线的独立扩展,可以采用下面这套组合:

  • Google OAuth 或邮箱登录;
  • Stripe Checkout 月度订阅;
  • 一个小型后端服务;
  • 通过 Webhook 同步订阅;
  • 在数据库中保存用户与订阅的映射关系;
  • 由 /api/entitlements 返回付费权限;
  • 使用 Customer Portal 管理取消订阅和付款方式。

这套方案的实现成本相对可控,以后也可以继续增加年付、优惠码、试用期、团队套餐和按量计费。

备注:内容仅供参考。