mcpField
P
paddle-checkout-web

paddle-checkout-web

github.com/paddlehq开发9121 次安装5 天前更新
代码仓库

关于 paddle-checkout-web

用于在 Next.js App Router 应用中接入 Paddle 托管结账的技能。内容涵盖浮层和内嵌结账、事件处理、客户信息预填、跳转及动态购物车更新,并将相关服务端任务另行处理。

主要功能

  1. 01配置浮层或内嵌结账
  2. 02处理结账事件和跳转
  3. 03预填已登录客户资料
  4. 04节流更新购物车商品
  5. 05说明结账形式及其限制

适用场景

  1. 01在 Next.js 应用中接入托管结账
  2. 02构建品牌化内嵌支付页面
  3. 03为已登录客户预填结账资料
  4. 04在结账过程中更新购物车

MCP 相关

若有 Paddle MCP 服务,该技能说明如何通过其 search 和 execute 工具执行账户及结账配置操作。

安装

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

$npx skills add https://github.com/paddlehq/paddle-agent-skills --skill paddle-checkout-web

安装后在 Claude Code 中输入/paddle-checkout-web确认已加载。

详细介绍

在 Next.js 中构建 Paddle 结账

何时使用此技能

在 Next.js(App Router)应用中添加托管式 Paddle 结账时,请使用此技能,无论是以覆盖层(模态框样式)还是内嵌方式(嵌入页面)呈现。此技能涵盖初始化、使用商品项打开结账、监听事件、预填客户信息、结账后的重定向,以及适用于购物车类界面的节流 updateItems 模式。

此技能仅适用于客户端。请搭配以下技能使用:

  • webhooks:在服务器上接收 transaction.completed / subscription.created 事件。
  • subscription-sync:将 Paddle 订阅状态同步到数据库中。
  • sandbox-testing:在 Paddle 沙盒中端到端测试集成。

先决条件

你需要:

  • 一个 Paddle 账户,并在开发期间启用沙盒环境。
  • 至少设置一个商品和价格。如果尚未设置,请使用 catalog-setup;记下价格 ID(格式类似 pri_01h...),以便在下文使用。
  • 已获批准用于结账的域名。在沙盒中,结账可以在任何页面上打开,包括 localhost;你添加的域名会自动获批。在正式环境中,用户需在 Paddle > My account > Settings > Website approval 下提交域名;没有用于提交域名的 API。如果有可用的 Paddle MCP 服务,请通过 client.checkoutDomains.list()(GET /checkout-domains)检查批准状态:每个域名都有一个 status,只有状态为 approved 的域名才能进行结账。
  • 已设置默认付款链接。请使用你自己域名下的页面,而不是 localhost。在正式环境中,其主机名必须是账户已批准的域名,或该域名加上 www.。沙盒不要求域名获批。如果沙盒更新返回 checkout_url_domain_not_approved,请在 Paddle > My account > Settings > Website approval 下添加域名;沙盒会立即批准,然后重试。如果有可用的 Paddle MCP 服务,请通过 client.settings.checkoutUrl.get()(GET /settings/checkout-url)读取该设置;未设置前,default_url 的值为 null。征得用户同意后,通过 client.settings.checkoutUrl.update({ default_url: "https://..." })(PATCH /settings/checkout-url,需要 checkout_settings.write 权限)进行设置。如果该操作不可用或返回 forbidden,请让用户在 Paddle > Checkout > Checkout configuration > Default payment link 下设置。
  • 两个环境变量。在 Paddle > My account > Settings > Authentication 下的 Client-side tokens 选项卡中创建客户端令牌:
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=test_...   # client-side token, safe to expose
NEXT_PUBLIC_PADDLE_ENV=sandbox             # or "production"

如果你有可用的 Paddle MCP 服务,请在 execute 中调用 client.clientTokens.create({ name: "Frontend dev token" }),以编程方式创建令牌,而不是让用户从控制面板复制。注意,clientTokens 使用驼峰命名法。创建令牌需要 client_token.write 权限。使用 Paddle CLI 时,运行 paddle --environment sandbox client-tokens create --json '{"name": "Frontend dev token"}' --jq '.data.token'。

