关于 paddle-checkout-web
用于在 Next.js App Router 应用中接入 Paddle 托管结账的技能。内容涵盖浮层和内嵌结账、事件处理、客户信息预填、跳转及动态购物车更新,并将相关服务端任务另行处理。
主要功能
- 01配置浮层或内嵌结账
- 02处理结账事件和跳转
- 03预填已登录客户资料
- 04节流更新购物车商品
- 05说明结账形式及其限制
适用场景
- 01在 Next.js 应用中接入托管结账
- 02构建品牌化内嵌支付页面
- 03为已登录客户预填结账资料
- 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-js1.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>
);
}
需要注意三点:
frameTarget是一个 CSS 类名(前面不带点)。无论你在此处设置什么,都必须同时渲染一个带有完全相同类名的元素。Paddle 会将 iframe 注入其中。eventCallback会针对每个事件触发——使用event.name区分事件(checkout.loaded、checkout.items.updated、checkout.completed、checkout.payment.failed等)。示例会保留最新的事件数据,以便同级组件显示价格总额。从每个数据事件中获取数据(包括checkout.loaded),可确保总额在首次更新前就正确显示。错误和警告事件的结构有所不同——请参阅下文的“读取结账状态”。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
结账完成时,会并行发生两件事:
- 结账界面触发
checkout.completed,并将用户的浏览器发送到你的successUrl(或调用你的自定义事件处理程序)。对于某些支付方式,此时支付仍在处理中。 - 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 })。
验证集成
- 运行
npm run dev,并前往结账页面。 - 确认结账页面已加载或 iframe 已渲染,且价格符合预期。
- 使用 Paddle 沙盒测试卡,例如
4242 4242 4242 4242,并使用任意未来到期日期和任意 3 位 CVC。 - 完成购买并确认:
- 浏览器跳转到了你的
successUrl。 checkout.completed事件已触发(eventCallback中的console.log)。- 在 Paddle 控制面板(沙盒)中,可在 Transactions 下看到该交易。
- 浏览器跳转到了你的
- 如需测试 webhook 端,请参阅
sandbox-testing中的模拟器。
相关文档
- Paddle.js 概览
- 构建覆盖式结账页面
- 构建品牌化内嵌结账页面
- 结账事件参考
Paddle.Checkout.open参考- 默认支付链接与域名审批
- 参考实现:paddle-nextjs-starter-kit — 请参阅
src/components/checkout/checkout-contents.tsx。