Paddle MCP 为每个服务提供三个工具(search、execute、report_missing_tool)。工作流:调用 search 确认准确的方法名称和参数结构,然后调用 execute,并传入一个会调用 client.<resource>.<operation>(...) 的异步函数。方法路径采用 camelCase(client.clientTokens.create、client.pricingPreview.preview)。请求体参数和响应字段采用 snake_case(tax_category、product_id、unit_price、currency_code)。列表方法返回 { pagination: { hasMore, estimatedTotal }, <resource>: [...] },而不是 data。数组的键是资源的 API 路径片段,因此当方法路径采用 camelCase 时,它会使用 kebab-case:(await client.products.list()).products、(await client.clientTokens.list())["client-tokens"]。当 pagination.hasMore 为 true 时,使用 { after: "<last_id>" } 翻页,而不是 .next()。未分页的列表(例如 client.currencies.list())会返回普通数组。单实体方法会返回该实体。在一次 execute 中串联多步工作流;变量不会在不同调用之间保留。硬性上限:每次 execute 最多 50 次 API 调用、超时 30 秒,代码最多 32,000 个字符。

Paddle CLI(paddle,v0.2.0 或更高版本)可使用用户保存的登录信息从终端执行 Paddle API 操作。资源命令与 API 参考文档对应:paddle products create 表示“创建产品”。运行 paddle <resource> --help 查找命令;在 create 或 update 命令中添加 --schema 可查看其请求体;对于没有对应命令的端点,可使用 paddle api <path>。它默认连接实时账户,因此除非用户正在使用实时环境,否则请传入 --environment sandbox。Agent 运行该命令时,它会返回 API 的 JSON(data、meta),其中 meta.environment 会标明所用账户。取消和删除命令需要 --confirm <id>。只有在用户已同意执行特定取消或删除操作后,才添加此参数;绝不能用它来绕过用户的拒绝。paddle api 的写入操作完全不会经过检查,因此应以相同方式处理。退出代码 5 表示 403:权限缺失,或登录的是另一个账户。退出代码 7 表示验证错误。如果 paddle whoami 显示未登录,请让用户自行运行 paddle login(sandbox 环境需要 API 密钥:paddle login --environment sandbox --api-key …),绝不要在聊天中索要密钥。使用 OAuth 登录的实时账户可以执行用户的 Paddle 角色所允许的任何更改,因此每次实时更改都要明确说明,并在执行前等待用户确认。对于 CI/CD,请将仅包含所需权限的实时 API 密钥设为 PADDLE_API_KEY。安装方法请参阅 Paddle CLI。

安装客户端库:

npm install @paddle/paddle-js

下面的内联示例使用 lodash.throttle 限制 updateItems 的调用频率。如果采用此模式,也请安装它及其类型:

npm install lodash.throttle
npm install -D @types/lodash.throttle

Node SDK(@paddle/paddle-node-sdk)用于服务端工作;结账不需要它。

选择结账样式

样式适用场景用户看到的内容
覆盖层集成最快。显示在当前页面上方。覆盖页面的模态窗口
内嵌需要完全控制周围布局和品牌样式时。结账字段会显示在由你控制的 <div> 中

两者都使用相同的 Paddle.Checkout.open() 调用,唯一的区别是 displayMode 设置和渲染位置。除非需要品牌化的内嵌结账,否则默认使用 覆盖层。覆盖层无需更改布局即可使用;内嵌结账则需要一个目标元素。

选择结账变体

变体说明
单页单页结账体验,所有字段(客户和付款详情)都显示在同一屏幕上。
多页两页结账:第一页收集客户详情,第二页收集付款详情。
快捷结账用于快捷钱包(Apple Pay、Google Pay)的内嵌式结账。

多页是默认选项。大多数用例推荐使用单页。

快捷结账(variant: "express")有额外规则:

  • 仅支持内嵌模式(displayMode: "inline")。
  • 如果将其与已认证客户(customerAuthToken)、upsell、allowedPaymentMethods 或交易中的企业结合使用,结账将无法打开。
  • showAddDiscounts、allowDiscountRemoval、showAddTaxId 和 allowLogout 均会被忽略。
  • 除非设置 showNonExpressPaymentMethods: false,否则其他付款方式会与钱包一同显示(默认为 true)。
  • express 需要 @paddle/paddle-js 1.6.4+ 才能使用相应类型,showNonExpressPaymentMethods 则需要 1.6.5+。

覆盖层结账 — 最简可行集成

这是最快的方式:用一个按钮打开某个价格的结账页面。推荐使用。

// app/buy/page.tsx
"use client";

import { initializePaddle, type Paddle } from "@paddle/paddle-js";
import { useEffect, useState } from "react";

export default function BuyPage() {
  const [paddle, setPaddle] = useState<Paddle | null>(null);

  useEffect(() => {
    if (!process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN || !process.env.NEXT_PUBLIC_PADDLE_ENV) {
      return;
    }
    initializePaddle({
      token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
      environment: process.env.NEXT_PUBLIC_PADDLE_ENV as "sandbox" | "production",
    }).then((p) => p && setPaddle(p));
  }, []);

  function openCheckout() {
    paddle?.Checkout.open({
      items: [{ priceId: "pri_01h...", quantity: 1 }],
      settings: {
        variant: "one-page", // or "multi-page"
      },
    });
  }

  return (
    <button onClick={openCheckout} disabled={!paddle}>
      Buy now
    </button>
  );
}

覆盖层结账就这么简单——无需设置 displayMode(覆盖层是默认模式)。用户会看到模态框,完成付款后,Paddle 会处理成功页面。

带完整事件处理的内嵌结账

当你需要在其他 UI 旁边呈现结账页面(例如价格摘要、品牌化布局、自定义成功状态)时,内嵌结账正适合你。完整模式如下:

// app/checkout/[priceId]/checkout-contents.tsx
"use client";

import { type Environments, initializePaddle, type Paddle } from "@paddle/paddle-js";
import type { CheckoutEventsData } from "@paddle/paddle-js/types/checkout/events";
import throttle from "lodash.throttle";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";

interface Props {
  userEmail?: string;
}

export function CheckoutContents({ userEmail }: Props) {
  const { priceId } = useParams<{ priceId: string }>();
  const [quantity, setQuantity] = useState(1);
  const [paddle, setPaddle] = useState<Paddle | null>(null);
  const [checkoutData, setCheckoutData] = useState<CheckoutEventsData | null>(null);

  // Throttle updateItems to avoid hammering Paddle on rapid quantity changes.
  const updateItems = useCallback(
    throttle((paddle: Paddle, priceId: string, quantity: number) => {
      paddle.Checkout.updateItems([{ priceId, quantity }]);
    }, 1000),
    [],
  );

  useEffect(() => {
    if (paddle?.Initialized) return;
    if (!process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN) return;

    initializePaddle({
      token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN,
      environment: process.env.NEXT_PUBLIC_PADDLE_ENV as Environments,
      eventCallback: (event) => {
        if (event.name && event.data) {
          // Every data event, checkout.loaded included, carries the latest totals.
          setCheckoutData(event.data);
        } else if (event.type === "checkout.warning") {
          // Warnings have no `name`: checkout opened, but some values were dropped.
          console.warn("[Paddle]", event.detail);
        } else if (event.name) {
          // checkout.error / checkout.payment.error: flat { name, type, code, detail, documentation_url }.
          console.error("[Paddle]", event.name, event.code, event.detail);
        }
      },
      checkout: {
        settings: {
          variant: "one-page",
          displayMode: "inline",
          theme: "dark",
          allowLogout: !userEmail,
          frameTarget: "paddle-checkout-frame",
          frameInitialHeight: 450,
          frameStyle: "width: 100%; background-color: transparent; border: none",
          successUrl: `${window.location.origin}/checkout/success`, // must be absolute
        },
      },
    }).then((p) => {
      if (p && priceId) {
        setPaddle(p);
        p.Checkout.open({
          ...(userEmail && { customer: { email: userEmail } }),
          items: [{ priceId, quantity }],
        });
      }
    });
  }, [paddle?.Initialized, priceId, userEmail]);

  useEffect(() => {
    if (paddle?.Initialized && priceId) {
      updateItems(paddle, priceId, quantity);
    }
  }, [paddle, priceId, quantity, updateItems]);

  return (
    <div>
      <PriceSection
        checkoutData={checkoutData}
        quantity={quantity}
        onQuantityChange={setQuantity}
      />
      {/* The class name here MUST match `frameTarget` above. */}
      <div className="paddle-checkout-frame" />
    </div>
  );
}

需要注意三点:

  1. frameTarget 是一个 CSS 类名(前面不带点)。无论你在此处设置什么,都必须同时渲染一个带有完全相同类名的元素。Paddle 会将 iframe 注入其中。
  2. eventCallback 会针对每个事件触发——使用 event.name 区分事件(checkout.loaded、checkout.items.updated、checkout.completed、checkout.payment.failed 等)。示例会保留最新的事件数据,以便同级组件显示价格总额。从每个数据事件中获取数据(包括 checkout.loaded),可确保总额在首次更新前就正确显示。错误和警告事件的结构有所不同——请参阅下文的“读取结账状态”。
  3. successUrl 是 Paddle 在结账完成后重定向到的地址。它必须是绝对 http(s):// URL,因此请通过 window.location.origin 构造。Checkout.open() 遇到相对 URL 会抛出异常;传给 initializePaddle 的相对 URL 则会被丢弃:结账会触发 checkout.warning,且不会重定向。请将此地址设为处理购买后体验的路由(例如显示订单确认),但不要依赖它来配置服务;Webhook 才是事实来源。

预填客户信息

如果用户已经通过身份验证,请传入其邮箱,以免他们手动输入:

paddle.Checkout.open({
  customer: { email: "[email protected]" },
  items: [{ priceId: "pri_01h...", quantity: 1 }],
});

若要进一步完善,可传入现有的 Paddle 客户 ID(通过 API 创建或由先前的结账返回):

paddle.Checkout.open({
  customer: { id: "ctm_01h..." },
  items: [{ priceId: "pri_01h...", quantity: 1 }],
});

在结账设置中将 allowLogout: false 设为可防止用户退出预填的会话——如果你已对用户进行身份验证,这会很有用。

动态行项目

如需更改数量、替换价格或添加其他商品而不关闭结账,请调用 Paddle.Checkout.updateItems()。它会替换整个列表——未包含的商品都会被移除:

paddle.Checkout.updateItems([
  { priceId: "pri_01h...", quantity: 3 },
  { priceId: "pri_02h...", quantity: 1 },
]);
  • 周期性商品必须使用相同的计费周期和试用期。
  • 空列表或任何 quantity 小于 1 的商品都会被忽略。如需移除商品,请发送不包含该商品的列表。
  • 调用前,请确认列表符合这些规则。

务必对这些调用进行节流(默认 1 秒较合适——参见上面的 lodash.throttle 示例)。Paddle 每次调用都会重新渲染结账界面;不进行节流的更新会导致闪烁,并有触发速率限制的风险。

读取结账状态

接入 eventCallback,以驱动你自己的 UI(累计金额、行项目明细、已应用的折扣)。最有用的事件:

事件触发时机常见用途
checkout.loaded结账界面完成首次渲染隐藏加载指示器,设置初始金额
checkout.items.updated行项目发生变化(包括通过 updateItems 更新)更新相邻的价格摘要
checkout.customer.created结账过程中创建了新客户获取新的 customer.id
checkout.payment.selected用户选择了支付方式按地区有条件地显示文案
checkout.completed客户完成结账——支付可能仍在处理中显示感谢页面(通过 webhook 授予访问权限)
checkout.error打开结账界面时发生错误显示备用方案(mailto、支持链接)
checkout.payment.failed支付尝试失败(银行卡被拒、3DS、电子钱包)显示重试指引——切勿显示原始错误
checkout.payment.error没有可用的支付方式(code: "no_payment_methods_available")显示备用方案(mailto、支持链接)
checkout.warning结账界面已打开,但部分预填或设置值被丢弃在开发过程中记录,以便发现无效输入

错误事件(checkout.error、checkout.payment.error)没有 data。从 @paddle/paddle-js 1.6.1 起,事件的顶层字段包含 name、type、code、detail 和 documentation_url。checkout.warning 完全没有 name,因此请使用 event.type === "checkout.warning" 来匹配。

完整列表见 developer.paddle.com/paddle-js/events。

结账后:重定向与 webhook

结账完成时,会并行发生两件事:

  1. 结账界面触发 checkout.completed,并将用户的浏览器发送到你的 successUrl(或调用你的自定义事件处理程序)。对于某些支付方式,此时支付仍在处理中。
  2. Paddle 会向你的服务器发送 transaction.completed(也可能发送 subscription.created)webhook。

应在 webhook 中处理资源开通,而不是在重定向或 checkout.completed 中处理。重定向用于改善用户体验(显示“感谢下单”);webhook 则是持久化、会重试且带签名的事件,值得信任。请参阅 webhooks 了解设置方法。

常见问题

  • “Something went wrong” 消息 — 通常表示没有默认支付链接,或者在正式环境中,域名尚未获准用于结账。使用 Paddle MCP 服务时,请检查这两项:client.settings.checkoutUrl.get()(GET /settings/checkout-url)应返回 default_url,且在正式环境中,client.checkoutDomains.list()(GET /checkout-domains)应将该 URL 的域名列为 approved。不使用 MCP 时,请让用户检查 Paddle > Checkout > Checkout configuration 和 Paddle > My account > Settings > Website approval。
  • Paddle is not defined — 忘记在调用 Paddle.Checkout.open() 之前等待 initializePaddle() 完成。该 Promise 会解析为 Paddle 对象;只有在此之后才能打开结账页面。
  • 结账页面无法以内嵌方式呈现 — frameTarget 类名与已渲染的元素不匹配,或者运行 Checkout.open() 时该元素尚未位于 DOM 中。先渲染目标元素,再在 useEffect 中打开结账页面。
  • 沙盒与正式环境不一致 — NEXT_PUBLIC_PADDLE_ENV 控制 Paddle.js 所连接的环境。如果客户端令牌属于沙盒,但你设置了 NEXT_PUBLIC_PADDLE_ENV=production,结账页面将无法加载。令牌和价格均按环境区分——沙盒中的 pri_... ID 在正式环境中不存在。
  • 将价格 ID 写入客户端代码 — 用于快速原型开发没问题,但对于实际应用,应在获取目录后从服务器加载价格 ID,这样无需重新部署即可替换。
  • 使用重定向或 checkout.completed 事件进行资源配置 — checkout.completed 可能在支付仍处于处理过程中时触发,而且用户可能关闭标签页、断开连接或阻止重定向。Webhook 才是事实来源。
  • 相对路径的 successUrl — Checkout.open() 会抛出异常,而通过 initializePaddle 设置的 successUrl 则会被丢弃,且只会显示一条 checkout.warning。请使用 `${window.location.origin}/checkout/success`。
  • 限制 updateItems 的调用频率 — 如果不限制调用频率,快速更改数量会导致闪烁,还可能触发速率限制。1 秒是合理的默认值。
  • 调用两次 initializePaddle — 第二次调用不会重新初始化,而是使用新选项调用 Paddle.Update(),因此新选项会替换首次设置的选项。请使用 paddle?.Initialized 进行保护(如示例所示),或采用单例模式;并将每次结账的设置传入 Paddle.Checkout.open({ settings })。

验证集成

  1. 运行 npm run dev,并前往结账页面。
  2. 确认结账页面已加载或 iframe 已渲染,且价格符合预期。
  3. 使用 Paddle 沙盒测试卡,例如 4242 4242 4242 4242,并使用任意未来到期日期和任意 3 位 CVC。
  4. 完成购买并确认:
    • 浏览器跳转到了你的 successUrl。
    • checkout.completed 事件已触发(eventCallback 中的 console.log)。
    • 在 Paddle 控制面板(沙盒)中,可在 Transactions 下看到该交易。
  5. 如需测试 webhook 端,请参阅 sandbox-testing 中的模拟器。

相关文档

评论

支持 Markdown